Skip to content

Commit 5ad05c9

Browse files
authored
fix(edit-content): keep CATEGORY form value as array across workflow rebuilds (#35530)
## 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 - [x] My code follows the style guidelines of this project. - [x] I have performed a self-review of my own code. - [x] I have made corresponding changes to the documentation. (No docs needed) - [x] My changes generate no new warnings. - [x] I have added tests that prove my fix is effective. (See verification below) - [x] New and existing unit tests pass locally with my changes. - [x] 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: ```js { 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.
1 parent 6bebb1d commit 5ad05c9

4 files changed

Lines changed: 64 additions & 17 deletions

File tree

core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field/dot-category-field.component.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { signalMethod } from '@ngrx/signals';
2-
31
import {
42
ChangeDetectionStrategy,
53
Component,
@@ -24,6 +22,7 @@ import { DotCategoryFieldDialogComponent } from './../dot-category-field-dialog/
2422
import { BaseControlValueAccessor } from '../../../shared/base-control-value-accesor';
2523
import { CategoriesService } from '../../services/categories.service';
2624
import { CategoryFieldStore } from '../../store/content-category-field.store';
25+
import { sameInodes } from '../../utils/category-field.utils';
2726

2827
/**
2928
* @class
@@ -88,19 +87,12 @@ export class DotCategoryFieldComponent
8887
*/
8988
$hasSelectedCategories = computed(() => this.store.selected().length > 0);
9089

91-
constructor() {
92-
super();
93-
this.handleChangeValue(this.$value);
94-
}
95-
9690
/**
9791
* Initialize the component.
9892
*
9993
* @memberof DotEditContentCategoryFieldComponent
10094
*/
10195
ngOnInit(): void {
102-
// Initialize the store with field information only
103-
// The contentlet data will come through ControlValueAccessor's writeValue
10496
this.store.load({
10597
field: this.$field(),
10698
contentlet: this.$contentlet()
@@ -144,18 +136,24 @@ export class DotCategoryFieldComponent
144136
this.onTouched();
145137
}
146138

147-
readonly handleChangeValue = signalMethod<string[]>((value) => {
148-
if (!value) {
149-
this.store.setSelectedFromInodes([]);
139+
override writeValue(value: string[]): void {
140+
super.writeValue(value);
150141

142+
if (this.store.state() !== ComponentStatus.LOADED) {
151143
return;
152144
}
153145

154-
if (!Array.isArray(value)) {
146+
const inodes = this.store.selected().map((category) => category.inode);
147+
if (inodes.length === 0) {
155148
return;
156149
}
157150

158-
// Update store with the new value
159-
this.store.setSelectedFromInodes(value);
160-
});
151+
if (Array.isArray(value) && sameInodes(value, inodes)) {
152+
return;
153+
}
154+
155+
// Defer to microtask: Angular calls writeValue from setUpControl
156+
// BEFORE assigning dir.control, so a sync onChange throws.
157+
queueMicrotask(() => this.onChange(inodes));
158+
}
161159
}

core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getSelectedFromContentlet,
99
removeEmptyArrays,
1010
removeItemByKey,
11+
sameInodes,
1112
transformCategories,
1213
updateChecked,
1314
getMenuItemsFromKeyParentPath
@@ -648,4 +649,26 @@ describe('CategoryFieldUtils', () => {
648649
expect(result).toEqual([]);
649650
});
650651
});
652+
653+
describe('sameInodes', () => {
654+
it('should return true for equal arrays in the same order', () => {
655+
expect(sameInodes(['a', 'b', 'c'], ['a', 'b', 'c'])).toBe(true);
656+
});
657+
658+
it('should return true for equal arrays in different order', () => {
659+
expect(sameInodes(['a', 'b', 'c'], ['c', 'a', 'b'])).toBe(true);
660+
});
661+
662+
it('should return false when arrays differ in length', () => {
663+
expect(sameInodes(['a', 'b'], ['a', 'b', 'c'])).toBe(false);
664+
});
665+
666+
it('should return false when arrays have the same length but different members', () => {
667+
expect(sameInodes(['a', 'b', 'c'], ['a', 'b', 'd'])).toBe(false);
668+
});
669+
670+
it('should return true for two empty arrays', () => {
671+
expect(sameInodes([], [])).toBe(true);
672+
});
673+
});
651674
});

core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,25 @@ export const getMenuItemsFromKeyParentPath = (
293293
export const removeEmptyArrays = (array: DotCategory[][]): DotCategory[][] => {
294294
return array.filter((item) => item.length > 0);
295295
};
296+
297+
/**
298+
* Shallowly compares two arrays of inode strings, ignoring order.
299+
*
300+
* Used by the category field's `ControlValueAccessor` to short-circuit
301+
* `writeValue` when the value the form is writing already matches the
302+
* inodes the store has selected, avoiding spurious `onChange` emissions
303+
* after a form rebuild (e.g. workflow action).
304+
*
305+
* @param value - Inodes coming from the form (`writeValue` argument).
306+
* @param inodes - Inodes currently selected in the store.
307+
* @returns `true` when both arrays contain the same inodes, regardless of order.
308+
*/
309+
export const sameInodes = (value: string[], inodes: string[]): boolean => {
310+
if (value.length !== inodes.length) {
311+
return false;
312+
}
313+
314+
const set = new Set(value);
315+
316+
return inodes.every((inode) => set.has(inode));
317+
};

core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ export const FLATTENED_FIELD_TYPES = [
1818
FIELD_TYPES.TAG
1919
];
2020

21-
export const UNCASTED_FIELD_TYPES = [FIELD_TYPES.BLOCK_EDITOR, FIELD_TYPES.KEY_VALUE];
21+
export const UNCASTED_FIELD_TYPES = [
22+
FIELD_TYPES.BLOCK_EDITOR,
23+
FIELD_TYPES.KEY_VALUE,
24+
FIELD_TYPES.CATEGORY
25+
];
2226

2327
export const TAB_FIELD_CLAZZ = 'com.dotcms.contenttype.model.field.ImmutableTabDividerField';
2428

0 commit comments

Comments
 (0)