AAE-43881 Fix for form rules not applying when new [data] comes in (#11879)

This commit is contained in:
Alex Molodyh
2026-05-20 14:19:30 +05:30
committed by Anamika Dey
parent 891d0a37e6
commit 6d2e64fb6e
6 changed files with 426 additions and 18 deletions
@@ -4,7 +4,7 @@
@if (hasTabs()) {
<div class="alfresco-tabs-widget">
<mat-tab-group [preserveContent]="true">
@for (tab of visibleTabs(); track tab) {
@for (tab of visibleTabs(); track tab.id) {
<mat-tab [label]="tab.title | translate ">
<ng-template matTabContent>
<div class="adf-form-tab-content">
@@ -26,7 +26,7 @@
</div>
<ng-template #render let-fieldToRender="fieldToRender">
@for (currentRootElement of fieldToRender; track currentRootElement) {
@for (currentRootElement of fieldToRender; track currentRootElement.id) {
@if (currentRootElement.type === 'section') {
<div [id]="'field-' + currentRootElement?.id + '-container'" class="adf-container-widget">
<adf-form-section [field]="currentRootElement.field" />
@@ -1,13 +1,13 @@
<div class="adf-grid-list-section-single-column"
[id]="'field-' + field?.id + '-container'"
[style.display]="field?.isVisible ? 'flex' : 'none'">
@for (sectionColumn of field.columns; track sectionColumn; let columnIndex = $index) {
<div [style.width.%]="getSectionColumnWidth(field.numberOfColumns, field.columns, columnIndex)">
@for (sectionField of sectionColumn.fields; track sectionField) {
<div class="adf-grid-list-section-column-view-item">
<adf-form-field [field]="sectionField"/>
</div>
}
</div>
@for (sectionColumn of field.columns; track sectionColumn.id; let columnIndex = $index) {
<div [style.width.%]="getSectionColumnWidth(field.numberOfColumns, field.columns, columnIndex)">
@for (sectionField of sectionColumn.fields; track sectionField) {
<div class="adf-grid-list-section-column-view-item">
<adf-form-field [field]="sectionField"/>
</div>
}
</div>
}
</div>
@@ -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');
});
});
});
@@ -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: {
@@ -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<FormCloudComponent>;
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 });
});
});
});
@@ -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<FormFieldValidator[]>('FORM_CLOUD_FIELD_VALIDATORS_TOKEN');
export const ADF_FORM_TAB_NAV_ENABLED = new InjectionToken<Observable<boolean> | 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<string, FormFieldRuntimeState> {
const snapshot = new Map<string, FormFieldRuntimeState>();
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<string> {
const prevMap = new Map(previousData.map((v) => [v.name, v.value]));
const changed = new Set<string>();
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<string, FormFieldRuntimeState>, changedFieldIds: Set<string>): 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;
}
}