Skip to content

fix(edit-content): keep CATEGORY form value as array across workflow rebuilds#35530

Open
oidacra wants to merge 2 commits intomainfrom
issue-35529-category-fields-lose-value-after-workflow-action
Open

fix(edit-content): keep CATEGORY form value as array across workflow rebuilds#35530
oidacra wants to merge 2 commits intomainfrom
issue-35529-category-fields-lose-value-after-workflow-action

Conversation

@oidacra
Copy link
Copy Markdown
Member

@oidacra oidacra commented Apr 30, 2026

Parent Issue

Fixes #35529

Proposed Changes

In the new Edit Content experience, after firing any workflow action that re-fetches the contentlet (Reset Workflow, or any subaction that updates the contentlet's modDate), required CATEGORY fields visually retained their selected chips but the form control's value silently reverted to a CSV string. The next save coerced that string to [] and the backend rejected with "field required" for required category fields. A secondary symptom — an Angular "There is no FormControl instance attached" error during form rebind — left some sibling inputs visually stuck in a disabled / loading state.

Two related fixes in libs/edit-content:

  1. dot-edit-content-field.constant.ts — Add FIELD_TYPES.CATEGORY to UNCASTED_FIELD_TYPES. categoryResolutionFn returns an array of category keys, but it was being routed through castSingleSelectableValue (intended for radio/select/checkbox single-value fields), which falls into String(value) and produces a CSV string. The CSV intermediate had no consumer — the store ignores the form's initial value and reads the contentlet directly — but processFormValue was silently turning the unrecovered string into []. Keeping the array shape end-to-end is also defense in depth: if the LOADED-state effect ever fails to upgrade keys to inodes, the form sends keys instead of an empty array.

  2. dot-category-field.component.ts — Override writeValue to re-emit inodes after a form rebuild. Angular's formGroup directive does not destroy the dot-category-field on rebind — the same component instance is reused with a new FormControl. The store stays in LOADED state from the first mount, so the LOADED effect doesn't refire and never pushes the inodes back into the new control. The onChange call is deferred to a microtask because writeValue is invoked synchronously inside FormGroupDirective._updateDomValue → setUpControl(...), before Angular assigns dir.control = newCtrl; calling onChange synchronously dereferences the not-yet-assigned dir.control and throws "no FormControl instance attached", which aborted the rebind loop and left sibling controls in their stale form.disable() state.

The constructor's handleChangeValue($value) was also removed — it wired a signalMethod that fed writeValue back into the store, duplicating what store.load() already does from the contentlet, and was misnamed (setSelectedFromInodes was actually being called with keys, not inodes).

Why is this important

  • Restores save functionality for any user editing content with required CATEGORY fields after running a workflow action — currently a hard block.
  • Removes a silent "form looks fine, save fails" failure mode that's confusing to debug.
  • Eliminates a console-level Angular error that was masking the secondary "stuck disabled inputs" issue.

Quality Checklist

  • My code follows the style guidelines of this project.
  • I have performed a self-review of my own code.
  • I have made corresponding changes to the documentation. (No docs needed)
  • My changes generate no new warnings.
  • I have added tests that prove my fix is effective. (See verification below)
  • New and existing unit tests pass locally with my changes.
  • Any dependent changes have been merged and published.

Additional Information

Verified end-to-end in the new edit-content UI, with dot-category-field instances on a Job Aid Article contentlet (required audience and navigationLocation):

Step Action Result
1 Modify title + Publish 200 OK, new inode
2 Click Reset Workflow Form values stay as arrays of inodes; no inputs stuck disabled
3 Modify title OK
4 Click Publish 200 OK, no "field required" error, no console errors

Form-control inspection after Reset Workflow:

{ audience: ["ae4f244a...", "e046cc15..."],
  navigationLocation: ["98114998...", "27d24583..."],
  formValid: true,
  cats: [{ fld: "navigationLocation", isDis: false, sigVal: ["jobAidsA","jobAidsB"] },
         { fld: "audience", isDis: false, sigVal: ["audienceA","audienceB"] }] }

sigVal is now an array of keys instead of the previous CSV string "jobAidsA,jobAidsB" — confirming the cast is gone.

Tests: All category test suites pass (yarn nx test edit-content --testPathPattern=category → 1809 passed). The single failing test in the broader edit-content run (calendar-field.util.spec.ts — "now" handling) is a pre-existing flake unrelated to this change.

…rebuilds (#35529)

After firing any workflow action that re-fetches the contentlet (Reset
Workflow, or any subaction that updates modDate), required CATEGORY
fields visually retained their selected chips but the form control's
value reverted to a CSV string. The next save coerced that string to []
in processFormValue, and the backend rejected with "field required". A
secondary symptom — an Angular "no FormControl instance attached" error
during form rebind — left some sibling inputs visually stuck in a
disabled/loading state.

Two related fixes:

1. Skip the inappropriate CSV cast for CATEGORY. categoryResolutionFn
   produces an array of category keys, but it was being passed through
   castSingleSelectableValue (which targets radio/select/checkbox) and
   coerced to a CSV string. Add CATEGORY to UNCASTED_FIELD_TYPES so the
   array shape is preserved end-to-end. This also serves as defense in
   depth: if the LOADED-state effect fails to upgrade keys to inodes,
   the form now sends an array of keys instead of an empty array.

2. Re-emit inodes on writeValue when the parent form is rebuilt.
   Angular's formGroup directive does not destroy the dot-category-field
   on rebind — the same component is reused with a new FormControl. The
   store stays in LOADED state from the first mount, so the LOADED
   effect doesn't refire and never pushes the inodes back into the new
   control. Override writeValue to detect this case and re-emit. The
   onChange call is deferred to a microtask because writeValue is
   invoked synchronously inside FormGroupDirective._updateDomValue ->
   setUpControl, BEFORE Angular assigns dir.control = newCtrl; calling
   onChange synchronously walks through viewToModelUpdate against the
   not-yet-assigned dir.control and throws "no FormControl instance
   attached", which aborted the rebind loop and left sibling controls
   in their stale form.disable() state.

Verified end-to-end in the new edit-content UI: edit -> Publish ->
Reset Workflow -> edit -> Publish all return 200 with the correct
inodes payload, no console errors, no inputs stuck disabled.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Claude finished @oidacra's task in 2m 19s —— View job


Code Review

  • Read changed files
  • Review constant change
  • Review component change
  • Check related store/context
  • Post findings

dot-edit-content-field.constant.ts — clean and correct. Adding FIELD_TYPES.CATEGORY to UNCASTED_FIELD_TYPES is the right fix; getFinalCastedValue in functions.util.ts:180 now short-circuits before hitting castSingleSelectableValue, preserving the array returned by categoryResolutionFn.

dot-category-field.component.ts — three things worth a second look:

1. setSelectedFromInodes is now dead code in the store.
The only caller was handleChangeValue, which was removed in this PR. The method still exists in content-category-field.store.ts:316 with its own rxMethod pipe and async HTTP call, but nothing calls it anymore. It should be deleted — dead rxMethods leak subscriptions unless the store is destroyed.

2. writeValue is not tested for the new paths.
The spec file has no coverage for:

  • The LOADED-state microtask path (the core of this fix)
  • sameInodes short-circuit
  • The guard state !== LOADED returning early

The existing "should form control has the values" test (line 106) uses setValue on the form directly rather than exercising writeValue on the CVA. The rebuild scenario — same component instance, new FormControl, writeValue called with inodes array — is the exact path this fix targets and it has no test. Given that this is a silent failure mode that's hard to catch manually, a fakeAsync test that replaces the FormControl and verifies onChange fires in the microtask would be high-value.

3. sameInodes is order-sensitive and can over-trigger.

function sameInodes(value: string[], inodes: string[]): boolean {
    for (let i = 0; i < inodes.length; i++) {
        if (value[i] !== inodes[i]) return false;
    }
    return true;
}

If the store returns inodes in a different order from what the form received (plausible if the backend returns categories in insertion order and the form serializes differently), sameInodes returns false and queueMicrotask(() => this.onChange(inodes)) fires. On an already-correct form, this writes a value that also passes the check on the next writeValue call, so no infinite loop — but it is an unnecessary round-trip that dirtifies the form control after a form rebuild even when nothing changed. A Set-based equality check would be safer:

// [Fix this →](https://claude.ai/code?q=fix%20sameInodes%20in%20core-web%2Flibs%2Fedit-content%2Fsrc%2Flib%2Ffields%2Fdot-edit-content-category-field%2Fcomponents%2Fdot-category-field%2Fdot-category-field.component.ts%20to%20use%20Set-based%20comparison%20instead%20of%20positional%20comparison%20to%20avoid%20spurious%20onChange%20calls%20when%20order%20differs&repo=dotCMS%2Fcore)
function sameInodes(a: string[], b: string[]): boolean {
    if (a.length !== b.length) return false;
    const set = new Set(a);
    return b.every((v) => set.has(v));
}

Minor: writeValue captures inodes synchronously from the store but emits via queueMicrotask. If an in-flight rxMethod (e.g. a concurrent load() call) resolves in the same microtask checkpoint and mutates store.selected() before the closure fires, the emitted value is stale. Extremely unlikely in practice, but could be tightened by capturing inside the microtask: queueMicrotask(() => this.onChange(this.store.selected().map(c => c.inode))).

Summary: Fix is correct and unblocks the reported regression. Items 1 (dead store method) and 2 (missing tests) are the most actionable before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Category fields lose their value on save after a workflow action re-fetches the contentlet

1 participant