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;
}
}