Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/components/filter-bar/filter-bar.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Clicking on a pseudo-link in the list of parameters will add a new value to the

<!-- example(filter-bar-pipe-types) -->

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.
Expand Down
2 changes: 2 additions & 0 deletions packages/components/filter-bar/filter-bar.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@

<!-- example(filter-bar-pipe-types) -->

Для типов `select` и `multiselect` в шаблоне пайпа доступен необязательный `compareWith` — компаратор, который пробрасывается во внутренний select и определяет, как выбранное значение сопоставляется со списком опций. Переопределите его, когда опции сравниваются по бизнес-ключу или когда выбранное значение — это отдельный объект (например, восстановленный из сохранённого фильтра), а не та же ссылка. Если не задан, опции сопоставляются по их `id`. Кастомный компаратор сам отвечает за обработку `null`/`undefined` — в отличие от компаратора по умолчанию, который никогда не считает `null`/`undefined` совпадением.

### Поиск значений

Если в фильтре много значений, то полезно включить поиск в выпадающем меню.
Expand Down
6 changes: 6 additions & 0 deletions packages/components/filter-bar/filter-bar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ export interface KbqPipeData<V> extends KbqPipe {
export interface KbqPipeTemplate extends Omit<KbqPipe, 'value'> {
values?: unknown[];
valueTemplate?: TemplateRef<any> | 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 {
Expand Down
10 changes: 9 additions & 1 deletion packages/components/filter-bar/pipes/base-pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
KbqFilterBarConfiguration,
KbqPipeData,
KbqPipeTemplate,
KbqPipeType
KbqPipeType,
KbqSelectValue
} from '../filter-bar.types';

/** Injection Token for providing configuration of filter-bar */
Expand Down Expand Up @@ -77,6 +78,12 @@ export abstract class KbqBasePipe<V> implements AfterViewInit {
protected values;
/** TemplateRef for selecting an option */
protected valueTemplate?: TemplateRef<any> | 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.
Expand Down Expand Up @@ -166,6 +173,7 @@ export abstract class KbqBasePipe<V> implements AfterViewInit {
if (template?.values) {
this.values = template.values;
this.valueTemplate = template.valueTemplate;
this.optionCompareWith = template.compareWith;
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<kbq-select
#select="kbqSelect"
multiple
[compareWith]="compareByValue"
[compareWith]="optionCompareWith ?? compareByValue"
[disabled]="data.disabled"
[kbq-title]="pipeTooltip"
[class]="{ 'kbq-active': select.panelOpen }"
Expand Down
110 changes: 109 additions & 1 deletion packages/components/filter-bar/pipes/pipe-multi-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
KbqFilterBarModule,
KbqPipe,
KbqPipeTemplate,
KbqPipeTypes
KbqPipeTypes,
KbqSelectValue
} from '@koobiq/components/filter-bar';
import { KbqBasePipe } from './base-pipe';
import { KbqPipeMultiSelectComponent } from './pipe-multi-select';
Expand Down Expand Up @@ -606,6 +607,113 @@ describe('KbqPipeMultiSelectComponent', () => {
});
});

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);
Expand Down
2 changes: 1 addition & 1 deletion packages/components/filter-bar/pipes/pipe-select.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<kbq-select
#select
[compareWith]="compareByValue"
[compareWith]="optionCompareWith ?? compareByValue"
[disabled]="data.disabled"
[kbq-title]="pipeTooltip"
[class]="{ 'kbq-active': select.panelOpen }"
Expand Down
92 changes: 92 additions & 0 deletions packages/components/filter-bar/pipes/pipe-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,98 @@ describe('KbqPipeSelectComponent', () => {
});
});

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/components/filter-bar.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export abstract class KbqBasePipe<V> 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<void>;
Expand Down Expand Up @@ -866,6 +867,7 @@ export class KbqPipeState<T> {

// @public (undocumented)
export interface KbqPipeTemplate extends Omit<KbqPipe, 'value'> {
compareWith?: (o1: KbqSelectValue | null, o2: KbqSelectValue | null) => boolean;
// (undocumented)
values?: unknown[];
// (undocumented)
Expand Down