mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-46247 Add type-aware form field value adapter service (#11968)
* AAE-46247 Add type-aware form field value adapter service
This commit is contained in:
@@ -263,11 +263,11 @@ describe('DisplayTextWidgetComponent', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'f1',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }]
|
||||
value: [{ firstName: 'Test', lastName: 'User' }]
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.field.value).toBe('Alyssa Adcock');
|
||||
expect(widget.field.value).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should format a direct Group value to group name', () => {
|
||||
@@ -296,21 +296,21 @@ describe('DisplayTextWidgetComponent', () => {
|
||||
const form = new FormModel({
|
||||
fields: [
|
||||
{ id: 'displayText1', type: 'display-text', value: 'Selected: ${field.peopleField}' },
|
||||
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] }
|
||||
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Test', lastName: 'User' }] }
|
||||
]
|
||||
});
|
||||
|
||||
widget.field = form.getFieldById('displayText1');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.field.value).toBe('Selected: Alyssa Adcock');
|
||||
expect(widget.field.value).toBe('Selected: Test User');
|
||||
});
|
||||
|
||||
it('should re-evaluate expression with formatted value when dependent People field changes', (done) => {
|
||||
const form = new FormModel({
|
||||
fields: [
|
||||
{ id: 'displayText1', type: 'display-text', value: 'Selected: ${field.peopleField}' },
|
||||
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Alyssa', lastName: 'Adcock' }] }
|
||||
{ id: 'peopleField', type: FormFieldTypes.PEOPLE, value: [{ firstName: 'Test', lastName: 'User' }] }
|
||||
]
|
||||
});
|
||||
formService = TestBed.inject(FormService);
|
||||
@@ -319,7 +319,7 @@ describe('DisplayTextWidgetComponent', () => {
|
||||
const peopleField = form.getFieldById('peopleField');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.field.value).toBe('Selected: Alyssa Adcock');
|
||||
expect(widget.field.value).toBe('Selected: Test User');
|
||||
|
||||
peopleField.value = [{ firstName: 'Jane', lastName: 'Smith' }];
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged', field: peopleField } as any);
|
||||
@@ -341,7 +341,7 @@ describe('DisplayTextWidgetComponent', () => {
|
||||
fixture = TestBed.createComponent(DisplayTextWidgetComponent);
|
||||
widget = fixture.componentInstance;
|
||||
|
||||
const rawValue = [{ firstName: 'Alyssa', lastName: 'Adcock' }];
|
||||
const rawValue = [{ firstName: 'Test', lastName: 'User' }];
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'f1',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
|
||||
@@ -717,12 +717,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
|
||||
value: [{ firstName: 'Test', lastName: 'User' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).toBe('Alyssa Adcock');
|
||||
expect(widget.displayValue).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should return comma-separated labels for a multi-select dropdown in read-only mode', () => {
|
||||
@@ -806,12 +806,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
|
||||
value: [{ firstName: 'Test', lastName: 'User' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).not.toBe('Alyssa Adcock');
|
||||
expect(widget.displayValue).not.toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -829,12 +829,12 @@ describe('TextWidgetComponent - ADF_CUSTOM_MESSAGE', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }],
|
||||
value: [{ firstName: 'Test', lastName: 'User' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).toBe('Alyssa Adcock');
|
||||
expect(widget.displayValue).toBe('Test User');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from './services/form-validation-service.interface';
|
||||
export * from './services/widget-visibility.service';
|
||||
export * from './services/form-field-value-formatter.service';
|
||||
export * from './services/form-field-value-formatter.token';
|
||||
export * from './services/form-field-value-adapter.service';
|
||||
|
||||
export * from './pipes';
|
||||
|
||||
|
||||
@@ -290,15 +290,15 @@ describe('FormExpressionService', () => {
|
||||
const mockField = {
|
||||
id: 'peopleField',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: { firstName: 'Alyssa', lastName: 'Adcock' }
|
||||
value: { firstName: 'Test', lastName: 'User' }
|
||||
};
|
||||
spyOn(formModel, 'getFieldById').and.returnValue(mockField as any);
|
||||
spyOn(formatter, 'hasFormatter').and.returnValue(true);
|
||||
spyOn(formatter, 'formatValue').and.returnValue('Alyssa Adcock');
|
||||
spyOn(formatter, 'formatValue').and.returnValue('Test User');
|
||||
|
||||
const result = formattingService.resolveExpressions(formModel, '${field.peopleField}');
|
||||
|
||||
expect(result).toBe('Alyssa Adcock');
|
||||
expect(result).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should fall back to JSON.stringify when no formatter is registered', () => {
|
||||
@@ -338,7 +338,7 @@ describe('FormExpressionService', () => {
|
||||
const mockField = {
|
||||
id: 'peopleField',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: { firstName: 'Alyssa', lastName: 'Adcock' }
|
||||
value: { firstName: 'Test', lastName: 'User' }
|
||||
};
|
||||
spyOn(formModel, 'getFieldById').and.returnValue(mockField as any);
|
||||
const hasFormatterSpy = spyOn(formatter, 'hasFormatter');
|
||||
@@ -346,7 +346,7 @@ describe('FormExpressionService', () => {
|
||||
const result = service.resolveExpressions(formModel, '${field.peopleField}');
|
||||
|
||||
expect(hasFormatterSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe('{"firstName":"Alyssa","lastName":"Adcock"}');
|
||||
expect(result).toBe('{"firstName":"Test","lastName":"User"}');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FormFieldValueAdapterService } from './form-field-value-adapter.service';
|
||||
import { FormFieldModel } from '../components/widgets/core/form-field.model';
|
||||
import { FormFieldTypes } from '../components/widgets/core/form-field-types';
|
||||
import { FormModel } from '../components/widgets/core';
|
||||
|
||||
/**
|
||||
* Test helper that creates a FormFieldModel attached to a fresh FormModel for unit tests.
|
||||
*
|
||||
* @param type form field type identifier (e.g., FormFieldTypes.PEOPLE)
|
||||
* @param value initial field value
|
||||
* @param config optional dropdown/radio options and multi-select flag
|
||||
* @param config.options dropdown/radio option list
|
||||
* @param config.multiple whether the field accepts multiple values
|
||||
* @returns a fully-wired FormFieldModel
|
||||
*/
|
||||
function makeField(type: string, value: any, config?: { options?: { id: string; name: string }[]; multiple?: boolean }): FormFieldModel {
|
||||
const form = new FormModel();
|
||||
const field = new FormFieldModel(form, {
|
||||
id: 'f1',
|
||||
type,
|
||||
value,
|
||||
selectionType: config?.multiple ? 'multiple' : 'single'
|
||||
});
|
||||
if (config?.options) {
|
||||
field.options = config.options;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
describe('FormFieldValueAdapterService', () => {
|
||||
let service: FormFieldValueAdapterService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(FormFieldValueAdapterService);
|
||||
});
|
||||
|
||||
describe('hasAdapter', () => {
|
||||
it('should return true for registered types', () => {
|
||||
expect(service.hasAdapter(FormFieldTypes.PEOPLE)).toBeTrue();
|
||||
expect(service.hasAdapter(FormFieldTypes.FUNCTIONAL_GROUP)).toBeTrue();
|
||||
expect(service.hasAdapter(FormFieldTypes.DROPDOWN)).toBeTrue();
|
||||
expect(service.hasAdapter(FormFieldTypes.RADIO_BUTTONS)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should return false for unregistered types', () => {
|
||||
expect(service.hasAdapter('text')).toBeFalse();
|
||||
expect(service.hasAdapter('unknown-type')).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default adapter', () => {
|
||||
it('should pass through values for unregistered types', () => {
|
||||
const field = makeField('text', 'hello');
|
||||
expect(service.adapt('hello', field)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should pass through value when field is null', () => {
|
||||
expect(service.adapt('anything', null as any)).toBe('anything');
|
||||
});
|
||||
});
|
||||
|
||||
describe('People adapter', () => {
|
||||
it('should pass through null', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt(null, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('should pass through undefined', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, undefined);
|
||||
expect(service.adapt(undefined, field)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('single-select: should wrap a user object into a single-element array', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
const user = { firstName: 'Test', lastName: 'User' };
|
||||
expect(service.adapt(user, field)).toEqual([user]);
|
||||
});
|
||||
|
||||
it('single-select: should parse a string into a single-element array of firstName/lastName', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt('Test User', field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
|
||||
});
|
||||
|
||||
it('single-select: should parse a single-word string into a single-element array with firstName only', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt('Test', field)).toEqual([{ firstName: 'Test' }]);
|
||||
});
|
||||
|
||||
it('single-select: should keep a single-element array', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt([{ firstName: 'Test' }], field)).toEqual([{ firstName: 'Test' }]);
|
||||
});
|
||||
|
||||
it('single-select: should return null when given an empty array', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt([], field)).toBeNull();
|
||||
});
|
||||
|
||||
it('single-select: should return null for an empty or whitespace string', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt('', field)).toBeNull();
|
||||
expect(service.adapt(' ', field)).toBeNull();
|
||||
});
|
||||
|
||||
it('single-select: should return null for stringified empty collections', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt('[]', field)).toBeNull();
|
||||
expect(service.adapt('{}', field)).toBeNull();
|
||||
});
|
||||
|
||||
it('multi-select: should drop blank entries and keep valid users', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
|
||||
expect(service.adapt(['', 'Test User'], field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
|
||||
});
|
||||
|
||||
it('multi-select: should wrap a single object into an array', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
|
||||
expect(service.adapt({ firstName: 'Test' }, field)).toEqual([{ firstName: 'Test' }]);
|
||||
});
|
||||
|
||||
it('multi-select: should keep an array of users (idempotent)', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
|
||||
const users = [{ firstName: 'Alice' }, { firstName: 'Bob' }];
|
||||
expect(service.adapt(users, field)).toEqual(users);
|
||||
});
|
||||
|
||||
it('multi-select: should parse an array of strings', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null, { multiple: true });
|
||||
expect(service.adapt(['Test User'], field)).toEqual([{ firstName: 'Test', lastName: 'User' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group adapter', () => {
|
||||
it('should pass through null', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
|
||||
expect(service.adapt(null, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('single-select: should wrap a group object into a single-element array', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
|
||||
const group = { id: 'g1', name: 'Engineering' };
|
||||
expect(service.adapt(group, field)).toEqual([group]);
|
||||
});
|
||||
|
||||
it('single-select: should wrap a string into a single-element array of { name }', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
|
||||
expect(service.adapt('Engineering', field)).toEqual([{ name: 'Engineering' }]);
|
||||
});
|
||||
|
||||
it('single-select: should return null for an empty string or stringified empty collection', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null);
|
||||
expect(service.adapt('', field)).toBeNull();
|
||||
expect(service.adapt('[]', field)).toBeNull();
|
||||
});
|
||||
|
||||
it('multi-select: should wrap a single group into an array', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null, { multiple: true });
|
||||
expect(service.adapt({ name: 'Engineering' }, field)).toEqual([{ name: 'Engineering' }]);
|
||||
});
|
||||
|
||||
it('multi-select: should parse an array of strings', () => {
|
||||
const field = makeField(FormFieldTypes.FUNCTIONAL_GROUP, null, { multiple: true });
|
||||
expect(service.adapt(['Eng', 'QA'], field)).toEqual([{ name: 'Eng' }, { name: 'QA' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown adapter', () => {
|
||||
const options = [
|
||||
{ id: 'a', name: 'Apple' },
|
||||
{ id: 'b', name: 'Banana' }
|
||||
];
|
||||
|
||||
it('should pass through null', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt(null, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('single-select: should pass through a string id', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt('a', field)).toBe('a');
|
||||
});
|
||||
|
||||
it('single-select: should extract id from an object', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt({ id: 'a', name: 'Apple' }, field)).toBe('a');
|
||||
});
|
||||
|
||||
it('single-select: should return null for an object without an id', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt({ name: 'Apple' }, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('single-select: should extract the first id from an array', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt(['a'], field)).toBe('a');
|
||||
});
|
||||
|
||||
it('single-select: should return null for an empty array', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options });
|
||||
expect(service.adapt([], field)).toBeNull();
|
||||
});
|
||||
|
||||
it('multi-select: should keep FormFieldOption array (idempotent)', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
|
||||
const value = [
|
||||
{ id: 'a', name: 'Apple' },
|
||||
{ id: 'b', name: 'Banana' }
|
||||
];
|
||||
expect(service.adapt(value, field)).toEqual(value);
|
||||
});
|
||||
|
||||
it('multi-select: should resolve string ids against field options', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
|
||||
expect(service.adapt(['a', 'b'], field)).toEqual([
|
||||
{ id: 'a', name: 'Apple' },
|
||||
{ id: 'b', name: 'Banana' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('multi-select: should fall back to id/name when option not found', () => {
|
||||
const field = makeField(FormFieldTypes.DROPDOWN, null, { options, multiple: true });
|
||||
expect(service.adapt(['z'], field)).toEqual([{ id: 'z', name: 'z' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Radio adapter', () => {
|
||||
const options = [
|
||||
{ id: 'yes', name: 'Yes' },
|
||||
{ id: 'no', name: 'No' }
|
||||
];
|
||||
|
||||
it('should pass through null', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt(null, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('should pass through a string id', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt('yes', field)).toBe('yes');
|
||||
});
|
||||
|
||||
it('should extract id from an object', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt({ id: 'yes', name: 'Yes' }, field)).toBe('yes');
|
||||
});
|
||||
|
||||
it('should return null for an object without an id', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt({ name: 'Yes' }, field)).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract the first id from an array', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt(['yes'], field)).toBe('yes');
|
||||
});
|
||||
|
||||
it('should return null for an empty array', () => {
|
||||
const field = makeField(FormFieldTypes.RADIO_BUTTONS, null, { options });
|
||||
expect(service.adapt([], field)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should allow overriding a registered adapter', () => {
|
||||
service.register(FormFieldTypes.PEOPLE, () => 'custom-override');
|
||||
const field = makeField(FormFieldTypes.PEOPLE, null);
|
||||
expect(service.adapt('anything', field)).toBe('custom-override');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FormFieldModel } from '../components/widgets/core/form-field.model';
|
||||
import { FormFieldTypes } from '../components/widgets/core/form-field-types';
|
||||
import { FormFieldOption } from '../components/widgets/core/form-field-option';
|
||||
|
||||
export type FormFieldValueAdapter = (value: unknown, field: FormFieldModel) => unknown;
|
||||
|
||||
interface AdaptedUser {
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
interface AdaptedGroup {
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FormFieldValueAdapterService {
|
||||
private readonly adapters = new Map<string, FormFieldValueAdapter>();
|
||||
|
||||
constructor() {
|
||||
this.register(FormFieldTypes.PEOPLE, (value) => this.adaptPeople(value));
|
||||
this.register(FormFieldTypes.FUNCTIONAL_GROUP, (value) => this.adaptGroup(value));
|
||||
this.register(FormFieldTypes.DROPDOWN, (value, field) => this.adaptDropdown(value, field));
|
||||
this.register(FormFieldTypes.RADIO_BUTTONS, (value) => this.adaptRadio(value));
|
||||
}
|
||||
|
||||
register(fieldType: string, adapter: FormFieldValueAdapter): void {
|
||||
this.adapters.set(fieldType, adapter);
|
||||
}
|
||||
|
||||
hasAdapter(fieldType: string): boolean {
|
||||
return this.adapters.has(fieldType);
|
||||
}
|
||||
|
||||
adapt(value: unknown, field: FormFieldModel): unknown {
|
||||
const adapter = this.adapters.get(field?.type);
|
||||
if (!adapter) {
|
||||
return value;
|
||||
}
|
||||
return adapter(value, field);
|
||||
}
|
||||
|
||||
private adaptPeople(value: unknown): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
const adapted = entries.map((entry) => this.toUser(entry)).filter((user) => user !== null);
|
||||
return adapted.length > 0 ? adapted : null;
|
||||
}
|
||||
|
||||
private adaptGroup(value: unknown): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
const adapted = entries.map((entry) => this.toGroup(entry)).filter((group) => group !== null);
|
||||
return adapted.length > 0 ? adapted : null;
|
||||
}
|
||||
|
||||
private adaptDropdown(value: unknown, field: FormFieldModel): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (field.hasMultipleValues) {
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
return entries.map((entry) => this.toOption(entry, field));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? this.toOptionId(value[0]) : null;
|
||||
}
|
||||
return this.toOptionId(value);
|
||||
}
|
||||
|
||||
private adaptRadio(value: unknown): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? this.toOptionId(value[0]) : null;
|
||||
}
|
||||
return this.toOptionId(value);
|
||||
}
|
||||
|
||||
private toUser(entry: unknown): unknown {
|
||||
if (typeof entry !== 'string') {
|
||||
return entry;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (this.isBlankToken(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const separatorIndex = trimmed.indexOf(' ');
|
||||
if (separatorIndex === -1) {
|
||||
const user: AdaptedUser = { firstName: trimmed };
|
||||
return user;
|
||||
}
|
||||
const user: AdaptedUser = {
|
||||
firstName: trimmed.slice(0, separatorIndex),
|
||||
lastName: trimmed.slice(separatorIndex + 1)
|
||||
};
|
||||
return user;
|
||||
}
|
||||
|
||||
private toGroup(entry: unknown): unknown {
|
||||
if (typeof entry !== 'string') {
|
||||
return entry;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (this.isBlankToken(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const group: AdaptedGroup = { name: trimmed };
|
||||
return group;
|
||||
}
|
||||
|
||||
private isBlankToken(value: string): boolean {
|
||||
return value === '' || value === '[]' || value === '{}';
|
||||
}
|
||||
|
||||
private toOptionId(entry: unknown): unknown {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return (entry as Record<string, unknown>)['id'] ?? null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private toOption(entry: unknown, field: FormFieldModel): FormFieldOption {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return entry as FormFieldOption;
|
||||
}
|
||||
const match = field.options?.find((option) => option.id === entry);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
const optionId = entry as string;
|
||||
return { id: optionId, name: optionId };
|
||||
}
|
||||
}
|
||||
@@ -139,13 +139,13 @@ describe('FormFieldValueFormatterService', () => {
|
||||
});
|
||||
|
||||
it('should format a single user object (single-select mode)', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, { firstName: 'Alyssa', lastName: 'Adcock' });
|
||||
expect(service.format(field)).toBe('Alyssa Adcock');
|
||||
const field = makeField(FormFieldTypes.PEOPLE, { firstName: 'Test', lastName: 'User' });
|
||||
expect(service.format(field)).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should format an array with one user', () => {
|
||||
const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Alyssa', lastName: 'Adcock' }]);
|
||||
expect(service.format(field)).toBe('Alyssa Adcock');
|
||||
const field = makeField(FormFieldTypes.PEOPLE, [{ firstName: 'Test', lastName: 'User' }]);
|
||||
expect(service.format(field)).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should format multiple users separated by comma', () => {
|
||||
|
||||
+2
-2
@@ -225,12 +225,12 @@ describe('DisplayExternalPropertyWidgetComponent', () => {
|
||||
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
readOnly: true,
|
||||
value: [{ firstName: 'Alyssa', lastName: 'Adcock' }]
|
||||
value: [{ firstName: 'Test', lastName: 'User' }]
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const input = await loader.getHarness(MatInputHarness);
|
||||
expect(await input.getValue()).toBe('Alyssa Adcock');
|
||||
expect(await input.getValue()).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should display comma-separated group names for a Group value', async () => {
|
||||
|
||||
+90
-1
@@ -15,7 +15,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FormFieldModel, FormFieldTypes, FormModel, IdentityGroupModel, NoopAuthModule } from '@alfresco/adf-core';
|
||||
import {
|
||||
ADF_TYPED_VALUE_FORMATTING_ENABLED,
|
||||
FormFieldModel,
|
||||
FormFieldTypes,
|
||||
FormModel,
|
||||
FormService,
|
||||
IdentityGroupModel,
|
||||
NoopAuthModule
|
||||
} from '@alfresco/adf-core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { GroupCloudWidgetComponent } from './group-cloud.widget';
|
||||
import { HarnessLoader } from '@angular/cdk/testing';
|
||||
@@ -246,4 +254,85 @@ describe('GroupCloudWidgetComponent', () => {
|
||||
expect(adfLeftLabel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactive sync on form rules event', () => {
|
||||
describe('when flag is on', () => {
|
||||
let formService: FormService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAuthModule, GroupCloudWidgetComponent],
|
||||
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
|
||||
});
|
||||
formService = TestBed.inject(FormService);
|
||||
fixture = TestBed.createComponent(GroupCloudWidgetComponent);
|
||||
widget = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should sync preSelectGroup with a new array reference when the selection changes', () => {
|
||||
const groups = [{ id: 'g1', name: 'Engineering' }];
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: null });
|
||||
fixture.detectChanges();
|
||||
widget.field.value = groups;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectGroup).toEqual(groups);
|
||||
expect(widget.preSelectGroup).not.toBe(groups);
|
||||
});
|
||||
|
||||
it('should not reassign preSelectGroup when the selection is unchanged', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.FUNCTIONAL_GROUP,
|
||||
value: [{ id: 'g1', name: 'Engineering' }]
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const initial = widget.preSelectGroup;
|
||||
widget.field.value = [{ id: 'g1', name: 'Engineering' }];
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectGroup).toBe(initial);
|
||||
});
|
||||
|
||||
it('should wrap a single group object into an array', () => {
|
||||
const group = { id: 'g1', name: 'Engineering' };
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: null });
|
||||
fixture.detectChanges();
|
||||
widget.field.value = group;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectGroup).toEqual([group]);
|
||||
});
|
||||
|
||||
it('should reset preSelectGroup to empty array when value is cleared', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.FUNCTIONAL_GROUP,
|
||||
value: [{ id: 'g1', name: 'Engineering' }]
|
||||
});
|
||||
fixture.detectChanges();
|
||||
widget.field.value = null;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectGroup).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when flag is off', () => {
|
||||
it('should not react to form rules events', () => {
|
||||
const formService = TestBed.inject(FormService);
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.FUNCTIONAL_GROUP, value: [] });
|
||||
fixture.detectChanges();
|
||||
const initial = widget.preSelectGroup;
|
||||
widget.field.value = [{ id: 'g1', name: 'Engineering' }];
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectGroup).toBe(initial);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
-1
@@ -22,6 +22,7 @@ import { filter } from 'rxjs/operators';
|
||||
import { ComponentSelectionMode } from '../../../../types';
|
||||
import { IdentityGroupModel } from '../../../../group/models/identity-group.model';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ReactivePreselectionService } from '../reactive-preselection.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { GroupCloudComponent } from '../../../../group/components/group-cloud.component';
|
||||
@@ -43,9 +44,12 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
|
||||
'(invalid)': 'event($event)',
|
||||
'(select)': 'event($event)'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [ReactivePreselectionService]
|
||||
})
|
||||
export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit {
|
||||
private readonly reactivePreselection: ReactivePreselectionService<IdentityGroupModel> = inject(ReactivePreselectionService);
|
||||
|
||||
typeId = 'GroupCloudWidgetComponent';
|
||||
roles: string[];
|
||||
mode: ComponentSelectionMode;
|
||||
@@ -57,6 +61,15 @@ export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
ngOnInit() {
|
||||
this.reactivePreselection.connect({
|
||||
getFieldId: () => this.field?.id,
|
||||
getFormId: () => this.field?.form?.id,
|
||||
getFieldValue: () => this.field?.value,
|
||||
getPreselection: () => this.preSelectGroup,
|
||||
setPreselection: (value) => (this.preSelectGroup = value),
|
||||
identityOf: (group) => group?.id ?? group?.name
|
||||
});
|
||||
|
||||
if (this.field) {
|
||||
this.roles = this.field.roles;
|
||||
this.mode = this.field.optionType as ComponentSelectionMode;
|
||||
@@ -85,6 +98,7 @@ export class GroupCloudWidgetComponent extends WidgetComponent implements OnInit
|
||||
this.field.form.validateForm();
|
||||
});
|
||||
}
|
||||
|
||||
onChangedGroup(groups: IdentityGroupModel[]): void {
|
||||
this.field.value = groups?.length ? [...groups] : null;
|
||||
this.onFieldChanged(this.field);
|
||||
|
||||
+84
-1
@@ -15,7 +15,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FormFieldModel, FormFieldTypes, FormModel, IdentityUserModel, NoopAuthModule } from '@alfresco/adf-core';
|
||||
import {
|
||||
ADF_TYPED_VALUE_FORMATTING_ENABLED,
|
||||
FormFieldModel,
|
||||
FormFieldTypes,
|
||||
FormModel,
|
||||
FormService,
|
||||
IdentityUserModel,
|
||||
NoopAuthModule
|
||||
} from '@alfresco/adf-core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { PeopleCloudWidgetComponent } from './people-cloud.widget';
|
||||
import { IdentityUserService } from '../../../../people/services/identity-user.service';
|
||||
@@ -276,4 +284,79 @@ describe('PeopleCloudWidgetComponent', () => {
|
||||
expect(adfLeftLabel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactive sync on form rules event', () => {
|
||||
describe('when flag is on', () => {
|
||||
let formService: FormService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAuthModule, PeopleCloudWidgetComponent],
|
||||
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
|
||||
});
|
||||
formService = TestBed.inject(FormService);
|
||||
fixture = TestBed.createComponent(PeopleCloudWidgetComponent);
|
||||
widget = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should sync preSelectUsers with a new array reference when the selection changes', () => {
|
||||
const users = [{ id: 'a', username: 'alpha' }];
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: null });
|
||||
fixture.detectChanges();
|
||||
widget.field.value = users;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectUsers).toEqual(users);
|
||||
expect(widget.preSelectUsers).not.toBe(users);
|
||||
});
|
||||
|
||||
it('should not reassign preSelectUsers when the selection is unchanged', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [{ id: 'a', username: 'alpha' }] });
|
||||
fixture.detectChanges();
|
||||
const initial = widget.preSelectUsers;
|
||||
widget.field.value = [{ id: 'a', username: 'alpha' }];
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectUsers).toBe(initial);
|
||||
});
|
||||
|
||||
it('should wrap a single user object into an array', () => {
|
||||
const user = { id: 'a', username: 'alpha' };
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: null });
|
||||
fixture.detectChanges();
|
||||
widget.field.value = user;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectUsers).toEqual([user]);
|
||||
});
|
||||
|
||||
it('should reset preSelectUsers to empty array when value is cleared', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [{ id: 'a', username: 'alpha' }] });
|
||||
fixture.detectChanges();
|
||||
widget.field.value = null;
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectUsers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when flag is off', () => {
|
||||
it('should not react to form rules events', () => {
|
||||
const formService = TestBed.inject(FormService);
|
||||
widget.field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.PEOPLE, value: [] });
|
||||
fixture.detectChanges();
|
||||
const initial = widget.preSelectUsers;
|
||||
widget.field.value = [{ id: 'a', username: 'alpha' }];
|
||||
|
||||
formService.formRulesEvent.next({ type: 'fieldValueChanged' } as any);
|
||||
|
||||
expect(widget.preSelectUsers).toBe(initial);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-1
@@ -23,6 +23,7 @@ import { ComponentSelectionMode } from '../../../../types';
|
||||
import { IdentityUserModel } from '../../../../people/models/identity-user.model';
|
||||
import { IdentityUserService } from '../../../../people/services/identity-user.service';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ReactivePreselectionService } from '../reactive-preselection.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
|
||||
@@ -45,10 +46,12 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
'(invalid)': 'event($event)',
|
||||
'(select)': 'event($event)'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [ReactivePreselectionService]
|
||||
})
|
||||
export class PeopleCloudWidgetComponent extends WidgetComponent implements OnInit {
|
||||
private readonly identityUserService = inject(IdentityUserService);
|
||||
private readonly reactivePreselection: ReactivePreselectionService<IdentityUserModel> = inject(ReactivePreselectionService);
|
||||
|
||||
typeId = 'PeopleCloudWidgetComponent';
|
||||
appName: string;
|
||||
@@ -63,6 +66,15 @@ export class PeopleCloudWidgetComponent extends WidgetComponent implements OnIni
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
ngOnInit() {
|
||||
this.reactivePreselection.connect({
|
||||
getFieldId: () => this.field?.id,
|
||||
getFormId: () => this.field?.form?.id,
|
||||
getFieldValue: () => this.field?.value,
|
||||
getPreselection: () => this.preSelectUsers,
|
||||
setPreselection: (value) => (this.preSelectUsers = value),
|
||||
identityOf: (user) => user?.id ?? user?.username ?? user?.email
|
||||
});
|
||||
|
||||
if (this.field) {
|
||||
this.roles = this.field.roles;
|
||||
this.mode = this.field.optionType as ComponentSelectionMode;
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ADF_TYPED_VALUE_FORMATTING_ENABLED, FormService } from '@alfresco/adf-core';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import { ReactivePreselectionHost, ReactivePreselectionService } from './reactive-preselection.service';
|
||||
|
||||
interface TestItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
describe('ReactivePreselectionService', () => {
|
||||
let formRulesEvent: Subject<any>;
|
||||
let fieldValue: unknown;
|
||||
let preselection: TestItem[];
|
||||
let host: ReactivePreselectionHost<TestItem>;
|
||||
|
||||
const HOST_FIELD_ID = 'target';
|
||||
const HOST_FORM_ID = 'form-1';
|
||||
|
||||
const emit = () => formRulesEvent.next({ type: 'fieldValueChanged' });
|
||||
|
||||
const createService = (token: Observable<boolean> | boolean | null): ReactivePreselectionService<TestItem> => {
|
||||
TestBed.resetTestingModule();
|
||||
formRulesEvent = new Subject<any>();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ReactivePreselectionService,
|
||||
{ provide: FormService, useValue: { formRulesEvent } },
|
||||
{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: token }
|
||||
]
|
||||
});
|
||||
return TestBed.inject<ReactivePreselectionService<TestItem>>(ReactivePreselectionService);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fieldValue = null;
|
||||
preselection = [];
|
||||
host = {
|
||||
getFieldId: () => HOST_FIELD_ID,
|
||||
getFormId: () => HOST_FORM_ID,
|
||||
getFieldValue: () => fieldValue,
|
||||
getPreselection: () => preselection,
|
||||
setPreselection: (value) => (preselection = value),
|
||||
identityOf: (item) => item?.id ?? item?.name
|
||||
};
|
||||
});
|
||||
|
||||
describe('when the flag is enabled', () => {
|
||||
beforeEach(() => createService(true).connect(host));
|
||||
|
||||
it('should sync the preselection with a new array reference when the value changes', () => {
|
||||
const next = [{ id: 'a' }];
|
||||
fieldValue = next;
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toEqual(next);
|
||||
expect(preselection).not.toBe(next);
|
||||
});
|
||||
|
||||
it('should wrap a single object into an array', () => {
|
||||
fieldValue = { id: 'a' };
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
|
||||
it('should not reassign the preselection when the value is unchanged', () => {
|
||||
preselection = [{ id: 'a' }];
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
|
||||
it('should clear the preselection when the value is cleared', () => {
|
||||
preselection = [{ id: 'a' }];
|
||||
fieldValue = null;
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore events other than fieldValueChanged', () => {
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
formRulesEvent.next({ type: 'formLoaded' });
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
|
||||
it('should ignore changes originating from the host field itself', () => {
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
formRulesEvent.next({ type: 'fieldValueChanged', field: { id: HOST_FIELD_ID } });
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
|
||||
it('should ignore changes from a different form', () => {
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
formRulesEvent.next({ type: 'fieldValueChanged', form: { id: 'other-form' } });
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
|
||||
it('should react to changes from another field in the same form', () => {
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
formRulesEvent.next({ type: 'fieldValueChanged', field: { id: 'source' }, form: { id: HOST_FORM_ID } });
|
||||
|
||||
expect(preselection).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
|
||||
it('should drop non-object entries instead of preselecting primitives', () => {
|
||||
fieldValue = 'AMolodyh';
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the flag is disabled', () => {
|
||||
it('should not react to form rules events', () => {
|
||||
createService(false).connect(host);
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
|
||||
it('should treat a null token as disabled', () => {
|
||||
createService(null).connect(host);
|
||||
const initial = preselection;
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
emit();
|
||||
|
||||
expect(preselection).toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the flag is provided as an observable', () => {
|
||||
it('should stay inert until the observable emits true', () => {
|
||||
const token = new BehaviorSubject<boolean>(false);
|
||||
createService(token).connect(host);
|
||||
fieldValue = [{ id: 'a' }];
|
||||
|
||||
emit();
|
||||
expect(preselection).toEqual([]);
|
||||
|
||||
token.next(true);
|
||||
emit();
|
||||
expect(preselection).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ADF_TYPED_VALUE_FORMATTING_ENABLED, FormRulesEvent, FormService } from '@alfresco/adf-core';
|
||||
import { isObservable } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface ReactivePreselectionHost<T> {
|
||||
getFieldId(): string;
|
||||
getFormId(): string;
|
||||
getFieldValue(): unknown;
|
||||
getPreselection(): T[];
|
||||
setPreselection(value: T[]): void;
|
||||
identityOf(item: T): string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReactivePreselectionService<T = unknown> {
|
||||
private readonly formService = inject(FormService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formattingToken = inject(ADF_TYPED_VALUE_FORMATTING_ENABLED, { optional: true });
|
||||
|
||||
private host: ReactivePreselectionHost<T>;
|
||||
private formattingEnabled = false;
|
||||
private subscribed = false;
|
||||
|
||||
connect(host: ReactivePreselectionHost<T>): void {
|
||||
this.host = host;
|
||||
if (isObservable(this.formattingToken)) {
|
||||
this.formattingToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled) => this.setEnabled(enabled ?? false));
|
||||
} else {
|
||||
this.setEnabled(this.formattingToken ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
private setEnabled(enabled: boolean): void {
|
||||
this.formattingEnabled = enabled;
|
||||
if (enabled) {
|
||||
this.subscribeToFormRules();
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToFormRules(): void {
|
||||
if (this.subscribed) {
|
||||
return;
|
||||
}
|
||||
this.subscribed = true;
|
||||
this.formService.formRulesEvent
|
||||
.pipe(
|
||||
filter((event) => event?.type === 'fieldValueChanged' && this.isExternalChange(event)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => this.sync());
|
||||
}
|
||||
|
||||
private isExternalChange(event: FormRulesEvent): boolean {
|
||||
const eventFieldId = event?.field?.id;
|
||||
if (eventFieldId && eventFieldId === this.host.getFieldId()) {
|
||||
return false;
|
||||
}
|
||||
const eventFormId = event?.form?.id;
|
||||
return !eventFormId || eventFormId === this.host.getFormId();
|
||||
}
|
||||
|
||||
private sync(): void {
|
||||
if (!this.formattingEnabled || !this.host) {
|
||||
return;
|
||||
}
|
||||
const next = this.toPreselection(this.host.getFieldValue());
|
||||
if (this.isSamePreselection(this.host.getPreselection(), next)) {
|
||||
return;
|
||||
}
|
||||
this.host.setPreselection(next);
|
||||
}
|
||||
|
||||
private toPreselection(value: unknown): T[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
return entries.filter((entry): entry is T => !!entry && typeof entry === 'object');
|
||||
}
|
||||
|
||||
private isSamePreselection(current: T[], next: T[]): boolean {
|
||||
if (current === next) {
|
||||
return true;
|
||||
}
|
||||
if (current?.length !== next.length) {
|
||||
return false;
|
||||
}
|
||||
return current.every((item, index) => this.host.identityOf(item) === this.host.identityOf(next[index]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user