diff --git a/packages/components/filter-bar/filter-bar.en.md b/packages/components/filter-bar/filter-bar.en.md index 7c1aef714d..87c442d6cd 100644 --- a/packages/components/filter-bar/filter-bar.en.md +++ b/packages/components/filter-bar/filter-bar.en.md @@ -41,6 +41,8 @@ Clicking on a pseudo-link in the list of parameters will add a new value to the +For the `select` and `multiselect` types, the pipe template accepts an optional `compareWith` — a comparator forwarded to the underlying select that controls how the selected value is matched against the option list. Override it when options are compared by a business key, or when the selected value is a distinct object (e.g. restored from a saved filter) rather than the same reference. When omitted, options are matched by their `id`. A custom comparator is responsible for its own null handling — unlike the default, which never matches a `null`/`undefined` value. + ### Search in pipes If the filter has many values, it is useful to enable search in the drop-down menu. diff --git a/packages/components/filter-bar/filter-bar.ru.md b/packages/components/filter-bar/filter-bar.ru.md index 4f1de26fa2..ab6a4978a8 100644 --- a/packages/components/filter-bar/filter-bar.ru.md +++ b/packages/components/filter-bar/filter-bar.ru.md @@ -41,6 +41,8 @@ +Для типов `select` и `multiselect` в шаблоне пайпа доступен необязательный `compareWith` — компаратор, который пробрасывается во внутренний select и определяет, как выбранное значение сопоставляется со списком опций. Переопределите его, когда опции сравниваются по бизнес-ключу или когда выбранное значение — это отдельный объект (например, восстановленный из сохранённого фильтра), а не та же ссылка. Если не задан, опции сопоставляются по их `id`. Кастомный компаратор сам отвечает за обработку `null`/`undefined` — в отличие от компаратора по умолчанию, который никогда не считает `null`/`undefined` совпадением. + ### Поиск значений Если в фильтре много значений, то полезно включить поиск в выпадающем меню. diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 028a126c61..1b0d4024d5 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -183,6 +183,12 @@ export interface KbqPipeData extends KbqPipe { export interface KbqPipeTemplate extends Omit { values?: unknown[]; valueTemplate?: TemplateRef | string; + /** + * Custom equality comparator forwarded to the underlying `kbq-select` of the `select` / + * `multiselect` pipes. When omitted, the pipe's default id-based comparator (`compareByValue`) + * is used. Ignored by other pipe types. + */ + compareWith?: (o1: KbqSelectValue | null, o2: KbqSelectValue | null) => boolean; } export interface KbqSaveFilterError { diff --git a/packages/components/filter-bar/pipes/base-pipe.ts b/packages/components/filter-bar/pipes/base-pipe.ts index 8d20564a47..e9f0b57c86 100644 --- a/packages/components/filter-bar/pipes/base-pipe.ts +++ b/packages/components/filter-bar/pipes/base-pipe.ts @@ -22,7 +22,8 @@ import { KbqFilterBarConfiguration, KbqPipeData, KbqPipeTemplate, - KbqPipeType + KbqPipeType, + KbqSelectValue } from '../filter-bar.types'; /** Injection Token for providing configuration of filter-bar */ @@ -77,6 +78,12 @@ export abstract class KbqBasePipe implements AfterViewInit { protected values; /** TemplateRef for selecting an option */ protected valueTemplate?: TemplateRef | string; + /** + * Optional equality comparator forwarded from the pipe template to the underlying `kbq-select`. + * Only consumed by the select / multi-select pipe components; when unset they fall back to their + * own id-based `compareByValue`. + */ + protected optionCompareWith?: (o1: KbqSelectValue | null, o2: KbqSelectValue | null) => boolean; /** * Whether the current platform is a Mac. @@ -166,6 +173,7 @@ export abstract class KbqBasePipe implements AfterViewInit { if (template?.values) { this.values = template.values; this.valueTemplate = template.valueTemplate; + this.optionCompareWith = template.compareWith; } }; diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.html b/packages/components/filter-bar/pipes/pipe-multi-select.html index 7fef894da7..6465ac9ce9 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.html +++ b/packages/components/filter-bar/pipes/pipe-multi-select.html @@ -1,7 +1,7 @@ { }); }); + describe('compareWith forwarding', () => { + const customCompare = (o1: KbqSelectValue | null, o2: KbqSelectValue | null): boolean => + o1?.value === o2?.value; + + // Options without `id`, used to force reliance on the custom `value`-based comparator (see the + // 'should match selected values' test below for why). + const idlessValues: KbqSelectValue[] = [ + { name: 'Option 1', value: 'value1' }, + { name: 'Option 2', value: 'value2' }, + { name: 'Option 3', value: 'value3' } + ]; + + const setTemplate = (values: KbqSelectValue[]) => { + fixture.componentInstance.pipeTemplates = [ + { + name: 'MultiSelect', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.MultiSelect, + values, + compareWith: customCompare, + cleanable: false, + removable: false, + disabled: false + } + ]; + }; + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + }); + + it('should forward a custom compareWith from the pipe template to the inner select', () => { + setTemplate(SELECT_VALUES); + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: [] })]); + fixture.detectChanges(); + + expect(getPipeComponent().select().compareWith).toBe(customCompare); + }); + + it('should use the default id comparator when the template omits compareWith', () => { + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: [] })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + + expect(component.select().compareWith).toBe(component.compareByValue); + }); + + it('should clear a previously set compareWith when a later template update omits it', () => { + setTemplate(SELECT_VALUES); + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: [] })]); + fixture.detectChanges(); + + expect(getPipeComponent().select().compareWith).toBe(customCompare); + + // A follow-up pipeTemplates update for the same pipe id that omits compareWith (e.g. new + // id-based values) must fall back to the default comparator, not keep forwarding the stale one. + fixture.componentInstance.pipeTemplates = [ + { + name: 'MultiSelect', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.MultiSelect, + values: SELECT_VALUES, + cleanable: false, + removable: false, + disabled: false + } + ]; + fixture.detectChanges(); + + const component = getPipeComponent(); + + expect(component.select().compareWith).toBe(component.compareByValue); + }); + + it('should match selected values in the panel using the custom comparator', fakeAsync(() => { + setTemplate(idlessValues); + // The two selected values are distinct objects equal to the second and third options only by + // `value`. Each selected item is resolved independently via `getCorrespondOption`/`.find`, so + // selecting more than one id-less item catches misattribution between items — under the + // default id comparator every id-less option compares equal and only the first ('Option 1') + // would ever be picked, so asserting both 'Option 2' and 'Option 3' are highlighted is what + // proves the custom `value` comparator is applied per item. + fixture.componentInstance.activeFilter = createFilter([ + createPipe({ + name: 'test', + value: [ + { name: 'Option 2', value: 'value2' }, + { name: 'Option 3', value: 'value3' } + ] + }) + ]); + fixture.detectChanges(); + + openSelect(); + flush(); + fixture.detectChanges(); + + const selectedText = Array.from(document.querySelectorAll('.kbq-option.kbq-selected')).map((el) => + el.textContent?.trim() + ); + + expect(selectedText).toEqual(['Option 2', 'Option 3']); + })); + }); + describe('selectAllHandler', () => { beforeEach(() => { fixture = TestBed.createComponent(TestComponent); diff --git a/packages/components/filter-bar/pipes/pipe-select.html b/packages/components/filter-bar/pipes/pipe-select.html index a39c4d87fe..9d4c09ed93 100644 --- a/packages/components/filter-bar/pipes/pipe-select.html +++ b/packages/components/filter-bar/pipes/pipe-select.html @@ -1,6 +1,6 @@ { }); }); + describe('compareWith forwarding', () => { + const customCompare = (o1: KbqSelectValue | null, o2: KbqSelectValue | null): boolean => + o1?.value === o2?.value; + + const setTemplateWithComparator = () => { + fixture.componentInstance.pipeTemplates = [ + { + name: 'Select', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Select, + values: SELECT_VALUES, + compareWith: customCompare, + cleanable: false, + removable: false, + disabled: false + } + ]; + }; + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); + }); + + it('should forward a custom compareWith from the pipe template to the inner select', () => { + setTemplateWithComparator(); + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: null })]); + fixture.detectChanges(); + + expect(getPipeComponent().select().compareWith).toBe(customCompare); + }); + + it('should use the default id comparator when the template omits compareWith', () => { + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: null })]); + fixture.detectChanges(); + + const component = getPipeComponent(); + + expect(component.select().compareWith).toBe(component.compareByValue); + }); + + it('should clear a previously set compareWith when a later template update omits it', () => { + setTemplateWithComparator(); + fixture.componentInstance.activeFilter = createFilter([createPipe({ name: 'test', value: null })]); + fixture.detectChanges(); + + expect(getPipeComponent().select().compareWith).toBe(customCompare); + + // A follow-up pipeTemplates update for the same pipe id that omits compareWith (e.g. new + // id-based values) must fall back to the default comparator, not keep forwarding the stale one. + fixture.componentInstance.pipeTemplates = [ + { + name: 'Select', + id: PIPE_TEMPLATE_ID, + type: KbqPipeTypes.Select, + values: SELECT_VALUES, + cleanable: false, + removable: false, + disabled: false + } + ]; + fixture.detectChanges(); + + const component = getPipeComponent(); + + expect(component.select().compareWith).toBe(component.compareByValue); + }); + + it('should match the selected value in the panel using the custom comparator', fakeAsync(() => { + setTemplateWithComparator(); + // The selected value is a distinct object equal to SELECT_VALUES[1] only by `value`; the + // options carry no `id`. `getCorrespondOption` uses `.find`, so exactly one option is + // selected either way — under the default id comparator every id-less option compares equal + // and the first ('Option 1') wins, so asserting the selected option is 'Option 2' is what + // proves the custom `value` comparator is applied. + fixture.componentInstance.activeFilter = createFilter([ + createPipe({ name: 'test', value: { name: 'Option 2', value: 'value2' } }) + ]); + fixture.detectChanges(); + + openSelect(); + flush(); + fixture.detectChanges(); + + const selectedText = Array.from(document.querySelectorAll('.kbq-option.kbq-selected')).map((el) => + el.textContent?.trim() + ); + + expect(selectedText).toEqual(['Option 2']); + })); + }); + describe('open', () => { beforeEach(() => { fixture = TestBed.createComponent(TestComponent); diff --git a/packages/docs-examples/components/filter-bar/filter-bar-pipe-types/filter-bar-pipe-types-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-pipe-types/filter-bar-pipe-types-example.ts index a58cb595b6..3198c91e31 100644 --- a/packages/docs-examples/components/filter-bar/filter-bar-pipe-types/filter-bar-pipe-types-example.ts +++ b/packages/docs-examples/components/filter-bar/filter-bar-pipe-types/filter-bar-pipe-types-example.ts @@ -89,14 +89,17 @@ export class FilterBarPipeTypesExample { name: 'MultiSelect', type: KbqPipeTypes.MultiSelect, values: [ - { name: 'Option 1', id: '1' }, - { name: 'Option 2', id: '2' }, - { name: 'Option 3', id: '3' }, - { name: 'Option 4', id: '4' }, - { name: 'Option 5', id: '5' }, - { name: 'Option 6', id: '6' }, - { name: 'Option 7', id: '7' } + { name: 'Option 1', value: 'value1' }, + { name: 'Option 2', value: 'value2' }, + { name: 'Option 3', value: 'value3' }, + { name: 'Option 4', value: 'value4' }, + { name: 'Option 5', value: 'value5' }, + { name: 'Option 6', value: 'value6' }, + { name: 'Option 7', value: 'value7' } ], + // Same rationale as the Select template below: options are identified by `value` rather than + // a synthetic `id`, so a custom `compareWith` is needed instead of the default id-based one. + compareWith: (o1, o2) => !!o1 && !!o2 && o1.value === o2.value, cleanable: false, removable: false, disabled: false @@ -105,15 +108,19 @@ export class FilterBarPipeTypesExample { name: 'Select', type: KbqPipeTypes.Select, values: [ - { name: 'Option 1', id: '1' }, - { name: 'Option 2', id: '2' }, - { name: 'Option 3', id: '3' }, - { name: 'Option 4', id: '4' }, - { name: 'Option 5', id: '5' }, - { name: 'Option 6', id: '6' }, - { name: 'Option 7', id: '7' } + { name: 'Option 1', value: 'value1' }, + { name: 'Option 2', value: 'value2' }, + { name: 'Option 3', value: 'value3' }, + { name: 'Option 4', value: 'value4' }, + { name: 'Option 5', value: 'value5' }, + { name: 'Option 6', value: 'value6' }, + { name: 'Option 7', value: 'value7' } ], - + // Options are identified by their business `value` rather than a synthetic `id`. Override + // `compareWith` whenever a selected value can be a distinct object equal only by that key + // (e.g. one restored from a saved filter) instead of the same reference. A custom comparator + // is responsible for its own null handling — unlike the default, which never matches null. + compareWith: (o1, o2) => !!o1 && !!o2 && o1.value === o2.value, cleanable: false, removable: false, disabled: false diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index bd2c9c21ae..808f5c768b 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -182,6 +182,7 @@ export abstract class KbqBasePipe implements AfterViewInit { onClear(): void; onRemove(): void; abstract open(): void; + protected optionCompareWith?: (o1: KbqSelectValue | null, o2: KbqSelectValue | null) => boolean; protected restoreTriggerFocus(): void; get showRemoveButton(): boolean; readonly stateChanges: Subject; @@ -866,6 +867,7 @@ export class KbqPipeState { // @public (undocumented) export interface KbqPipeTemplate extends Omit { + compareWith?: (o1: KbqSelectValue | null, o2: KbqSelectValue | null) => boolean; // (undocumented) values?: unknown[]; // (undocumented)