From 6d2e64fb6e390e4e6f57e02ffc19d7cb09cdde16 Mon Sep 17 00:00:00 2001 From: Alex Molodyh <140214274+amolodyh-hyland@users.noreply.github.com> Date: Thu, 14 May 2026 08:47:42 -0700 Subject: [PATCH] AAE-43881 Fix for form rules not applying when new [data] comes in (#11879) --- .../components/form-renderer.component.html | 4 +- .../form-section/form-section.component.html | 16 +- .../widgets/core/form-field.model.spec.ts | 65 +++++ .../widgets/core/form-field.model.ts | 15 ++ .../components/form-cloud.component.spec.ts | 254 ++++++++++++++++++ .../form/components/form-cloud.component.ts | 90 ++++++- 6 files changed, 426 insertions(+), 18 deletions(-) diff --git a/lib/core/src/lib/form/components/form-renderer.component.html b/lib/core/src/lib/form/components/form-renderer.component.html index 237ee726d1..2ebe30faa1 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.html +++ b/lib/core/src/lib/form/components/form-renderer.component.html @@ -4,7 +4,7 @@ @if (hasTabs()) {
- @for (tab of visibleTabs(); track tab) { + @for (tab of visibleTabs(); track tab.id) {
@@ -26,7 +26,7 @@
- @for (currentRootElement of fieldToRender; track currentRootElement) { + @for (currentRootElement of fieldToRender; track currentRootElement.id) { @if (currentRootElement.type === 'section') {
diff --git a/lib/core/src/lib/form/components/form-section/form-section.component.html b/lib/core/src/lib/form/components/form-section/form-section.component.html index 11028a7b2f..4d730d1dc7 100644 --- a/lib/core/src/lib/form/components/form-section/form-section.component.html +++ b/lib/core/src/lib/form/components/form-section/form-section.component.html @@ -1,13 +1,13 @@
- @for (sectionColumn of field.columns; track sectionColumn; let columnIndex = $index) { -
- @for (sectionField of sectionColumn.fields; track sectionField) { -
- -
- } -
+ @for (sectionColumn of field.columns; track sectionColumn.id; let columnIndex = $index) { +
+ @for (sectionField of sectionColumn.fields; track sectionField) { +
+ +
+ } +
}
diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts index 549cacf2e9..07971a578a 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts @@ -1757,4 +1757,69 @@ describe('FormFieldModel', () => { }); }); }); + + describe('restoreRuntimeValue', () => { + it('should set _value and write to form.values for a top-level text field', () => { + const form = new FormModel(); + const field = new FormFieldModel(form, { id: 'text1', type: 'text', value: null }); + + field.restoreRuntimeValue('restored'); + + expect(field.value).toBe('restored'); + expect(form.values['text1']).toBe('restored'); + }); + + it('should not call onFormFieldChanged unlike updateForm', () => { + const form = new FormModel(); + const field = new FormFieldModel(form, { id: 'text1', type: 'text', value: null }); + spyOn(form, 'onFormFieldChanged'); + + field.restoreRuntimeValue('restored'); + + expect(form.onFormFieldChanged).not.toHaveBeenCalled(); + }); + + it('should resolve dropdown option object from string id via getFormValue', () => { + const form = new FormModel(); + const field = new FormFieldModel(form, { + id: 'dd1', + type: 'dropdown', + optionType: 'manual', + options: [ + { id: 'opt1', name: 'Option 1' }, + { id: 'opt2', name: 'Option 2' } + ], + value: null + }); + + field.restoreRuntimeValue('opt1'); + + expect(form.values['dd1']).toEqual({ id: 'opt1', name: 'Option 1' }); + }); + + it('should set form.values to null when dropdown option id does not match any option', () => { + const form = new FormModel(); + const field = new FormFieldModel(form, { + id: 'dd1', + type: 'dropdown', + optionType: 'manual', + options: [{ id: 'opt1', name: 'Option 1' }], + value: null + }); + + field.restoreRuntimeValue('nonexistent'); + + expect(form.values['dd1']).toBeNull(); + }); + + it('should not update form.values when resolved value is undefined', () => { + const form = new FormModel(); + form.values['text1'] = 'existing'; + const field = new FormFieldModel(form, { id: 'text1', type: 'text', value: 'existing' }); + + field.restoreRuntimeValue(undefined); + + expect(form.values['text1']).toBe('existing'); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index 17da6ad85f..61b07ec7fe 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -607,6 +607,21 @@ export class FormFieldModel extends FormWidgetModel { this.form.onFormFieldChanged(this); } + restoreRuntimeValue(value: any): void { + this._value = value; + const formValue = this.getFormValue(); + if (this.parent) { + this.updateRepeatableSectionValue(formValue); + } else { + this.updateValue(formValue); + } + } + + restoreRuntimeFlags(required: boolean, readOnly: boolean): void { + this._required = required; + this._readOnly = readOnly; + } + getFormValue() { switch (this.type) { case FormFieldTypes.DROPDOWN: { diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts index d21acecf67..2ea4a8c50d 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts @@ -2221,3 +2221,257 @@ describe('FormCloudComponent - ADF_FORM_TAB_NAV_ENABLED token', () => { expect(formComponent.shouldShowTabNavigation).toBeFalse(); }); }); + +describe('FormCloudComponent — runtime state preservation on data refresh', () => { + let fixture: ComponentFixture; + let formComponent: FormCloudComponent; + let visibilityService: WidgetVisibilityService; + + const formJson = { + id: 'test-form', + name: 'Test Form', + fields: [ + { + fieldType: 'ContainerRepresentation', + id: 'container1', + name: 'Container', + type: 'container', + tab: null, + numberOfColumns: 2, + fields: { + 1: [ + { + fieldType: 'FormFieldRepresentation', + id: 'text1', + name: 'Text1', + type: 'text', + value: null, + required: false, + readOnly: false, + visibilityCondition: null, + params: { existingColspan: 1, maxColspan: 2 } + } + ], + 2: [ + { + fieldType: 'FormFieldRepresentation', + id: 'dropdown1', + name: 'Dropdown1', + type: 'dropdown', + value: '', + required: false, + readOnly: false, + optionType: 'manual', + options: [ + { id: 'opt1', name: 'Option 1' }, + { id: 'opt2', name: 'Option 2' } + ], + visibilityCondition: null, + params: { existingColspan: 1, maxColspan: 2 } + } + ] + } + } + ] + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent], + providers: [ + { provide: VersionCompatibilityService, useValue: {} }, + { provide: FormRenderingService, useClass: CloudFormRenderingService } + ] + }); + + const apiService = TestBed.inject(AlfrescoApiService); + spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth); + + visibilityService = TestBed.inject(WidgetVisibilityService); + spyOn(visibilityService, 'refreshVisibility').and.callThrough(); + + fixture = TestBed.createComponent(FormCloudComponent); + formComponent = fixture.componentInstance; + + formComponent.form = formComponent.parseForm(JSON.parse(JSON.stringify(formJson))); + formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(formJson))); + + fixture.detectChanges(); + }); + + describe('data binding change triggers refreshFormData', () => { + it('should emit formDataRefreshed when data changes', (done) => { + const data = [new TaskVariableCloud({ name: 'text1', value: 'hello' })]; + const change = new SimpleChange([], data, false); + formComponent.data = data; + + formComponent.formDataRefreshed.subscribe((form) => { + expect(form).toBeTruthy(); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should call visibilityService.refreshVisibility during data refresh', () => { + const data = [new TaskVariableCloud({ name: 'text1', value: 'hello' })]; + const change = new SimpleChange([], data, false); + formComponent.data = data; + + formComponent.ngOnChanges({ data: change }); + + expect(visibilityService.refreshVisibility).toHaveBeenCalled(); + }); + + it('should call validateForm on the new form instance after data refresh', () => { + const data = [new TaskVariableCloud({ name: 'text1', value: 'hello' })]; + const change = new SimpleChange([], data, false); + formComponent.data = data; + + formComponent.ngOnChanges({ data: change }); + + expect(formComponent.form).toBeTruthy(); + expect(formComponent.form.isValid).toBeDefined(); + }); + }); + + describe('runtime state restoration', () => { + it('should preserve text field value that was not in the new data payload', (done) => { + const textField = formComponent.form.getFieldById('text1'); + textField.value = 'rule-set-value'; + + const data = [new TaskVariableCloud({ name: 'dropdown1', value: 'opt1' })]; + const prevData: TaskVariableCloud[] = []; + const change = new SimpleChange(prevData, data, false); + formComponent.data = data; + + formComponent.formLoaded.subscribe((form) => { + const restoredText = form.getFieldById('text1'); + expect(restoredText.value).toBe('rule-set-value'); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should preserve field that is unchanged between previous and current data', (done) => { + const prev = [new TaskVariableCloud({ name: 'text1', value: 'same' })]; + formComponent.form.getFieldById('text1').value = 'same'; + + const next = [new TaskVariableCloud({ name: 'text1', value: 'same' })]; + const change = new SimpleChange(prev, next, false); + formComponent.data = next; + + formComponent.formLoaded.subscribe((form) => { + const textField = form.getFieldById('text1'); + expect(textField.value).toBe('same'); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should override field that changed between previous and current data', (done) => { + const prev = [new TaskVariableCloud({ name: 'text1', value: 'old' })]; + formComponent.form.getFieldById('text1').value = 'old'; + + const next = [new TaskVariableCloud({ name: 'text1', value: 'updated' })]; + const change = new SimpleChange(prev, next, false); + formComponent.data = next; + + formComponent.formLoaded.subscribe((form) => { + const textField = form.getFieldById('text1'); + expect(textField.value).toBe('updated'); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should restore readOnly and required state for unchanged fields', (done) => { + const textField = formComponent.form.getFieldById('text1'); + textField.readOnly = true; + textField.required = true; + + const data = [new TaskVariableCloud({ name: 'dropdown1', value: 'opt1' })]; + const change = new SimpleChange([], data, false); + formComponent.data = data; + + formComponent.formLoaded.subscribe((form) => { + const restoredField = form.getFieldById('text1'); + expect(restoredField.readOnly).toBe(true); + expect(restoredField.required).toBe(true); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should enable Start Process outcome button after data refresh populates all required fields', (done) => { + const requiredFormJson = { + id: 'required-form', + name: 'Required Form', + fields: [ + { + fieldType: 'ContainerRepresentation', + id: 'container1', + name: 'Container', + type: 'container', + tab: null, + numberOfColumns: 1, + fields: { + 1: [ + { + fieldType: 'FormFieldRepresentation', + id: 'requiredText', + name: 'Required Text', + type: 'text', + value: null, + required: true, + readOnly: false, + visibilityCondition: null, + params: { existingColspan: 1, maxColspan: 1 } + } + ] + } + } + ] + }; + + formComponent.form = formComponent.parseForm(JSON.parse(JSON.stringify(requiredFormJson))); + formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(requiredFormJson))); + + const startProcessOutcome = formComponent.form.outcomes.find((o) => o.name === FormOutcomeModel.START_PROCESS_ACTION); + expect(formComponent.isOutcomeButtonEnabled(startProcessOutcome)).toBeFalse(); + + const data = [new TaskVariableCloud({ name: 'requiredText', value: 'filled' })]; + const change = new SimpleChange([], data, false); + formComponent.data = data; + + formComponent.formLoaded.subscribe(() => { + expect(formComponent.form.isValid).toBeTrue(); + const refreshedOutcome = formComponent.form.outcomes.find((o) => o.name === FormOutcomeModel.START_PROCESS_ACTION); + expect(formComponent.isOutcomeButtonEnabled(refreshedOutcome)).toBeTrue(); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + + it('should pass previousValue from the SimpleChange to getChangedFieldIds comparison', (done) => { + const prev = [new TaskVariableCloud({ name: 'text1', value: 'old' })]; + formComponent.form.getFieldById('text1').value = 'old'; + + const next = [new TaskVariableCloud({ name: 'text1', value: 'old' }), new TaskVariableCloud({ name: 'dropdown1', value: 'opt2' })]; + const change = new SimpleChange(prev, next, false); + formComponent.data = next; + + formComponent.formLoaded.subscribe((form) => { + expect(form.getFieldById('text1').value).toBe('old'); + done(); + }); + + formComponent.ngOnChanges({ data: change }); + }); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts index 927ae9ef67..b3e51d939e 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts @@ -39,6 +39,7 @@ import { FormBaseComponent, FormEvent, FormFieldModel, + FormRulesEvent, FormFieldValidator, FormModel, FormOutcomeEvent, @@ -67,6 +68,14 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { A11yModule } from '@angular/cdk/a11y'; +interface FormFieldRuntimeState { + value: any; + required: boolean; + readOnly: boolean; + isVisible: boolean; + visibilityCondition: any; +} + export const FORM_CLOUD_FIELD_VALIDATORS_TOKEN = new InjectionToken('FORM_CLOUD_FIELD_VALIDATORS_TOKEN'); export const ADF_FORM_TAB_NAV_ENABLED = new InjectionToken | boolean>('ADF_FORM_TAB_NAV_ENABLED'); @@ -319,9 +328,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, return; } - const data = changes['data']?.currentValue; - if (data?.length > 0) { - this.refreshFormData(); + const dataChange = changes['data']; + if (dataChange?.currentValue?.length > 0) { + this.refreshFormData(dataChange.previousValue ?? []); return; } @@ -563,12 +572,77 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, } } - private refreshFormData() { + private refreshFormData(previousData: TaskVariableCloud[] = []) { + const snapshot = this.snapshotRuntimeState(); + this.form = this.parseForm(this.formCloudRepresentationJSON); - if (this.form) { - this.setCheckParentVisibilityForValidationOnFields(); - this.onFormLoaded(this.form); - this.onFormDataRefreshed(this.form); + if (!this.form) { + return; + } + + const changedFieldIds = this.getChangedFieldIds(previousData, this.data ?? []); + this.restoreRuntimeState(this.form, snapshot, changedFieldIds); + + this.setCheckParentVisibilityForValidationOnFields(); + this.visibilityService.refreshVisibility(this.form); + this.form.validateForm(); + this.recomputeVisibleOutcomes(); + this.onFormLoaded(this.form); + this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form))); + this.onFormDataRefreshed(this.form); + } + + private snapshotRuntimeState(): Map { + const snapshot = new Map(); + if (!this.form) { + return snapshot; + } + + for (const field of this.form.getFormFields()) { + snapshot.set(field.id, { + value: field.value, + required: field.required, + readOnly: field.readOnly, + isVisible: field.isVisible, + visibilityCondition: field.visibilityCondition + }); + } + + return snapshot; + } + + private getChangedFieldIds(previousData: TaskVariableCloud[], nextData: TaskVariableCloud[]): Set { + const prevMap = new Map(previousData.map((v) => [v.name, v.value])); + const changed = new Set(); + + for (const variable of nextData) { + const prev = prevMap.get(variable.name); + const next = variable.value; + const isPrimitive = (v: unknown) => v === null || (typeof v !== 'object' && typeof v !== 'function'); + const equal = isPrimitive(prev) && isPrimitive(next) ? Object.is(prev, next) : JSON.stringify(prev) === JSON.stringify(next); + if (!equal) { + changed.add(variable.name); + } + } + + return changed; + } + + private restoreRuntimeState(form: FormModel, snapshot: Map, changedFieldIds: Set): void { + for (const field of form.getFormFields()) { + if (changedFieldIds.has(field.id)) { + continue; + } + + const prior = snapshot.get(field.id); + if (!prior) { + continue; + } + + field.restoreRuntimeValue(prior.value); + field.restoreRuntimeFlags(prior.required, prior.readOnly); + field.isVisible = prior.isVisible; + field.visibilityCondition = prior.visibilityCondition; } }