# Provider Form Dirty State Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Disable persistence buttons until provider data changes, retain retry behavior after failures, and make schedule removal/selection controls compact and aligned.

**Architecture:** Add a focused browser-only dirty-state module backed by a `WeakMap` of canonical form snapshots. Existing form policies remain responsible for editability and busy state, then delegate submit availability to the tracker; render/save boundaries explicitly establish a new pristine baseline. CSS overrides use selectors more specific than the generic operation-label grid without changing native control semantics.

**Tech Stack:** Laravel Blade, plain JavaScript concatenated by Laravel Mix, CSS, Node assertion tests, PHPUnit feature tests.

---

## File structure

- Create `resources/js/provider/09-editor-dirty-state.js`: canonical snapshots, event binding, pristine/dirty transitions, and submit synchronization.
- Modify `resources/js/provider/09-editor-contracts.js`: expose busy state to CSS with `aria-busy`.
- Modify `resources/js/provider/05-profile-view.js` and `06-profile-viewmodel.js`: apply and reset dirty policy around profile renders/saves.
- Modify `resources/js/provider/07-categories-view.js` and `08-categories-viewmodel.js`: preserve category-specific radio locking while tracking selection changes.
- Modify `resources/js/provider/11-locations-view.js`, `12-location-browser.js`, and `12-locations-viewmodel.js`: track initial/new locations, programmatic map changes, and successful saves.
- Modify `resources/js/provider/13-operations-ui.js`, `14-services-viewmodel.js`, `15-attributes-viewmodel.js`, `16-schedules-viewmodel.js`, and `17-contacts-viewmodel.js`: share dirty policy across persistent location operations while excluding the new-contact form.
- Modify `public/css/provider/portal/05-forms.css` and `05-controls.css`: correct disabled cursors, compact danger actions, and inline 20px choices.
- Modify `webpack.mix.js` and generated `public/js/provider/app.js`: include and compile the new module.
- Create `tests/js/provider-form-dirty-state.test.js`: focused state-machine regression coverage.
- Modify `tests/js/provider-payloads.test.js`, `provider-location.test.js`, `provider-operations.test.js`, and `provider-control-css.test.js`: integration and visual contract coverage.
- Modify `package.json`: include the focused test in `test:provider`.

### Task 1: Add the dirty-state state machine with TDD

**Files:**
- Create: `tests/js/provider-form-dirty-state.test.js`
- Create: `resources/js/provider/09-editor-dirty-state.js`
- Modify: `package.json`
- Modify: `webpack.mix.js`

- [ ] **Step 1: Write the failing focused state test**

Create a small fake form/control implementation and assert this public contract:

```javascript
providerBindFormDirtyState(form);
assert.strictEqual(submit.disabled, true);

name.value = 'Sucursal Norte';
form.listeners.input({ target: name });
assert.strictEqual(providerIsFormDirty(form), true);
assert.strictEqual(submit.disabled, false);

name.value = 'Casa matriz';
form.listeners.input({ target: name });
assert.strictEqual(providerIsFormDirty(form), false);
assert.strictEqual(submit.disabled, true);

providerMarkFormPristine(form);
providerApplyFormDirtyPolicy(form, true);
assert.strictEqual(submit.disabled, true);
```

Also cover checkbox state, row order, ignored unnamed toolbar controls, failure-preserving refresh, and absence of tracking on an excluded form.

- [ ] **Step 2: Run the focused test and confirm RED**

Run: `node tests/js/provider-form-dirty-state.test.js`

Expected: failure because `providerBindFormDirtyState` is not defined.

- [ ] **Step 3: Implement the minimal tracker**

Implement this state machine in a module under 150 lines:

```javascript
var providerFormDirtyStates = new WeakMap();

function providerDirtyFormControls(form) {
    return Array.from(form?.querySelectorAll?.('input, select, textarea') || [])
        .filter((control) => {
            const type = String(control.type || '').toLowerCase();
            const ignored = control.dataset
                && Object.prototype.hasOwnProperty.call(control.dataset, 'providerDirtyIgnore');
            const key = control.name || control.dataset?.field;
            return Boolean(key) && !ignored && !['file', 'button', 'submit', 'reset', 'image'].includes(type)
                && !['_token', 'access_token'].includes(control.name);
        });
}

function providerFormSnapshot(form) {
    return JSON.stringify(providerDirtyFormControls(form).map((control) => ({
        key: control.name || control.dataset.field,
        type: String(control.type || control.tagName || '').toLowerCase(),
        value: control.value ?? '',
        checked: ['checkbox', 'radio'].includes(control.type) ? control.checked === true : null,
    })));
}

function providerRefreshFormDirtyState(form, locked) {
    const state = providerFormDirtyStates.get(form);
    if (!state) return false;
    if (typeof locked === 'boolean') state.locked = locked;
    state.dirty = providerFormSnapshot(form) !== state.baseline;
    if (form.dataset) form.dataset.providerDirty = state.dirty ? 'true' : 'false';
    const submit = form.querySelector?.('button[type="submit"]');
    if (submit) submit.disabled = state.locked || providerIsEditorBusy(form) || !state.dirty;
    return state.dirty;
}

function providerMarkFormPristine(form) {
    const state = providerFormDirtyStates.get(form);
    if (!state) return false;
    state.baseline = providerFormSnapshot(form);
    return providerRefreshFormDirtyState(form);
}

function providerBindFormDirtyState(form) {
    if (!form) return false;
    if (!providerFormDirtyStates.has(form)) {
        providerFormDirtyStates.set(form, { baseline: '', dirty: false, locked: false });
        const refresh = () => providerRefreshFormDirtyState(form);
        form.addEventListener?.('input', refresh);
        form.addEventListener?.('change', refresh);
    }
    providerMarkFormPristine(form);
    return true;
}

function providerIsFormDirty(form) {
    return providerFormDirtyStates.get(form)?.dirty === true;
}

function providerApplyFormDirtyPolicy(form, locked) {
    providerSetEditorControlsDisabled(form, locked);
    providerRefreshFormDirtyState(form, locked);
}

function providerRefreshClosestDirtyForm(node) {
    let current = node;
    while (current && String(current.tagName || '').toUpperCase() !== 'FORM') {
        current = current.parentNode;
    }
    return providerRefreshFormDirtyState(current);
}
```

Persisted controls are named inputs/selects/textareas or controls carrying `data-field`; buttons, file inputs, and `data-provider-dirty-ignore` controls are excluded. Checkbox/radio values include `checked`; every other entry includes key, type, and value in DOM order. `providerApplyFormDirtyPolicy` must only gate submit buttons for forms registered in the `WeakMap`, leaving login, upload, verification, review, and new-contact actions unchanged.

- [ ] **Step 4: Register the module and test**

Insert `resources/js/provider/09-editor-dirty-state.js` immediately after `09-editor-contracts.js` in the provider Mix source list. Add `node tests/js/provider-form-dirty-state.test.js` to `test:provider` before the larger provider integration tests.

- [ ] **Step 5: Run the focused test and confirm GREEN**

Run: `node tests/js/provider-form-dirty-state.test.js`

Expected: `provider-form-dirty-state.test.js: PASS`.

### Task 2: Integrate persistence forms with TDD

**Files:**
- Modify: `tests/js/provider-payloads.test.js`
- Modify: `tests/js/provider-location.test.js`
- Modify: `tests/js/provider-operations.test.js`
- Modify: `resources/js/provider/09-editor-contracts.js`
- Modify: `resources/js/provider/05-profile-view.js`
- Modify: `resources/js/provider/06-profile-viewmodel.js`
- Modify: `resources/js/provider/07-categories-view.js`
- Modify: `resources/js/provider/08-categories-viewmodel.js`
- Modify: `resources/js/provider/11-locations-view.js`
- Modify: `resources/js/provider/12-location-browser.js`
- Modify: `resources/js/provider/12-locations-viewmodel.js`
- Modify: `resources/js/provider/13-operations-ui.js`
- Modify: `resources/js/provider/14-services-viewmodel.js`
- Modify: `resources/js/provider/15-attributes-viewmodel.js`
- Modify: `resources/js/provider/16-schedules-viewmodel.js`
- Modify: `resources/js/provider/17-contacts-viewmodel.js`

- [ ] **Step 1: Add failing integration assertions**

Extend the current fake forms to prove:

```javascript
// Initial render/save baseline
context.providerBindFormDirtyState(existingForm);
assert.strictEqual(existingSubmit.disabled, true);

// Mutation enables persistence
existingControl.value = 'Nuevo valor';
context.providerRefreshFormDirtyState(existingForm);
assert.strictEqual(existingSubmit.disabled, false);

// Success establishes a new baseline
await context.providerSubmitOperation(existingForm, buildRows, save, render, generation);
assert.strictEqual(context.providerIsFormDirty(existingForm), false);
assert.strictEqual(existingSubmit.disabled, true);

// Error remains retryable
await context.providerSubmitOperation(existingForm, buildRows, failingSave, render, generation);
assert.strictEqual(context.providerIsFormDirty(existingForm), true);
assert.strictEqual(existingSubmit.disabled, false);
```

Add equivalent assertions for profile success, category selection, location/map changes, dynamic add/remove rows, schedules/exceptions, specialties/features, existing contacts, and ensure `provider-contact-create-form` remains untracked and enabled when editable.

- [ ] **Step 2: Run integration tests and confirm RED**

Run:

```bash
node tests/js/provider-payloads.test.js
node tests/js/provider-location.test.js
node tests/js/provider-operations.test.js
```

Expected: assertions fail because render/save boundaries do not yet bind or reset dirty state.

- [ ] **Step 3: Expose busy semantics**

In `providerBeginEditorOperation`, set `aria-busy="true"` before disabling controls. In `providerEndEditorOperation`, remove `aria-busy` only for the current operation. Preserve duplicate-submit protection.

- [ ] **Step 4: Integrate profile and categories**

- Bind the profile form after its initial authoritative render.
- Call `providerMarkFormPristine(form)` after a successful profile render.
- Replace profile restore logic with `providerApplyFormDirtyPolicy(form, locked)` after normal editability handling.
- Bind categories after catalogs/selection render.
- Refresh the tracked state after category selection rerenders.
- Mark categories pristine only after a successful API response.
- Keep unselected primary radios disabled independently of submit state.

- [ ] **Step 5: Integrate locations**

- Bind location tracking before user interaction.
- Mark the form pristine after the initial location/map state is established and after successful persistence.
- Refresh on `providerApplyMapChange` because map values are assigned programmatically.
- Have `providerLocationFormPolicy` use the shared policy after applying provider/editability locks.
- Ensure an empty create form starts pristine and becomes dirty through normal `input`/`change` events.

- [ ] **Step 6: Integrate location operation forms**

- `providerOperationFormPolicy` applies editability/busy state, restores schedule mode-specific disabled time controls, then gates only tracked submit buttons.
- `providerSubmitOperation` calls `providerMarkFormPristine(form)` only after the success renderer finishes.
- `providerAppendOperationRow` and `providerRemoveOperationRow` refresh the closest tracked form.
- Bind services, specialties, features, schedules, and exceptions after their authoritative initial render.
- Bind only existing contact cards; do not register `provider-contact-create-form`.

- [ ] **Step 7: Run integration tests and confirm GREEN**

Run the three commands from Step 2.

Expected: all three scripts print their existing PASS messages with no assertion failures.

### Task 3: Correct control presentation with TDD

**Files:**
- Modify: `tests/js/provider-control-css.test.js`
- Modify: `public/css/provider/portal/05-forms.css`
- Modify: `public/css/provider/portal/05-controls.css`

- [ ] **Step 1: Write failing CSS contract assertions**

Assert the exact visual contract:

```javascript
assert.match(forms, /button:disabled[^}]*cursor:\s*not-allowed/s);
assert.match(forms, /\[aria-busy="true"\][^{]*button:disabled[^}]*cursor:\s*wait/s);
assert.match(controls, /\.provider-button--compact\s*\{[^}]*width:\s*max-content/s);
assert.match(controls, /\.provider-operation-row\s+\.provider-check\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s);
assert.match(controls, /\.provider-operation-row\s+\.provider-check\s+input[^}]*width:\s*20px[^}]*height:\s*20px/s);
```

Also assert the mobile rule no longer expands `.provider-button--compact` to `width: 100%`.

- [ ] **Step 2: Run CSS test and confirm RED**

Run: `node tests/js/provider-control-css.test.js`

Expected: failure on cursor, compact width, and operation check alignment assertions.

- [ ] **Step 3: Implement focused CSS overrides**

- Change ordinary disabled buttons to `cursor: not-allowed`.
- Add a higher-specificity busy-form rule using `[aria-busy="true"]` with `cursor: wait`.
- Give `.provider-button--compact` intrinsic/max-content width, `justify-self: start`, and `align-self: end`.
- Remove the mobile full-width compact override.
- Add `.provider-operation-row .provider-check` as a flex row with centered alignment, zero margin, and a minimum usable height.
- Add a 20px input rule specific enough to beat `.provider-operation-row input[type="checkbox"]` from the generic forms stylesheet.

- [ ] **Step 4: Run CSS test and confirm GREEN**

Run: `node tests/js/provider-control-css.test.js`

Expected: `provider-control-css.test.js: PASS`.

### Task 4: Compile and verify the complete change

**Files:**
- Modify generated: `public/js/provider/app.js`
- Verify: all files above

- [ ] **Step 1: Compile production assets**

Run: `npm run production`

Expected: Mix exits 0 and regenerates `public/js/provider/app.js` without compilation errors. Remove the generated untracked `public/mix-manifest.json`, matching the repository deployment convention.

- [ ] **Step 2: Run focused and full provider checks**

Run:

```bash
npm run test:provider
php artisan architecture:audit --strict
php artisan test --compact
```

Expected: every command exits 0; provider JavaScript tests, strict architecture audit, and the complete PHPUnit suite pass.

- [ ] **Step 3: Review the change graph and diff**

Update the knowledge graph, then run `detect_changes` and `get_affected_flows` against `origin/master`. Review `git diff --check`, confirm no secrets or unrelated generated/cache files are staged, and verify every design criterion maps to a passing test.

- [ ] **Step 4: Handle the version hook and commit**

Stage only the implementation, tests, generated provider bundle, plan, and required version file. Commit with `fix(provider): gate unchanged forms and align schedule controls`. In the interactive hook choose option `1` to increment the patch version from `1.8.2` to `1.8.3`, then rerun version-sensitive tests if the hook changes tracked files.

- [ ] **Step 5: Integrate and push**

Fast-forward or cherry-pick the verified worktree commits into local `master` without overwriting unrelated user changes, then push `master` to `origin`. Confirm `origin/master` contains the implementation commit and report the exact web version and droplet deployment commands.
