mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-43881 Fix for form rules not applying when new [data] comes in (#11879)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user