From 7f0ed7a6a373e7dc637a1d811221ff1def65ebed Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 7 Jul 2026 21:59:40 +0300 Subject: [PATCH 01/16] fix(filter-bar): first attempt --- packages/components/core/locales/en-US.ts | 7 +- packages/components/core/locales/es-LA.ts | 7 +- packages/components/core/locales/pt-BR.ts | 7 +- packages/components/core/locales/ru-RU.ts | 7 +- packages/components/core/locales/tk-TM.ts | 7 +- .../filter-bar/examples.filter-bar.en.md | 2 +- .../filter-bar/filter-bar-tokens.scss | 6 + .../filter-bar/filter-bar.module.ts | 4 +- .../components/filter-bar/filter-bar.scss | 4 +- .../components/filter-bar/filter-bar.spec.ts | 40 ++- packages/components/filter-bar/filter-bar.ts | 24 +- .../components/filter-bar/filter-bar.types.ts | 29 +- .../filter-bar/filter-refresher.scss | 2 +- .../components/filter-bar/filter-refresher.ts | 33 +- packages/components/filter-bar/filters.html | 16 +- packages/components/filter-bar/filters.scss | 6 +- .../components/filter-bar/filters.spec.ts | 13 +- packages/components/filter-bar/filters.ts | 38 ++- .../components/filter-bar/pipe-add.spec.ts | 10 +- packages/components/filter-bar/pipe-add.ts | 9 +- .../filter-bar/pipes/base-pipe.scss | 8 +- .../components/filter-bar/pipes/base-pipe.ts | 30 +- .../filter-bar/pipes/pipe-button.ts | 3 +- .../filter-bar/pipes/pipe-date-base.ts | 226 +++++++++++++ .../filter-bar/pipes/pipe-date.spec.ts | 10 +- .../components/filter-bar/pipes/pipe-date.ts | 217 +------------ .../filter-bar/pipes/pipe-datetime.spec.ts | 10 +- .../filter-bar/pipes/pipe-datetime.ts | 218 +------------ .../filter-bar/pipes/pipe-multi-select.html | 1 + .../pipes/pipe-multi-select.spec.ts | 34 +- .../filter-bar/pipes/pipe-multi-select.ts | 15 +- .../pipes/pipe-multi-tree-select.html | 28 +- .../pipes/pipe-multi-tree-select.scss | 4 +- .../pipes/pipe-multi-tree-select.spec.ts | 10 +- .../pipes/pipe-multi-tree-select.ts | 45 +-- .../filter-bar/pipes/pipe-multiselect.scss | 4 +- .../filter-bar/pipes/pipe-select.html | 1 + .../filter-bar/pipes/pipe-select.spec.ts | 10 +- .../filter-bar/pipes/pipe-select.ts | 13 +- .../filter-bar/pipes/pipe-text.spec.ts | 10 +- .../components/filter-bar/pipes/pipe-text.ts | 2 +- .../filter-bar/pipes/pipe-tree-select.html | 26 +- .../filter-bar/pipes/pipe-tree-select.spec.ts | 14 +- .../filter-bar/pipes/pipe-tree-select.ts | 32 +- packages/components/filter-bar/public-api.ts | 1 + tools/public_api_guard/components/core.api.md | 50 +++ .../components/filter-bar.api.md | 306 +++++++++++++----- 47 files changed, 942 insertions(+), 657 deletions(-) create mode 100644 packages/components/filter-bar/filter-bar-tokens.scss create mode 100644 packages/components/filter-bar/pipes/pipe-date-base.ts diff --git a/packages/components/core/locales/en-US.ts b/packages/components/core/locales/en-US.ts index bad707dba2..085b703982 100644 --- a/packages/components/core/locales/en-US.ts +++ b/packages/components/core/locales/en-US.ts @@ -78,11 +78,16 @@ export const enUSLocaleData = { error: 'The search with such name already exists', errorHint: 'Could not save the filter. Try again or contact the administrator.', saveButton: 'Save', - cancelButton: 'Cancel' + cancelButton: 'Cancel', + actionsTooltip: 'Filter actions' }, add: { tooltip: 'Add filter' }, + refresher: { + refresh: 'Refresh', + settings: 'Refresh settings' + }, pipe: { clearButtonTooltip: 'Clear', removeButtonTooltip: 'Delete', diff --git a/packages/components/core/locales/es-LA.ts b/packages/components/core/locales/es-LA.ts index 94471ba698..386123dc4b 100644 --- a/packages/components/core/locales/es-LA.ts +++ b/packages/components/core/locales/es-LA.ts @@ -77,11 +77,16 @@ export const esLALocaleData = { error: 'Ya existe una búsqueda con ese nombre', errorHint: 'No se pudo guardar el filtro. Intente de nuevo o comuníquese con el administrador.', saveButton: 'Guardar', - cancelButton: 'Cancelar' + cancelButton: 'Cancelar', + actionsTooltip: 'Acciones de filtro' }, add: { tooltip: 'Agregar filtro' }, + refresher: { + refresh: 'Actualizar', + settings: 'Configuración de actualización' + }, pipe: { clearButtonTooltip: 'Borrar', removeButtonTooltip: 'Eliminar', diff --git a/packages/components/core/locales/pt-BR.ts b/packages/components/core/locales/pt-BR.ts index 45368cfcd1..54deac9c57 100644 --- a/packages/components/core/locales/pt-BR.ts +++ b/packages/components/core/locales/pt-BR.ts @@ -77,11 +77,16 @@ export const ptBRLocaleData = { error: 'A busca com esse nome já existe', errorHint: 'O filtro não pôde ser salvo. Tente de novo ou entre em contato com o administrador.', saveButton: 'Salvar', - cancelButton: 'Cancelar' + cancelButton: 'Cancelar', + actionsTooltip: 'Ações do filtro' }, add: { tooltip: 'Adicionar filtros' }, + refresher: { + refresh: 'Atualizar', + settings: 'Configurações de atualização' + }, pipe: { clearButtonTooltip: 'Apagar', removeButtonTooltip: 'Deletar', diff --git a/packages/components/core/locales/ru-RU.ts b/packages/components/core/locales/ru-RU.ts index d9755d3054..3faa2adbff 100644 --- a/packages/components/core/locales/ru-RU.ts +++ b/packages/components/core/locales/ru-RU.ts @@ -78,11 +78,16 @@ export const ruRULocaleData = { error: 'Поиск с таким названием уже существует', errorHint: 'Не удалось сохранить фильтр. Попробуйте снова или сообщите администратору.', saveButton: 'Сохранить', - cancelButton: 'Отмена' + cancelButton: 'Отмена', + actionsTooltip: 'Действия с фильтром' }, add: { tooltip: 'Добавить фильтр' }, + refresher: { + refresh: 'Обновить', + settings: 'Настройки обновления' + }, pipe: { clearButtonTooltip: 'Очистить', removeButtonTooltip: 'Удалить', diff --git a/packages/components/core/locales/tk-TM.ts b/packages/components/core/locales/tk-TM.ts index e335bfe187..801b513060 100644 --- a/packages/components/core/locales/tk-TM.ts +++ b/packages/components/core/locales/tk-TM.ts @@ -78,11 +78,16 @@ export const tkTMLocaleData = { error: 'Şeýle atly gözleg eýýäm bar', errorHint: 'Filtri ýazdyryp bolmady. Täzeden synanyň ýa-da administratora ýüz tutuň.', saveButton: 'Ýazdyr', - cancelButton: 'Ýatyr' + cancelButton: 'Ýatyr', + actionsTooltip: 'Filtr amallary' }, add: { tooltip: 'Filtr goş' }, + refresher: { + refresh: 'Täzele', + settings: 'Täzeleme sazlamalary' + }, pipe: { clearButtonTooltip: 'Arassala', removeButtonTooltip: 'Ýokla', diff --git a/packages/components/filter-bar/examples.filter-bar.en.md b/packages/components/filter-bar/examples.filter-bar.en.md index 317222a190..87d75b0cdc 100644 --- a/packages/components/filter-bar/examples.filter-bar.en.md +++ b/packages/components/filter-bar/examples.filter-bar.en.md @@ -1,6 +1,6 @@ #### Example of adding a custom pipe - + #### Example of multiple pipes with the same type diff --git a/packages/components/filter-bar/filter-bar-tokens.scss b/packages/components/filter-bar/filter-bar-tokens.scss new file mode 100644 index 0000000000..e471660a20 --- /dev/null +++ b/packages/components/filter-bar/filter-bar-tokens.scss @@ -0,0 +1,6 @@ +.kbq-filter-bar { + /* Maximum width of a pipe / filters trigger before its label truncates. */ + --kbq-filter-bar-pipe-max-width: 320px; + /* Minimum width reserved for the refresher control. */ + --kbq-filter-bar-refresher-min-width: 64px; +} diff --git a/packages/components/filter-bar/filter-bar.module.ts b/packages/components/filter-bar/filter-bar.module.ts index a7b277440d..da7d530bdc 100644 --- a/packages/components/filter-bar/filter-bar.module.ts +++ b/packages/components/filter-bar/filter-bar.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { KbqFilterBar } from './filter-bar'; import { KbqFilterBarButton } from './filter-bar-button'; import { kbqFilterBarPipesProvider } from './filter-bar.types'; -import { KbqFilterBarRefresher } from './filter-refresher'; +import { KbqFilterRefresher } from './filter-refresher'; import { KbqFilterReset } from './filter-reset'; import { KbqFilters } from './filters'; import { KbqPipeAdd } from './pipe-add'; @@ -13,7 +13,7 @@ import { KbqPipeState } from './pipes/pipe-state'; const COMPONENTS = [ KbqFilterBar, KbqFilters, - KbqFilterBarRefresher, + KbqFilterRefresher, KbqFilterBarButton, KbqFilterReset, KbqPipeAdd, diff --git a/packages/components/filter-bar/filter-bar.scss b/packages/components/filter-bar/filter-bar.scss index 2ad7dfcc5f..3e0454c9d5 100644 --- a/packages/components/filter-bar/filter-bar.scss +++ b/packages/components/filter-bar/filter-bar.scss @@ -15,7 +15,7 @@ gap: var(--kbq-size-s); .kbq-button > .kbq-icon { - margin-right: var(--kbq-size-xxs); + margin-inline-end: var(--kbq-size-xxs); } & > .kbq-icon { @@ -27,7 +27,7 @@ display: flex; flex-direction: row; - margin-left: var(--kbq-size-3xl); + margin-inline-start: var(--kbq-size-3xl); } } diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index feaf4595ea..ee33f71dd3 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -78,7 +78,15 @@ describe('KbqFilterBar', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ @@ -293,6 +301,36 @@ describe('KbqFilterBar', () => { expect(filterBar.filter!.pipes).toEqual([pipe2]); }); + it('should leave filter.pipes untouched when the pipe is not part of the filter', () => { + const pipe1 = createPipe({ name: 'pipe1' }); + const pipe2 = createPipe({ name: 'pipe2' }); + const absentPipe = createPipe({ name: 'absent' }); + const filterBar = getFilterBar(); + + filterBar.filter = createFilter([pipe1, pipe2]); + + filterBar.removePipe(absentPipe); + + expect(filterBar.filter!.pipes).toEqual([pipe1, pipe2]); + }); + + it('should not emit onRemovePipe when the pipe is not part of the filter', () => { + const pipe = createPipe({ name: 'pipe1' }); + const absentPipe = createPipe({ name: 'absent' }); + const filterBar = getFilterBar(); + + filterBar.filter = createFilter([pipe]); + + const spy = jest.fn(); + + filterBar.onRemovePipe.subscribe(spy); + spy.mockClear(); + + filterBar.removePipe(absentPipe); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should emit onRemovePipe event', () => { const pipe = createPipe({ name: 'removable' }); const filterBar = getFilterBar(); diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index 9e9cabd6a8..501a40cba2 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -4,11 +4,9 @@ import { ChangeDetectorRef, Component, contentChild, - EventEmitter, inject, Input, input, - Output, output, ViewEncapsulation } from '@angular/core'; @@ -20,6 +18,7 @@ import { KBQ_FILTER_BAR_CONFIGURATION, KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KbqFilter, + KbqFilterBarConfiguration, KbqPipe, KbqPipeTemplate } from './filter-bar.types'; @@ -46,7 +45,7 @@ import { KbqFilters } from './filters'; `, - styleUrls: ['filter-bar.scss'], + styleUrls: ['filter-bar-tokens.scss', 'filter-bar.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -61,7 +60,8 @@ export class KbqFilterBar { readonly externalConfiguration = inject(KBQ_FILTER_BAR_CONFIGURATION, { optional: true }); - configuration; + /** Localized strings and configuration for the filter-bar and its pipes. */ + configuration: KbqFilterBarConfiguration = KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; /** @docs-private */ readonly filters = contentChild(KbqFilters); @@ -114,7 +114,7 @@ export class KbqFilterBar { */ readonly filterChange = output(); /** Event that emits whenever the value of the pipe changes. */ - @Output() readonly onChangePipe = new EventEmitter(); + readonly onChangePipe = output(); /** Event that emits whenever the pipe deleted. */ readonly onRemovePipe = output(); /** Event that emits whenever the pipe cleared. */ @@ -174,7 +174,7 @@ export class KbqFilterBar { this.initDefaultParams(); } - merge(this.onChangePipe, outputToObservable(this.onRemovePipe)) + merge(outputToObservable(this.onChangePipe), outputToObservable(this.onRemovePipe)) .pipe(takeUntilDestroyed()) .subscribe(() => { if (this.filter) { @@ -185,7 +185,7 @@ export class KbqFilterBar { merge( outputToObservable(this.filterChange), - this.onChangePipe, + outputToObservable(this.onChangePipe), outputToObservable(this.onRemovePipe), this.internalFilterChanges ) @@ -198,7 +198,11 @@ export class KbqFilterBar { /** Remove pipe from current filter and emit event */ removePipe(pipe: KbqPipe) { - this.filter?.pipes.splice(this.filter?.pipes.indexOf(pipe), 1); + if (!this.filter?.pipes.includes(pipe)) return; + + // Replace the array instead of splicing in place so `pipes` follows a new-reference-on-change + // model (friendlier to OnPush / signal reference-equality) and unknown pipes are left untouched. + this.filter.pipes = this.filter.pipes.filter((item) => item !== pipe); this.onRemovePipe.emit(pipe); this.changes.next(); @@ -216,7 +220,9 @@ export class KbqFilterBar { /** Set the filter state "changed" to false */ resetFilterChangedState() { - this.filter!.changed = false; + if (!this.filter) return; + + this.filter.changed = false; } private updateLocaleParams = () => { diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 2bd9e907ef..23ed1b889c 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -1,6 +1,7 @@ -import { InjectionToken, Provider, TemplateRef } from '@angular/core'; +import { InjectionToken, Provider, TemplateRef, Type } from '@angular/core'; import { ruRULocaleData } from '@koobiq/components/core'; import { KbqFilterBar } from './filter-bar'; +import type { KbqBasePipe } from './pipes/base-pipe'; import { KbqPipeDateComponent } from './pipes/pipe-date'; import { KbqPipeDatetimeComponent } from './pipes/pipe-datetime'; import { KbqPipeMultiSelectComponent } from './pipes/pipe-multi-select'; @@ -10,20 +11,25 @@ import { KbqPipeSelectComponent } from './pipes/pipe-select'; import { KbqPipeTextComponent } from './pipes/pipe-text'; import { KbqPipeTreeSelectComponent } from './pipes/pipe-tree-select'; -/**default configuration of filter-bar */ +/** default configuration of filter-bar */ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION = ruRULocaleData.filterBar; +/** Shape of the localized strings consumed by the filter-bar and its pipes. */ +export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; + /** Injection Token for providing configuration of filter-bar */ -export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken('KbqFilterBarConfiguration'); +export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken('KbqFilterBarConfiguration'); /** Injection Token for providing pipes in filter-bar */ -export const KBQ_FILTER_BAR_PIPES = new InjectionToken('kbq-filter-bar-pipes'); +export const KBQ_FILTER_BAR_PIPES = new InjectionToken>>>( + 'kbq-filter-bar-pipes' +); /** Utility provider for `KBQ_FILTER_BAR_PIPES`. */ export const kbqFilterBarPipesProvider = (): Provider => { return { provide: KBQ_FILTER_BAR_PIPES, - useValue: new Map(defaultFilterBarPipes) + useValue: new Map>>(defaultFilterBarPipes) }; }; @@ -39,10 +45,11 @@ export enum KbqPipeTypes { Datetime = 'datetime' } -export type KbqPipeType = `${KbqPipeTypes}` | string; +// `string & {}` keeps the literal union members visible to autocomplete while still allowing custom pipe types. +export type KbqPipeType = `${KbqPipeTypes}` | (string & {}); /** list of pipes available out of the box. */ -export const defaultFilterBarPipes: [string, unknown][] = [ +export const defaultFilterBarPipes: [KbqPipeType, Type>][] = [ [KbqPipeTypes.ReadOnly, KbqPipeReadonlyComponent], [KbqPipeTypes.Text, KbqPipeTextComponent], [KbqPipeTypes.Select, KbqPipeSelectComponent], @@ -62,6 +69,8 @@ export interface KbqDateTimeValue { export interface KbqSelectValue { name: string; value: unknown; + /** Optional stable identifier used by the select/multi-select pipe comparators. */ + id?: string | number; } export interface KbqFilter { @@ -147,14 +156,16 @@ export interface KbqTreeSelectNode { * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. * The return value is the list of extends `KbqTreeSelectNode`. */ -export function kbqBuildTree(value: any, level: number): T[] { +export function kbqBuildTree(value: Record, level: number): T[] { const data: T[] = []; for (const [k, v] of Object.entries(value)) { + const isBranch = typeof v === 'object' && v !== null && !(v as Record)['value']; + data.push({ name: k.toString(), value: v, - children: typeof v === 'object' && v && !v['value'] ? kbqBuildTree(v, level + 1) : null + children: isBranch ? kbqBuildTree(v as Record, level + 1) : null } as T); } diff --git a/packages/components/filter-bar/filter-refresher.scss b/packages/components/filter-bar/filter-refresher.scss index ac4e714306..8515d9700b 100644 --- a/packages/components/filter-bar/filter-refresher.scss +++ b/packages/components/filter-bar/filter-refresher.scss @@ -2,5 +2,5 @@ display: flex; align-items: center; - min-width: 64px; + min-width: var(--kbq-filter-bar-refresher-min-width, 64px); } diff --git a/packages/components/filter-bar/filter-refresher.ts b/packages/components/filter-bar/filter-refresher.ts index 1c8c280a8d..55612a483a 100644 --- a/packages/components/filter-bar/filter-refresher.ts +++ b/packages/components/filter-bar/filter-refresher.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqFilterBar } from './filter-bar'; +import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION } from './filter-bar.types'; @Component({ selector: 'kbq-filter-refresher, [kbq-filter-refresher]', @@ -9,11 +11,21 @@ import { KbqIconModule } from '@koobiq/components/icon'; KbqIconModule ], template: ` - - `, styleUrls: ['filter-refresher.scss'], @@ -23,4 +35,13 @@ import { KbqIconModule } from '@koobiq/components/icon'; class: 'kbq-filter-refresher' } }) -export class KbqFilterBarRefresher {} +export class KbqFilterRefresher { + /** KbqFilterBar instance */ + private readonly filterBar = inject(KbqFilterBar, { optional: true }); + + /** localized data + * @docs-private */ + protected get localeData() { + return this.filterBar?.configuration ?? KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; + } +} diff --git a/packages/components/filter-bar/filters.html b/packages/components/filter-bar/filters.html index 6e905c3750..0682686855 100644 --- a/packages/components/filter-bar/filters.html +++ b/packages/components/filter-bar/filters.html @@ -39,12 +39,13 @@ kbq-button kbqFilterBarButton class="kbq-button_action" + [attr.aria-label]="localeData.saveNewFilterTooltip" [class.kbq-active]="focusedElementBeforeIs(saveNewFilterButton) && opened" [color]="colors.Empty" [disabled]="isSaving" (click)="openSaveAsNewFilterPopover()" > - + } @@ -54,13 +55,14 @@ kbq-button kbqFilterBarButton class="kbq-button_action" + [attr.aria-label]="localeData.actionsTooltip" [color]="colors.Empty" [kbqDropdownTriggerFor]="filterActions" [disabled]="isSaving" [class.kbq-active]="focusedElementBeforeIs(filterActionsButton) && filterActionsOpened" [class]="{ 'kbq-button_changed-saved-filter': filterBar.isSavedAndChanged }" > - + @@ -78,6 +80,7 @@ placeholder="{{ localeData.searchPlaceholder }}" autocomplete="off" kbqInput + [attr.aria-label]="localeData.searchPlaceholder" [formControl]="searchControl" (click)="stopEventPropagation($event)" (keydown)="searchKeydownHandler($event)" @@ -159,7 +162,13 @@ @if (showFilterSavingError) { - + {{ filterSavingErrorText }} } @@ -172,6 +181,7 @@ #newFilterName kbqInput type="text" + [attr.aria-label]="localeData.name" [formControl]="filterName" (keydown.enter)="saveAsNew($event)" /> diff --git a/packages/components/filter-bar/filters.scss b/packages/components/filter-bar/filters.scss index c662178278..e4367a1aff 100644 --- a/packages/components/filter-bar/filters.scss +++ b/packages/components/filter-bar/filters.scss @@ -5,7 +5,7 @@ display: flex; > .kbq-button { - max-width: 320px; + max-width: var(--kbq-filter-bar-pipe-max-width, 320px); & .kbq-button-wrapper { width: 100%; @@ -13,7 +13,7 @@ } > .kbq-button .kbq-icon { - margin-right: var(--kbq-size-xxs); + margin-inline-end: var(--kbq-size-xxs); } & .kbq-filters__filter-name { @@ -36,7 +36,7 @@ & .kbq-filter-bar__arrow { align-self: center; - margin-left: var(--kbq-size-s); + margin-inline-start: var(--kbq-size-s); } .kbq-button.kbq-button_changed-filter { diff --git a/packages/components/filter-bar/filters.spec.ts b/packages/components/filter-bar/filters.spec.ts index 7885a15a4c..75afdf1673 100644 --- a/packages/components/filter-bar/filters.spec.ts +++ b/packages/components/filter-bar/filters.spec.ts @@ -89,7 +89,15 @@ describe('KbqFilters', () => { let filterBarDebugElement: DebugElement; let filtersDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ @@ -283,7 +291,8 @@ describe('KbqFilters', () => { fixture.detectChanges(); flush(); - Object.defineProperty(component.filterName, 'invalid', { get: () => true }); + // Empty value fails the real Validators.required guard built in preparePopover. + component.filterName.setValue(''); component.saveAsNew(); expect(fixture.componentInstance.onSaveSpy).not.toHaveBeenCalled(); diff --git a/packages/components/filter-bar/filters.ts b/packages/components/filter-bar/filters.ts index 083c46c629..2dc9c0756e 100644 --- a/packages/components/filter-bar/filters.ts +++ b/packages/components/filter-bar/filters.ts @@ -14,7 +14,7 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule, UntypedFormControl, Validators } from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { KbqAlertModule } from '@koobiq/components/alert'; import { KbqButton, KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors, KbqFormsModule, PopUpPlacements, PopUpSizes } from '@koobiq/components/core'; @@ -97,14 +97,14 @@ export class KbqFilters implements OnInit { private readonly saveFilterButton = viewChild.required('saveFilterButton'); /** control for search filter */ - searchControl: UntypedFormControl = new UntypedFormControl(); + readonly searchControl = new FormControl(null); /** filtered by search filters */ filteredOptions: Observable; /** @docs-private */ - popoverSize = PopUpSizes.Medium; + protected readonly popoverSize = PopUpSizes.Medium; /** @docs-private */ - popoverOffset: number = 4; + protected readonly popoverOffset: number = 4; /** new filter name for saving */ filterName: FormControl; @@ -113,11 +113,11 @@ export class KbqFilters implements OnInit { saveNewFilter: boolean; showFilterSavingError: boolean = false; - filterSavingErrorText: string; + filterSavingErrorText: string = ''; isSaving: boolean = false; - readonly filters = input(undefined!); + readonly filters = input.required(); /** Event that is generated whenever the user selects a filter. */ readonly onSelectFilter = output(); @@ -172,7 +172,7 @@ export class KbqFilters implements OnInit { private focusedElementBeforeOpen: KbqButton | null; constructor() { - this.filterBar.changes.subscribe(() => this.changeDetectorRef.markForCheck()); + this.filterBar.changes.pipe(takeUntilDestroyed()).subscribe(() => this.changeDetectorRef.markForCheck()); } ngOnInit(): void { @@ -252,16 +252,24 @@ export class KbqFilters implements OnInit { } restoreFocus() { - if (this.focusedElementBeforeOpen && !this.focusedElementBeforeOpen.disabled) { - this.focusMonitor.focusVia(this.focusedElementBeforeOpen.elementRef, this.focusOrigin); - this.focusedElementBeforeOpen = null; + // The popover can be opened from the dropdown item or programmatically, paths that never + // capture a trigger via `saveFocusedElement`. Fall back to the main button so focus returns + // to the filters control instead of being dropped on `` (WCAG 2.4.3). + const target = this.focusedElementBeforeOpen ?? this.mainButton(); + + if (target && !target.disabled) { + this.focusMonitor.focusVia(target.elementRef, this.focusOrigin); } + + this.focusedElementBeforeOpen = null; } preparePopover() { this.filterName = new FormControl(this.filter?.name || '', Validators.required); - this.filterName.valueChanges.pipe(distinctUntilChanged()).subscribe(() => (this.showFilterSavingError = false)); + this.filterName.valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => (this.showFilterSavingError = false)); this.popover().show(); @@ -370,11 +378,9 @@ export class KbqFilters implements OnInit { this.changeDetectorRef.markForCheck(); } - private getFilteredOptions(value): KbqFilter[] { - const searchFilter = value && value.new ? value.value : value; - - return searchFilter - ? this.filters().filter((filter) => filter.name.toLowerCase().includes(searchFilter.toLowerCase())) + private getFilteredOptions(value: string | null): KbqFilter[] { + return value + ? this.filters().filter((filter) => filter.name.toLowerCase().includes(value.toLowerCase())) : this.filters(); } } diff --git a/packages/components/filter-bar/pipe-add.spec.ts b/packages/components/filter-bar/pipe-add.spec.ts index dc389b5e02..0af5c12dcf 100644 --- a/packages/components/filter-bar/pipe-add.spec.ts +++ b/packages/components/filter-bar/pipe-add.spec.ts @@ -85,7 +85,15 @@ describe('KbqPipeAdd', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipe-add.ts b/packages/components/filter-bar/pipe-add.ts index 72b438291a..16fca90cf3 100644 --- a/packages/components/filter-bar/pipe-add.ts +++ b/packages/components/filter-bar/pipe-add.ts @@ -26,11 +26,12 @@ import { getId } from './pipes/base-pipe'; kbqTooltip="{{ filterBar.configuration.add.tooltip }}" kbq-button kbq-select-matcher + [attr.aria-label]="filterBar.configuration.add.tooltip" [color]="'contrast-fade'" [kbqStyle]="'outline'" [class]="{ 'kbq-active': select.panelOpen }" > - + @for (template of filterBar.pipeTemplates; track template) { @@ -97,9 +98,11 @@ export class KbqPipeAdd { } this.filterBar.filter.changed = true; - this.filterBar.filter.pipes.push( + // New array (not in-place push) to keep `pipes` on a new-reference-on-change model. + this.filterBar.filter.pipes = [ + ...this.filterBar.filter.pipes, Object.assign({}, option.value, { values: undefined, valueTemplate: undefined, openOnAdd: true }) - ); + ]; this.onAddPipe.emit(option.value); this.filterBar.filterChange.emit(this.filterBar.filter); diff --git a/packages/components/filter-bar/pipes/base-pipe.scss b/packages/components/filter-bar/pipes/base-pipe.scss index bf1dc81feb..7bc3aad3bb 100644 --- a/packages/components/filter-bar/pipes/base-pipe.scss +++ b/packages/components/filter-bar/pipes/base-pipe.scss @@ -12,7 +12,7 @@ } .kbq-button-wrapper { - max-width: 320px; + max-width: var(--kbq-filter-bar-pipe-max-width, 320px); } .kbq-pipe__name { @@ -29,15 +29,15 @@ flex: 1 1 auto; &:not(.kbq-pipe__value_empty) { - margin-left: 4px; + margin-inline-start: 4px; } } .kbq-pipe__separator { z-index: 1; - margin-left: -1px; - margin-right: 0; + margin-inline-start: -1px; + margin-inline-end: 0; height: var(--kbq-size-l); diff --git a/packages/components/filter-bar/pipes/base-pipe.ts b/packages/components/filter-bar/pipes/base-pipe.ts index 8719cc8618..12843d564f 100644 --- a/packages/components/filter-bar/pipes/base-pipe.ts +++ b/packages/components/filter-bar/pipes/base-pipe.ts @@ -16,7 +16,13 @@ import { isMac } from '@koobiq/components/core'; import { Subject } from 'rxjs'; import { delay, filter } from 'rxjs/operators'; import { KbqFilterBar } from '../filter-bar'; -import { KbqPipeData, KbqPipeTemplate, KbqPipeType } from '../filter-bar.types'; +import { + KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + KbqFilterBarConfiguration, + KbqPipeData, + KbqPipeTemplate, + KbqPipeType +} from '../filter-bar.types'; /** Injection Token for providing configuration of filter-bar */ export const KBQ_PIPE_DATA = new InjectionToken('KBQ_PIPE_DATA'); @@ -59,6 +65,13 @@ export abstract class KbqBasePipe implements AfterViewInit { /** Last known focus origin within the pipe. Used to preserve the keyboard focus ring on restore. */ private focusOrigin: FocusOrigin = null; + /** + * Whether this pipe has been destroyed. Guards deferred (`setTimeout`) work that could otherwise + * read view queries or mutate `data` after the pipe was removed or the filter switched. + * @docs-private + */ + protected destroyed = false; + /** values to select from the pipe template */ protected values; /** TemplateRef for selecting an option */ @@ -87,14 +100,14 @@ export abstract class KbqBasePipe implements AfterViewInit { /** localized data * @docs-private */ - get localeData() { - return this.filterBar?.configuration; + get localeData(): KbqFilterBarConfiguration { + return this.filterBar?.configuration ?? KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; } constructor() { this.$implicit = this; - this.stateChanges.subscribe(() => { + this.stateChanges.pipe(takeUntilDestroyed()).subscribe(() => { this.changeDetectorRef.markForCheck(); }); @@ -110,7 +123,10 @@ export abstract class KbqBasePipe implements AfterViewInit { ) .subscribe((origin) => (this.focusOrigin = origin)); - this.destroyRef.onDestroy(() => this.focusMonitor.stopMonitoring(this.elementRef)); + this.destroyRef.onDestroy(() => { + this.destroyed = true; + this.focusMonitor.stopMonitoring(this.elementRef); + }); afterNextRender(() => { this.isMac = isMac(); @@ -166,7 +182,7 @@ export abstract class KbqBasePipe implements AfterViewInit { this.stateChanges.next(); this.filterBar?.onClearPipe.emit(this.data); - this.filterBar?.onChangePipe.next(this.data); + this.filterBar?.onChangePipe.emit(this.data); } /** @@ -234,7 +250,7 @@ export class KbqPipeMinWidth { } constructor() { - this.filterBar?.changes.pipe(delay(0)).subscribe(this.update); + this.filterBar?.changes.pipe(delay(0), takeUntilDestroyed()).subscribe(this.update); afterNextRender({ read: this.update }); } diff --git a/packages/components/filter-bar/pipes/pipe-button.ts b/packages/components/filter-bar/pipes/pipe-button.ts index bedc2e31f7..fda87547cc 100644 --- a/packages/components/filter-bar/pipes/pipe-button.ts +++ b/packages/components/filter-bar/pipes/pipe-button.ts @@ -29,12 +29,13 @@ import { KbqPipeState } from './pipe-state'; kbqTooltip="{{ pipe.data.cleanable ? localeData.clearButtonTooltip : localeData.removeButtonTooltip }}" class="kbq-pipe__remove-button" kbq-button + [attr.aria-label]="pipe.data.cleanable ? localeData.clearButtonTooltip : localeData.removeButtonTooltip" [disabled]="pipe.data.disabled" [kbqPipeState]="pipe.data" [kbqTooltipDisabled]="pipe.data.disabled" (click)="pipe.data.cleanable ? pipe.onClear() : pipe.onRemove()" > - + `, styleUrl: 'pipe-button.scss', diff --git a/packages/components/filter-bar/pipes/pipe-date-base.ts b/packages/components/filter-bar/pipes/pipe-date-base.ts new file mode 100644 index 0000000000..36be0ff78a --- /dev/null +++ b/packages/components/filter-bar/pipes/pipe-date-base.ts @@ -0,0 +1,226 @@ +import { AfterViewInit, Directive, inject, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup } from '@angular/forms'; +import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; +import { DateAdapter, DateFormatter, ENTER, KbqComponentColors, PopUpPlacements } from '@koobiq/components/core'; +import { KbqListSelection } from '@koobiq/components/list'; +import { KbqPopoverTrigger } from '@koobiq/components/popover'; +import { KbqDateTimeValue } from '../filter-bar.types'; +import { KbqBasePipe } from './base-pipe'; + +/** + * Shared implementation for the `date` and `datetime` pipes. The two pipes differ only in how a range + * is formatted and in their default start/end values, exposed here as the {@link formatRange}, + * {@link getDefaultStart} and {@link getDefaultEnd} hooks. Everything else — the period list / custom + * period flow, calendar handling and form group — is common. + */ +@Directive() +export abstract class KbqPipeDateBaseComponent extends KbqBasePipe implements AfterViewInit { + // The concrete date type `D` is unconstrained, so the adapter/formatter are used loosely here, + // matching the behaviour the `date`/`datetime` pipes had before this base class was extracted. + protected readonly adapter: DateAdapter = inject(DateAdapter); + protected readonly formatter: DateFormatter = inject(DateFormatter); + + /** @docs-private */ + protected readonly placements = PopUpPlacements; + /** @docs-private */ + protected readonly styles = KbqButtonStyles; + /** @docs-private */ + protected readonly colors = KbqComponentColors; + + /** Whether the current state is list of periods. When false will displayed control for set custom period */ + protected isListMode = true; + + /** @docs-private */ + protected formGroup: FormGroup; + + /** @docs-private */ + protected showStartCalendar: boolean = false; + /** @docs-private */ + protected showEndCalendar: boolean = false; + + /** @docs-private */ + readonly popover = viewChild.required('popover'); + /** @docs-private */ + // Optional: the list only exists while the popover is open in list mode, and it is read + // in deferred callbacks that may fire after the popover has already closed. + readonly listSelection = viewChild('listSelection', { read: KbqListSelection }); + /** @docs-private */ + readonly returnButton = viewChild.required('returnButton', { read: KbqButton }); + + /** formatted value for period */ + get formattedValue(): string { + if (this.start && this.end) { + return this.formatRange(this.start, this.end); + } + + return this.data.value?.name ?? ''; + } + + /** Whether the current pipe is disabled. */ + get disabled(): boolean { + return ( + !this.adapter.isDateInstance(this.formGroup.controls.start.value) || + !this.adapter.isDateInstance(this.formGroup.controls.end.value) || + this.formGroup.controls.start.invalid + ); + } + + /** parsed start */ + get start(): D { + return this.adapter.parse(this.data.value?.start, ''); + } + + /** default object for start */ + get defaultStart(): D { + if (this.data.value?.start) { + return this.adapter.today().plus(this.data.value?.start); + } + + return this.getDefaultStart(); + } + + /** parsed end */ + get end(): D { + return this.adapter.parse(this.data.value?.end, ''); + } + + /** default object for end */ + get defaultEnd(): D { + if (this.data.value?.start) { + return this.adapter.today(); + } + + return this.getDefaultEnd(); + } + + /** Whether the current pipe is empty. */ + override get isEmpty(): boolean { + if (this.data.value === null) return true; + + if (this.data.value?.name) return false; + + return !this.adapter.isDateInstance(this.start) || !this.adapter.isDateInstance(this.end); + } + + override ngAfterViewInit() { + super.ngAfterViewInit(); + + this.popover() + .visibleChange.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((visible) => { + if (visible) { + // Move keyboard focus onto the period list so Enter selects a preset + // instead of the focus trap landing on the "custom period" row. + if (this.isListMode) { + setTimeout(() => this.listSelection()?.focus()); + } + } else { + this.filterBar?.onClosePipe.emit(this.data); + } + }); + } + + /** keydown handler + * @docs-private */ + onKeydown($event: KeyboardEvent) { + if (($event.ctrlKey || $event.metaKey) && $event.keyCode === ENTER) { + this.onApplyPeriod(); + } + + if ($event.keyCode === ENTER) { + $event.preventDefault(); + } + } + + onApplyPeriod() { + this.data.value = { + start: this.formGroup.controls.start.value.toISO(), + end: this.formGroup.controls.end.value.toISO() + }; + this.stateChanges.next(); + + this.filterBar?.onChangePipe.emit(this.data); + + this.popover().hide(); + + setTimeout(() => this.restoreTriggerFocus()); + } + + onSelect(item: KbqDateTimeValue) { + this.data.value = item; + this.stateChanges.next(); + + this.filterBar?.onChangePipe.emit(this.data); + + this.popover().hide(); + + setTimeout(() => this.restoreTriggerFocus()); + } + + showPeriod() { + this.isListMode = false; + this.showStartCalendar = false; + this.showEndCalendar = false; + + this.initFormGroup(); + + setTimeout(() => { + this.popover().updatePosition(true); + this.returnButton().focus(); + }); + } + + showList() { + this.isListMode = true; + + setTimeout(() => this.listSelection()?.focus()); + this.popover().updatePosition(true); + } + + /** opens popover */ + override open() { + this.popover().show(); + } + + onSelectStartDate(value: D) { + this.formGroup.controls.start.setValue(value); + } + + onSelectEndDate(value: D) { + this.formGroup.controls.end.setValue(value); + } + + onFocusStartInput() { + this.showStartCalendar = true; + this.showEndCalendar = false; + + this.popover().updatePosition(true); + } + + onFocusEndInput() { + this.showEndCalendar = true; + this.showStartCalendar = false; + } + + hideCalendars() { + this.showStartCalendar = false; + this.showEndCalendar = false; + } + + /** Formats the selected range for display. */ + protected abstract formatRange(start: D, end: D): string; + + /** Default start value used when the pipe has no stored `start`. */ + protected abstract getDefaultStart(): D; + + /** Default end value used when the pipe has no stored `start`. */ + protected abstract getDefaultEnd(): D; + + private initFormGroup() { + this.formGroup = new FormGroup({ + start: new FormControl(this.start || this.defaultStart), + end: new FormControl(this.end || this.defaultEnd) + }); + } +} diff --git a/packages/components/filter-bar/pipes/pipe-date.spec.ts b/packages/components/filter-bar/pipes/pipe-date.spec.ts index e1a11d9a06..9bc83acb35 100644 --- a/packages/components/filter-bar/pipes/pipe-date.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-date.spec.ts @@ -82,7 +82,15 @@ describe('KbqPipeDateComponent', () => { let filterBarDebugElement: DebugElement; let adapter: DateAdapter; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipes/pipe-date.ts b/packages/components/filter-bar/pipes/pipe-date.ts index dce50cc9e4..62224d70e7 100644 --- a/packages/components/filter-bar/pipes/pipe-date.ts +++ b/packages/components/filter-bar/pipes/pipe-date.ts @@ -1,25 +1,17 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, inject, viewChild, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { KbqButton, KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; -import { - DateAdapter, - DateFormatter, - ENTER, - KbqComponentColors, - KbqFormattersModule, - PopUpPlacements -} from '@koobiq/components/core'; +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqFormattersModule } from '@koobiq/components/core'; import { KbqDatepickerModule } from '@koobiq/components/datepicker'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; -import { KbqListModule, KbqListSelection } from '@koobiq/components/list'; -import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover'; +import { KbqListModule } from '@koobiq/components/list'; +import { KbqPopoverModule } from '@koobiq/components/popover'; import { KbqTimepickerModule } from '@koobiq/components/timepicker'; import { KbqTitleModule } from '@koobiq/components/title'; -import { KbqDateTimeValue } from '../filter-bar.types'; import { KbqBasePipe } from './base-pipe'; +import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeButton } from './pipe-button'; import { KbqPipeState } from './pipe-state'; @@ -52,201 +44,16 @@ import { KbqPipeState } from './pipe-state'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class KbqPipeDateComponent extends KbqBasePipe implements AfterViewInit { - private readonly adapter = inject(DateAdapter); - private readonly formatter = inject(DateFormatter); - - /** @docs-private */ - protected readonly placements = PopUpPlacements; - /** @docs-private */ - protected readonly styles = KbqButtonStyles; - /** @docs-private */ - protected readonly colors = KbqComponentColors; - - /** Whether the current state is list of periods. When false will displayed control for set custom period */ - protected isListMode = true; - - /** @docs-private */ - protected formGroup: FormGroup; - - /** @docs-private */ - protected showStartCalendar: boolean = false; - /** @docs-private */ - protected showEndCalendar: boolean = false; - - /** formatted value for period */ - get formattedValue(): string { - if (this.start && this.end) { - return this.formatter.rangeShortDate(this.start, this.end); - } - - return this.data.value?.name ?? ''; - } - - /** Whether the current pipe is disabled. */ - get disabled(): boolean { - return ( - !this.adapter.isDateInstance(this.formGroup.controls.start.value) || - !this.adapter.isDateInstance(this.formGroup.controls.end.value) || - this.formGroup.controls.start.invalid - ); - } - - /** parsed start */ - get start(): D { - return this.adapter.parse(this.data.value?.start, ''); +export class KbqPipeDateComponent extends KbqPipeDateBaseComponent { + protected formatRange(start: D, end: D): string { + return this.formatter.rangeShortDate(start, end); } - /** default object for start */ - get defaultStart(): D { - if (this.data.value?.start) { - return this.adapter.today().plus(this.data.value?.start); - } - + protected getDefaultStart(): D { return this.adapter.today(); } - /** parsed end */ - get end(): D { - return this.adapter.parse(this.data.value?.end, ''); - } - - /** default object for end */ - get defaultEnd(): D { - if (this.data.value?.start) { - return this.adapter.today(); - } - + protected getDefaultEnd(): D { return this.adapter.today().plus({ days: 1 }); } - - /** Whether the current pipe is empty. */ - override get isEmpty(): boolean { - if (this.data.value === null) return true; - - if (this.data.value?.name) return false; - - return !this.adapter.isDateInstance(this.start) || !this.adapter.isDateInstance(this.end); - } - - /** @docs-private */ - readonly popover = viewChild.required('popover'); - /** @docs-private */ - // Optional: the list only exists while the popover is open in list mode, and it is read - // in deferred callbacks that may fire after the popover has already closed. - listSelection = viewChild('listSelection', { read: KbqListSelection }); - /** @docs-private */ - returnButton = viewChild.required('returnButton', { read: KbqButton }); - - override ngAfterViewInit() { - super.ngAfterViewInit(); - - this.popover() - .visibleChange.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((visible) => { - if (visible) { - // Move keyboard focus onto the period list so Enter selects a preset - // instead of the focus trap landing on the "custom period" row. - if (this.isListMode) { - setTimeout(() => this.listSelection()?.focus()); - } - } else { - this.filterBar?.onClosePipe.emit(this.data); - } - }); - } - - /** keydown handler - * @docs-private */ - onKeydown($event: KeyboardEvent) { - if (($event.ctrlKey || $event.metaKey) && $event.keyCode === ENTER) { - this.onApplyPeriod(); - } - - if ($event.keyCode === ENTER) { - $event.preventDefault(); - } - } - - onApplyPeriod() { - this.data.value = { - start: this.formGroup.controls.start.value.toISO(), - end: this.formGroup.controls.end.value.toISO() - }; - this.stateChanges.next(); - - this.filterBar?.onChangePipe.next(this.data); - - this.popover().hide(); - - setTimeout(() => this.restoreTriggerFocus()); - } - - onSelect(item: KbqDateTimeValue) { - this.data.value = item; - this.stateChanges.next(); - - this.filterBar?.onChangePipe.next(this.data); - - this.popover().hide(); - - setTimeout(() => this.restoreTriggerFocus()); - } - - showPeriod() { - this.isListMode = false; - this.showStartCalendar = false; - this.showEndCalendar = false; - - this.initFormGroup(); - - setTimeout(() => { - this.popover().updatePosition(true); - this.returnButton().focus(); - }); - } - - showList() { - this.isListMode = true; - - setTimeout(() => this.listSelection()?.focus()); - this.popover().updatePosition(true); - } - - /** opens popover */ - override open() { - this.popover().show(); - } - - onSelectStartDate(value: D) { - this.formGroup.controls.start.setValue(value); - } - - onSelectEndDate(value: D) { - this.formGroup.controls.end.setValue(value); - } - - onFocusStartInput() { - this.showStartCalendar = true; - this.showEndCalendar = false; - - this.popover().updatePosition(true); - } - - onFocusEndInput() { - this.showEndCalendar = true; - this.showStartCalendar = false; - } - - hideCalendars() { - this.showStartCalendar = false; - this.showEndCalendar = false; - } - - private initFormGroup() { - this.formGroup = new FormGroup({ - start: new FormControl(this.start || this.defaultStart), - end: new FormControl(this.end || this.defaultEnd) - }); - } } diff --git a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts index 0288da4510..e4ad2e834b 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts @@ -82,7 +82,15 @@ describe('KbqPipeDatetimeComponent', () => { let filterBarDebugElement: DebugElement; let adapter: DateAdapter; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipes/pipe-datetime.ts b/packages/components/filter-bar/pipes/pipe-datetime.ts index f44caa47f0..1bb578bb9c 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.ts @@ -1,25 +1,17 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, inject, viewChild, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { KbqButton, KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; -import { - DateAdapter, - DateFormatter, - ENTER, - KbqComponentColors, - KbqFormattersModule, - PopUpPlacements -} from '@koobiq/components/core'; +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqFormattersModule } from '@koobiq/components/core'; import { KbqDatepickerModule } from '@koobiq/components/datepicker'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; -import { KbqListModule, KbqListSelection } from '@koobiq/components/list'; -import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover'; +import { KbqListModule } from '@koobiq/components/list'; +import { KbqPopoverModule } from '@koobiq/components/popover'; import { KbqTimepickerModule } from '@koobiq/components/timepicker'; import { KbqTitleModule } from '@koobiq/components/title'; -import { KbqDateTimeValue } from '../filter-bar.types'; import { KbqBasePipe } from './base-pipe'; +import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeButton } from './pipe-button'; import { KbqPipeState } from './pipe-state'; @@ -52,202 +44,16 @@ import { KbqPipeState } from './pipe-state'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class KbqPipeDatetimeComponent extends KbqBasePipe implements AfterViewInit { - private readonly adapter = inject(DateAdapter); - private readonly formatter = inject(DateFormatter); - - /** @docs-private */ - protected readonly placements = PopUpPlacements; - /** @docs-private */ - protected readonly styles = KbqButtonStyles; - /** @docs-private */ - protected readonly colors = KbqComponentColors; - - /** Whether the current state is list of periods. When false will displayed control for set custom period */ - protected isListMode = true; - - /** @docs-private */ - protected formGroup: FormGroup; - - /** @docs-private */ - protected showStartCalendar: boolean = false; - /** @docs-private */ - protected showEndCalendar: boolean = false; - - /** formatted value for period */ - get formattedValue(): string { - if (this.start && this.end) { - return this.formatter.rangeShortDateTime(this.start, this.end); - } - - return this.data.value?.name ?? ''; - } - - /** Whether the current pipe is disabled. */ - get disabled(): boolean { - return ( - !this.adapter.isDateInstance(this.formGroup.controls.start.value) || - !this.adapter.isDateInstance(this.formGroup.controls.end.value) || - this.formGroup.controls.start.invalid - ); - } - - /** parsed start */ - get start(): D { - return this.adapter.parse(this.data.value?.start, ''); +export class KbqPipeDatetimeComponent extends KbqPipeDateBaseComponent { + protected formatRange(start: D, end: D): string { + return this.formatter.rangeShortDateTime(start, end); } - /** default object for start */ - get defaultStart(): D { - if (this.data.value?.start) { - return this.adapter.today().plus(this.data.value?.start); - } - + protected getDefaultStart(): D { return this.adapter.today().startOf('day'); } - /** parsed end */ - get end(): D { - return this.adapter.parse(this.data.value?.end, ''); - } - - /** default object for end */ - get defaultEnd(): D { - if (this.data.value?.start) { - return this.adapter.today(); - } - + protected getDefaultEnd(): D { return this.adapter.today().endOf('day'); } - - /** Whether the current pipe is empty. */ - override get isEmpty(): boolean { - if (this.data.value === null) return true; - - if (this.data.value?.name) return false; - - return !this.adapter.isDateInstance(this.start) || !this.adapter.isDateInstance(this.end); - } - - /** @docs-private */ - readonly popover = viewChild.required('popover'); - /** @docs-private */ - // Optional: the list only exists while the popover is open in list mode, and it is read - // in deferred callbacks that may fire after the popover has already closed. - listSelection = viewChild('listSelection', { read: KbqListSelection }); - /** @docs-private */ - returnButton = viewChild.required('returnButton', { read: KbqButton }); - - override ngAfterViewInit() { - super.ngAfterViewInit(); - - this.popover() - .visibleChange.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((visible) => { - if (visible) { - // Move keyboard focus onto the period list so Enter selects a preset - // instead of the focus trap landing on the "custom period" row. - if (this.isListMode) { - setTimeout(() => this.listSelection()?.focus()); - } - } else { - this.filterBar?.onClosePipe.emit(this.data); - } - }); - } - - /** keydown handler - * @docs-private */ - onKeydown($event: KeyboardEvent) { - if (($event.ctrlKey || $event.metaKey) && $event.keyCode === ENTER) { - this.onApplyPeriod(); - } - - if ($event.keyCode === ENTER) { - $event.preventDefault(); - } - } - - /** @docs-private */ - onApplyPeriod() { - this.data.value = { - start: this.formGroup.controls.start.value.toISO(), - end: this.formGroup.controls.end.value.toISO() - }; - this.stateChanges.next(); - - this.filterBar?.onChangePipe.next(this.data); - - this.popover().hide(); - - setTimeout(() => this.restoreTriggerFocus()); - } - - onSelect(item: KbqDateTimeValue) { - this.data.value = item; - this.stateChanges.next(); - - this.filterBar?.onChangePipe.next(this.data); - - this.popover().hide(); - - setTimeout(() => this.restoreTriggerFocus()); - } - - showPeriod() { - this.isListMode = false; - this.showStartCalendar = false; - this.showEndCalendar = false; - - this.initFormGroup(); - - setTimeout(() => { - this.popover().updatePosition(true); - this.returnButton().focus(); - }); - } - - showList() { - this.isListMode = true; - - setTimeout(() => this.listSelection()?.focus()); - this.popover().updatePosition(true); - } - - /** opens popover */ - override open() { - this.popover().show(); - } - - onSelectStartDate(value: D) { - this.formGroup.controls.start.setValue(value); - } - - onSelectEndDate(value: D) { - this.formGroup.controls.end.setValue(value); - } - - onFocusStartInput() { - this.showStartCalendar = true; - this.showEndCalendar = false; - - this.popover().updatePosition(true); - } - - onFocusEndInput() { - this.showEndCalendar = true; - this.showStartCalendar = false; - } - - hideCalendars() { - this.showStartCalendar = false; - this.showEndCalendar = false; - } - - private initFormGroup() { - this.formGroup = new FormGroup({ - start: new FormControl(this.start || this.defaultStart), - end: new FormControl(this.end || this.defaultEnd) - }); - } } diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.html b/packages/components/filter-bar/pipes/pipe-multi-select.html index ae21488acc..7fef894da7 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.html +++ b/packages/components/filter-bar/pipes/pipe-multi-select.html @@ -41,6 +41,7 @@ autocomplete="off" kbqInput type="text" + [attr.aria-label]="localeData.search.placeholder" [formControl]="searchControl" (keydown.enter)="toggleSelectionAllByEnterKey()" /> diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts index ea28032c7b..2a2923257b 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts @@ -88,7 +88,15 @@ describe('KbqPipeMultiSelectComponent', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ @@ -683,14 +691,19 @@ describe('KbqPipeMultiSelectComponent', () => { fixture.detectChanges(); const component = getPipeComponent(); + let lastFiltered: any[] = []; + + // Subscribe before emitting: filteredOptions is a cold merge with no replay, so the + // subscription must be active when searchControl emits. + component.filteredOptions.subscribe((filtered) => { + lastFiltered = filtered; + }); component.searchControl.setValue('Option 1'); flush(); - component.filteredOptions.subscribe((filtered) => { - expect(filtered.length).toBe(1); - expect(filtered[0].name).toBe('Option 1'); - }); + expect(lastFiltered.length).toBe(1); + expect(lastFiltered[0].name).toBe('Option 1'); })); it('should return all options when search is empty', fakeAsync(() => { @@ -700,13 +713,18 @@ describe('KbqPipeMultiSelectComponent', () => { fixture.detectChanges(); const component = getPipeComponent(); + let lastFiltered: any[] = []; + + component.filteredOptions.subscribe((filtered) => { + lastFiltered = filtered; + }); + component.searchControl.setValue('Option 1'); + flush(); component.searchControl.setValue(''); flush(); - component.filteredOptions.subscribe((filtered) => { - expect(filtered.length).toBe(SELECT_VALUES.length); - }); + expect(lastFiltered.length).toBe(SELECT_VALUES.length); })); }); diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.ts b/packages/components/filter-bar/pipes/pipe-multi-select.ts index f1efadfb62..b555b6db0a 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-select.ts @@ -9,7 +9,7 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqOption, KbqPseudoCheckboxModule, KbqPseudoCheckboxState } from '@koobiq/components/core'; @@ -56,9 +56,9 @@ import { KbqPipeState } from './pipe-state'; }) export class KbqPipeMultiSelectComponent extends KbqBasePipe implements AfterViewInit, OnInit { /** control for search options */ - searchControl: UntypedFormControl = new UntypedFormControl(); + readonly searchControl = new FormControl(null); /** filtered by search options */ - filteredOptions: Observable; + filteredOptions: Observable; /** @docs-private */ readonly select = viewChild.required(KbqSelect); @@ -203,7 +203,8 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i } /** Comparator of selected options */ - compareByValue = (o1: any, o2: any): boolean => o1?.id === o2?.id; + compareByValue = (o1: Pick | null, o2: Pick | null): boolean => + o1?.id === o2?.id; /** handler for select all options in select */ selectAllHandler = (event: KeyboardEvent) => { @@ -241,8 +242,10 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i } private getFilteredOptions = (): KbqSelectValue[] => { - return this.searchControl.value - ? this.values.filter((item) => item.name.toLowerCase().includes(this.searchControl.value.toLowerCase())) + const search = this.searchControl.value; + + return search + ? this.values.filter((item: KbqSelectValue) => item.name.toLowerCase().includes(search.toLowerCase())) : this.values; }; } diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.html b/packages/components/filter-bar/pipes/pipe-multi-tree-select.html index 0d0ad771bc..1daf1f6100 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.html +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.html @@ -13,19 +13,21 @@ (opened)="onOpen()" (selectionChange)="onSelect($event)" > - - - - - - + @if (data.search) { + + + + + + }
{{ localeData.pipe.emptySearchResult }}
diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss b/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss index 465928b8a2..9c4bcf5748 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss @@ -1,12 +1,12 @@ .kbq-pipe__multi-tree-select { .kbq-pipe__value { - margin-left: var(--kbq-size-xxs); + margin-inline-start: var(--kbq-size-xxs); } .kbq-badge { align-self: center; - margin-left: var(--kbq-size-xs); + margin-inline-start: var(--kbq-size-xs); } & .kbq-tree-select .kbq-select__trigger { diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts index f100545042..6fd496cdaa 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts @@ -115,7 +115,15 @@ describe('KbqPipeMultiTreeSelectComponent', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts index 0e4869633a..8456d2b119 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts @@ -1,7 +1,6 @@ -import { NgIf } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqHighlightModule, KbqPseudoCheckboxModule, KbqPseudoCheckboxState } from '@koobiq/components/core'; @@ -18,7 +17,6 @@ import { KbqTreeSelection } from '@koobiq/components/tree'; import { KbqTreeSelect, KbqTreeSelectModule } from '@koobiq/components/tree-select'; -import { Observable } from 'rxjs'; import { KbqPipeTemplate, KbqSelectValue, KbqTreeSelectFlatNode, KbqTreeSelectNode } from '../filter-bar.types'; import { getId, KbqBasePipe, KbqPipeMinWidth } from './base-pipe'; import { KbqPipeButton } from './pipe-button'; @@ -39,7 +37,6 @@ import { KbqPipeState } from './pipe-state'; KbqHighlightModule, KbqTreeModule, KbqTreeSelectModule, - NgIf, FormsModule, KbqBadgeModule, KbqPseudoCheckboxModule @@ -57,17 +54,13 @@ import { KbqPipeState } from './pipe-state'; }) export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe implements OnInit, AfterViewInit { /** control for search options */ - searchControl: UntypedFormControl = new UntypedFormControl(); - /** filtered by search options */ - filteredOptions: Observable; + readonly searchControl = new FormControl(null); treeControl: FlatTreeControl; treeFlattener: KbqTreeFlattener; dataSource: KbqTreeFlatDataSource; - template: any; - /** @docs-private */ readonly select = viewChild.required(KbqTreeSelect); @@ -151,7 +144,9 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe this.treeControl.filterNodes(value)); + this.searchControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.treeControl.filterNodes(value)); } override ngAfterViewInit() { @@ -162,11 +157,11 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe this.filterBar?.onClosePipe.emit(this.data)); } - isNodeHasChild(_: number, nodeData) { + isNodeHasChild(_: number, nodeData: KbqTreeSelectFlatNode) { return nodeData.expandable; } - isNodeSelectAll(_: number, nodeData) { + isNodeSelectAll(_: number, nodeData: KbqTreeSelectFlatNode) { return nodeData.value === kbqTreeSelectAllValue; } @@ -182,6 +177,8 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe { + if (this.destroyed) return; + if (this.selectedAllEqualsSelectedNothing && this.allOptionsSelected) { this.data.value = []; } else { @@ -221,6 +218,8 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe { + if (this.destroyed) return; + if (this.selectedAllEqualsSelectedNothing && this.allOptionsSelected) { this.data.value = []; } else { @@ -235,14 +234,15 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe { - const template = templates?.find((template) => getId(template) === getId(this.data)); + const template = templates?.find((item) => getId(item) === getId(this.data)); if (template?.values) { - this.values = template.values; - this.valueTemplate = template.valueTemplate; - const values = [...(template.values as KbqTreeSelectNode[])]; if (this.data.selectAll) { @@ -303,7 +303,7 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe this.select().selectionModel.selected.includes(d))) { + if ( + !isParentSelected && + descendants.every((d: KbqTreeSelectFlatNode) => this.select().selectionModel.selected.includes(d)) + ) { this.select().selectionModel.select(parent); this.toggleParents(parent.parent); } else if (isParentSelected) { @@ -320,12 +323,12 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe { + private transformer = (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => { const flatNode = new KbqTreeSelectFlatNode(); flatNode.name = node.name; flatNode.value = node.value; - flatNode.parent = parent; + flatNode.parent = parent ?? undefined; flatNode.level = level; flatNode.expandable = !!node.children; diff --git a/packages/components/filter-bar/pipes/pipe-multiselect.scss b/packages/components/filter-bar/pipes/pipe-multiselect.scss index bbd3b595f0..8f3758c930 100644 --- a/packages/components/filter-bar/pipes/pipe-multiselect.scss +++ b/packages/components/filter-bar/pipes/pipe-multiselect.scss @@ -1,12 +1,12 @@ .kbq-pipe__multiselect { .kbq-pipe__value { - margin-left: var(--kbq-size-xxs); + margin-inline-start: var(--kbq-size-xxs); } .kbq-badge { align-self: center; - margin-left: var(--kbq-size-xs); + margin-inline-start: var(--kbq-size-xs); } } diff --git a/packages/components/filter-bar/pipes/pipe-select.html b/packages/components/filter-bar/pipes/pipe-select.html index 5cdc45de36..a39c4d87fe 100644 --- a/packages/components/filter-bar/pipes/pipe-select.html +++ b/packages/components/filter-bar/pipes/pipe-select.html @@ -34,6 +34,7 @@ placeholder="{{ localeData.search.placeholder }}" kbqInput type="text" + [attr.aria-label]="localeData.search.placeholder" [formControl]="searchControl" /> diff --git a/packages/components/filter-bar/pipes/pipe-select.spec.ts b/packages/components/filter-bar/pipes/pipe-select.spec.ts index e7b1669c94..731f4fd7f0 100644 --- a/packages/components/filter-bar/pipes/pipe-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-select.spec.ts @@ -80,7 +80,15 @@ describe('KbqPipeSelectComponent', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipes/pipe-select.ts b/packages/components/filter-bar/pipes/pipe-select.ts index 094417dcb9..424a02b6a7 100644 --- a/packages/components/filter-bar/pipes/pipe-select.ts +++ b/packages/components/filter-bar/pipes/pipe-select.ts @@ -1,7 +1,7 @@ import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIcon } from '@koobiq/components/icon'; @@ -44,9 +44,9 @@ import { KbqPipeState } from './pipe-state'; }) export class KbqPipeSelectComponent extends KbqBasePipe implements AfterViewInit, OnInit { /** control for search options */ - searchControl: UntypedFormControl = new UntypedFormControl(); + readonly searchControl = new FormControl(null); /** filtered by search options */ - filteredOptions: Observable; + filteredOptions: Observable; /** @docs-private */ readonly select = viewChild.required(KbqSelect); @@ -86,16 +86,17 @@ export class KbqPipeSelectComponent extends KbqBasePipe implemen } /** Comparator of selected options */ - compareByValue = (o1: any, o2: any): boolean => o1 && o2 && o1.id === o2.id; + compareByValue = (o1: Pick | null, o2: Pick | null): boolean => + !!o1 && !!o2 && o1.id === o2.id; /** opens select */ override open() { this.select().open(); } - private getFilteredOptions(value: string): KbqSelectValue[] { + private getFilteredOptions(value: string | null): KbqSelectValue[] { return value - ? this.values.filter((item) => item.name.toLowerCase().includes(value.toLowerCase())) + ? this.values.filter((item: KbqSelectValue) => item.name.toLowerCase().includes(value.toLowerCase())) : this.values; } } diff --git a/packages/components/filter-bar/pipes/pipe-text.spec.ts b/packages/components/filter-bar/pipes/pipe-text.spec.ts index f5f8f01abc..568cc8c75e 100644 --- a/packages/components/filter-bar/pipes/pipe-text.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-text.spec.ts @@ -70,7 +70,15 @@ describe('KbqPipeTextComponent', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/components/filter-bar/pipes/pipe-text.ts b/packages/components/filter-bar/pipes/pipe-text.ts index 02fdc6f751..4c9c16bfba 100644 --- a/packages/components/filter-bar/pipes/pipe-text.ts +++ b/packages/components/filter-bar/pipes/pipe-text.ts @@ -77,7 +77,7 @@ export class KbqPipeTextComponent extends KbqBasePipe implements this.control.markAsPristine(); this.popover().hide(); - this.filterBar?.onChangePipe.next(this.data); + this.filterBar?.onChangePipe.emit(this.data); setTimeout(() => this.restoreTriggerFocus()); } diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.html b/packages/components/filter-bar/pipes/pipe-tree-select.html index 505fce2fad..72b1afee70 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.html +++ b/packages/components/filter-bar/pipes/pipe-tree-select.html @@ -10,18 +10,20 @@ (opened)="onOpen()" (selectionChange)="onSelect($event.value)" > - - - - - - + @if (data.search) { + + + + + + }
{{ localeData.pipe.emptySearchResult }}
diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts index 1f78e33080..25eaca1577 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts @@ -99,7 +99,15 @@ describe('KbqPipeTreeSelectComponent', () => { let fixture: ComponentFixture; let filterBarDebugElement: DebugElement; - window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + const originalStructuredClone = window.structuredClone; + + beforeAll(() => { + window.structuredClone = (value) => JSON.parse(JSON.stringify(value)); + }); + + afterAll(() => { + window.structuredClone = originalStructuredClone; + }); beforeEach(() => { TestBed.configureTestingModule({ @@ -434,13 +442,13 @@ describe('KbqPipeTreeSelectComponent', () => { it('should return true for expandable nodes', () => { const component = getPipeComponent(); - expect(component.hasChild(0, { expandable: true })).toBe(true); + expect(component.hasChild(0, { name: 'node', value: null, level: 0, expandable: true })).toBe(true); }); it('should return false for leaf nodes', () => { const component = getPipeComponent(); - expect(component.hasChild(0, { expandable: false })).toBe(false); + expect(component.hasChild(0, { name: 'node', value: null, level: 0, expandable: false })).toBe(false); }); }); diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.ts b/packages/components/filter-bar/pipes/pipe-tree-select.ts index c101159774..4ee20fc1e6 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.ts +++ b/packages/components/filter-bar/pipes/pipe-tree-select.ts @@ -1,7 +1,6 @@ -import { NgIf } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqHighlightModule } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -16,7 +15,6 @@ import { KbqTreeOption } from '@koobiq/components/tree'; import { KbqTreeSelect, KbqTreeSelectModule } from '@koobiq/components/tree-select'; -import { Observable } from 'rxjs'; import { KbqPipeTemplate, KbqSelectValue, KbqTreeSelectFlatNode, KbqTreeSelectNode } from '../filter-bar.types'; import { getId, KbqBasePipe, KbqPipeMinWidth } from './base-pipe'; import { KbqPipeButton } from './pipe-button'; @@ -37,7 +35,6 @@ import { KbqPipeState } from './pipe-state'; KbqHighlightModule, KbqTreeModule, KbqTreeSelectModule, - NgIf, FormsModule ], templateUrl: 'pipe-tree-select.html', @@ -53,17 +50,13 @@ import { KbqPipeState } from './pipe-state'; }) export class KbqPipeTreeSelectComponent extends KbqBasePipe implements OnInit, AfterViewInit { /** control for search options */ - searchControl: UntypedFormControl = new UntypedFormControl(); - /** filtered by search options */ - filteredOptions: Observable; + readonly searchControl = new FormControl(null); treeControl: FlatTreeControl; treeFlattener: KbqTreeFlattener; dataSource: KbqTreeFlatDataSource; - template: any; - /** @docs-private */ readonly select = viewChild.required(KbqTreeSelect); @@ -95,7 +88,9 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl } ngOnInit(): void { - this.searchControl.valueChanges.subscribe((value) => this.treeControl.filterNodes(value)); + this.searchControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.treeControl.filterNodes(value)); } override ngAfterViewInit() { @@ -106,7 +101,7 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl .subscribe(() => this.filterBar?.onClosePipe.emit(this.data)); } - hasChild(_: number, nodeData) { + hasChild(_: number, nodeData: KbqTreeSelectFlatNode) { return nodeData.expandable; } @@ -118,14 +113,15 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl this.stateChanges.next(); } - /** updates values for selection and value template */ + /** + * Populates the tree data source from the pipe template. The shared `values`/`valueTemplate` + * assignment is already performed by the base subscription, so this override only does the + * tree-specific work and avoids a redundant double assignment. + */ override updateTemplates = (templates: KbqPipeTemplate[] | null) => { - const template = templates?.find((template) => getId(template) === getId(this.data)); + const template = templates?.find((item) => getId(item) === getId(this.data)); if (template?.values) { - this.values = template.values; - this.valueTemplate = template.valueTemplate; - this.dataSource.data = template.values as KbqTreeSelectNode[]; } }; @@ -139,12 +135,12 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl this.treeControl.expandAll(); } - private transformer = (node: KbqTreeSelectNode, level: number, parent: any) => { + private transformer = (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => { const flatNode = new KbqTreeSelectFlatNode(); flatNode.name = node.name; flatNode.value = node.value; - flatNode.parent = parent; + flatNode.parent = parent ?? undefined; flatNode.level = level; flatNode.expandable = !!node.children; diff --git a/packages/components/filter-bar/public-api.ts b/packages/components/filter-bar/public-api.ts index f54c12738e..e93c6cc9a1 100644 --- a/packages/components/filter-bar/public-api.ts +++ b/packages/components/filter-bar/public-api.ts @@ -10,6 +10,7 @@ export * from './pipe.directive'; export * from './pipes/base-pipe'; export * from './pipes/pipe-button'; export * from './pipes/pipe-date'; +export * from './pipes/pipe-date-base'; export * from './pipes/pipe-datetime'; export * from './pipes/pipe-multi-select'; export * from './pipes/pipe-readonly'; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index e8018147e6..5128875dea 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -452,10 +452,15 @@ export const enUSLocaleData: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -690,10 +695,15 @@ export const esLALocaleData: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -1151,10 +1161,15 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -1364,10 +1379,15 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -1573,10 +1593,15 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -1788,10 +1813,15 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -1999,10 +2029,15 @@ export function KBQ_DEFAULT_LOCALE_DATA_FACTORY(): { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -3924,10 +3959,15 @@ export const ptBRLocaleData: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -4349,10 +4389,15 @@ export const ruRULocaleData: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -4695,10 +4740,15 @@ export const tkTMLocaleData: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 198589b11e..9c7f153bf1 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -8,9 +8,10 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { ChangeDetectorRef } from '@angular/core'; +import { DateAdapter } from '@koobiq/components/core'; +import { DateFormatter } from '@koobiq/components/core'; import { DestroyRef } from '@angular/core'; import { ElementRef } from '@angular/core'; -import { EventEmitter } from '@angular/core'; import { FlatTreeControl } from '@koobiq/components/tree'; import { FocusMonitor } from '@angular/cdk/a11y'; import { FocusOrigin } from '@angular/cdk/a11y'; @@ -35,20 +36,66 @@ import * as _koobiq_components_core from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnInit } from '@angular/core'; import { PopUpPlacements } from '@koobiq/components/core'; -import { PopUpSizes } from '@koobiq/components/core'; import { Provider } from '@angular/core'; import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; -import { UntypedFormControl } from '@angular/forms'; +import { Type } from '@angular/core'; // @public -export const defaultFilterBarPipes: [string, unknown][]; +export const defaultFilterBarPipes: [KbqPipeType, Type>][]; // @public export function getId(item: KbqPipeTemplate): KbqPipeType | string | number; // @public -export const KBQ_FILTER_BAR_CONFIGURATION: InjectionToken; +export const KBQ_FILTER_BAR_CONFIGURATION: InjectionToken<{ + reset: { + buttonName: string; + }; + search: { + tooltip: string; + placeholder: string; + }; + filters: { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + add: { + tooltip: string; + }; + refresher: { + refresh: string; + settings: string; + }; + pipe: { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; + datePipe: { + customPeriod: string; + customPeriodFrom: string; + customPeriodTo: string; + customPeriodErrorHint: string; + backToPeriodSelection: string; + }; +}>; // @public export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: { @@ -75,10 +122,15 @@ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: { errorHint: string; saveButton: string; cancelButton: string; + actionsTooltip: string; }; add: { tooltip: string; }; + refresher: { + refresh: string; + settings: string; + }; pipe: { clearButtonTooltip: string; removeButtonTooltip: string; @@ -96,7 +148,7 @@ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: { }; // @public -export const KBQ_FILTER_BAR_PIPES: InjectionToken; +export const KBQ_FILTER_BAR_PIPES: InjectionToken>>>; // @public export const KBQ_PIPE_DATA: InjectionToken; @@ -107,6 +159,7 @@ export abstract class KbqBasePipe implements AfterViewInit { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; readonly data: KbqPipeData; + protected destroyed: boolean; protected readonly destroyRef: DestroyRef; protected readonly elementRef: ElementRef; protected readonly filterBar: KbqFilterBar | null; @@ -114,7 +167,7 @@ export abstract class KbqBasePipe implements AfterViewInit { get isEmpty(): boolean; isMac: boolean; isTemplateRef(value: unknown): boolean; - get localeData(): any; + get localeData(): KbqFilterBarConfiguration; // (undocumented) ngAfterViewInit(): void; onClear(): void; @@ -133,7 +186,7 @@ export abstract class KbqBasePipe implements AfterViewInit { } // @public -export function kbqBuildTree(value: any, level: number): T[]; +export function kbqBuildTree(value: Record, level: number): T[]; // @public (undocumented) export interface KbqDateTimeValue { @@ -166,10 +219,56 @@ export class KbqFilterBar { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; readonly changes: BehaviorSubject; - // (undocumented) - configuration: any; - // (undocumented) - readonly externalConfiguration: unknown; + configuration: KbqFilterBarConfiguration; + // (undocumented) + readonly externalConfiguration: { + reset: { + buttonName: string; + }; + search: { + tooltip: string; + placeholder: string; + }; + filters: { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + add: { + tooltip: string; + }; + refresher: { + refresh: string; + settings: string; + }; + pipe: { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; + datePipe: { + customPeriod: string; + customPeriodFrom: string; + customPeriodTo: string; + customPeriodErrorHint: string; + backToPeriodSelection: string; + }; + } | null; get filter(): KbqFilter | null; set filter(value: KbqFilter | null); readonly filterChange: i0.OutputEmitterRef; @@ -183,7 +282,7 @@ export class KbqFilterBar { get isSaved(): boolean; get isSavedAndChanged(): boolean; protected readonly localeService: _koobiq_components_core.KbqLocaleService | null; - readonly onChangePipe: EventEmitter; + readonly onChangePipe: i0.OutputEmitterRef; readonly onClearPipe: i0.OutputEmitterRef; readonly onClosePipe: i0.OutputEmitterRef; readonly onRemovePipe: i0.OutputEmitterRef; @@ -213,6 +312,9 @@ export class KbqFilterBarButton { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; + // @public (undocumented) export class KbqFilterBarModule { // (undocumented) @@ -220,23 +322,71 @@ export class KbqFilterBarModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public export const kbqFilterBarPipesProvider: () => Provider; // @public (undocumented) -export class KbqFilterBarRefresher { +export class KbqFilterRefresher { + protected get localeData(): { + reset: { + buttonName: string; + }; + search: { + tooltip: string; + placeholder: string; + }; + filters: { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + add: { + tooltip: string; + }; + refresher: { + refresh: string; + settings: string; + }; + pipe: { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; + datePipe: { + customPeriod: string; + customPeriodFrom: string; + customPeriodTo: string; + customPeriodErrorHint: string; + backToPeriodSelection: string; + }; + }; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public (undocumented) export class KbqFilterReset { - get localeData(): any; + get localeData(): string; readonly onResetFilter: i0.OutputEmitterRef; // (undocumented) protected resetFilter(): void; @@ -274,7 +424,24 @@ export class KbqFilters implements OnInit { get isEmpty(): boolean; // (undocumented) isSaving: boolean; - get localeData(): any; + get localeData(): { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; protected readonly mainButton: i0.Signal; // (undocumented) ngOnInit(): void; @@ -292,8 +459,8 @@ export class KbqFilters implements OnInit { protected readonly placements: typeof PopUpPlacements; protected readonly popover: i0.Signal; get popoverHeader(): string; - popoverOffset: number; - popoverSize: PopUpSizes; + protected readonly popoverOffset: number; + protected readonly popoverSize = PopUpSizes.Medium; // (undocumented) preparePopover(): void; // (undocumented) @@ -309,7 +476,7 @@ export class KbqFilters implements OnInit { saveFocusedElement(button?: KbqButton): void; saveNewFilter: boolean; protected readonly saveNewFilterButton: i0.Signal; - searchControl: UntypedFormControl; + readonly searchControl: FormControl; searchKeydownHandler(event: KeyboardEvent): void; // (undocumented) selectFilter(filter: KbqFilter): void; @@ -320,7 +487,7 @@ export class KbqFilters implements OnInit { stopEventPropagation(event: Event): void; protected readonly styles: typeof KbqButtonStyles; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -376,7 +543,13 @@ export class KbqPipeButton { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; protected readonly filterBar: KbqFilterBar; - get localeData(): any; + get localeData(): { + clearButtonTooltip: string; + removeButtonTooltip: string; + applyButton: string; + emptySearchResult: string; + selectAll: string; + }; protected readonly pipe: KbqBasePipe; readonly readonly: i0.InputSignalWithTransform; // (undocumented) @@ -391,20 +564,27 @@ export interface KbqPipeData extends KbqPipe { value: V | null; } -// @public (undocumented) -export class KbqPipeDateComponent extends KbqBasePipe implements AfterViewInit { +// @public +export abstract class KbqPipeDateBaseComponent extends KbqBasePipe implements AfterViewInit { + // (undocumented) + protected readonly adapter: DateAdapter; protected readonly colors: typeof KbqComponentColors; get defaultEnd(): D; get defaultStart(): D; get disabled(): boolean; get end(): D; + protected abstract formatRange(start: D, end: D): string; get formattedValue(): string; + // (undocumented) + protected readonly formatter: DateFormatter; protected formGroup: FormGroup; + protected abstract getDefaultEnd(): D; + protected abstract getDefaultStart(): D; // (undocumented) hideCalendars(): void; get isEmpty(): boolean; protected isListMode: boolean; - listSelection: i0.Signal; + readonly listSelection: i0.Signal; // (undocumented) ngAfterViewInit(): void; // (undocumented) @@ -423,7 +603,7 @@ export class KbqPipeDateComponent extends KbqBasePipe imple open(): void; protected readonly placements: typeof PopUpPlacements; readonly popover: i0.Signal; - returnButton: i0.Signal; + readonly returnButton: i0.Signal; protected showEndCalendar: boolean; // (undocumented) showList(): void; @@ -433,51 +613,33 @@ export class KbqPipeDateComponent extends KbqBasePipe imple get start(): D; protected readonly styles: typeof KbqButtonStyles; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "kbq-pipe-date", never, {}, {}, never, never, true, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, never, never, {}, {}, never, never, true, never>; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration, never>; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; } // @public (undocumented) -export class KbqPipeDatetimeComponent extends KbqBasePipe implements AfterViewInit { - protected readonly colors: typeof KbqComponentColors; - get defaultEnd(): D; - get defaultStart(): D; - get disabled(): boolean; - get end(): D; - get formattedValue(): string; - protected formGroup: FormGroup; +export class KbqPipeDateComponent extends KbqPipeDateBaseComponent { // (undocumented) - hideCalendars(): void; - get isEmpty(): boolean; - protected isListMode: boolean; - listSelection: i0.Signal; + protected formatRange(start: D, end: D): string; // (undocumented) - ngAfterViewInit(): void; - onApplyPeriod(): void; + protected getDefaultEnd(): D; // (undocumented) - onFocusEndInput(): void; - // (undocumented) - onFocusStartInput(): void; - onKeydown($event: KeyboardEvent): void; + protected getDefaultStart(): D; // (undocumented) - onSelect(item: KbqDateTimeValue): void; + static ɵcmp: i0.ɵɵComponentDeclaration, "kbq-pipe-date", never, {}, {}, never, never, true, never>; // (undocumented) - onSelectEndDate(value: D): void; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + +// @public (undocumented) +export class KbqPipeDatetimeComponent extends KbqPipeDateBaseComponent { // (undocumented) - onSelectStartDate(value: D): void; - open(): void; - protected readonly placements: typeof PopUpPlacements; - readonly popover: i0.Signal; - returnButton: i0.Signal; - protected showEndCalendar: boolean; + protected formatRange(start: D, end: D): string; // (undocumented) - showList(): void; + protected getDefaultEnd(): D; // (undocumented) - showPeriod(): void; - protected showStartCalendar: boolean; - get start(): D; - protected readonly styles: typeof KbqButtonStyles; + protected getDefaultStart(): D; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration, "kbq-pipe-datetime", never, {}, {}, never, never, true, never>; // (undocumented) @@ -518,8 +680,8 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i get allOptionsSelected(): boolean; get allVisibleOptionsSelected(): boolean; get checkboxState(): KbqPseudoCheckboxState; - compareByValue: (o1: any, o2: any) => boolean; - filteredOptions: Observable; + compareByValue: (o1: Pick | null, o2: Pick | null) => boolean; + filteredOptions: Observable; get isEmpty(): boolean; // (undocumented) ngAfterViewInit(): void; @@ -529,7 +691,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i onSelect(item: KbqSelectValue[]): void; open(): void; readonly options: i0.Signal; - searchControl: UntypedFormControl; + readonly searchControl: FormControl; readonly select: i0.Signal; selectAllHandler: (event: KeyboardEvent) => void; get selected(): KbqSelectValue[] | null; @@ -555,8 +717,8 @@ export class KbqPipeReadonlyComponent extends KbqBasePipe { // @public (undocumented) export class KbqPipeSelectComponent extends KbqBasePipe implements AfterViewInit, OnInit { - compareByValue: (o1: any, o2: any) => boolean; - filteredOptions: Observable; + compareByValue: (o1: Pick | null, o2: Pick | null) => boolean; + filteredOptions: Observable; get isEmpty(): boolean; // (undocumented) ngAfterViewInit(): void; @@ -564,7 +726,7 @@ export class KbqPipeSelectComponent extends KbqBasePipe implemen // (undocumented) onSelect(item: KbqSelectValue): void; open(): void; - searchControl: UntypedFormControl; + readonly searchControl: FormControl; readonly select: i0.Signal; get selected(): KbqSelectValue | null; // (undocumented) @@ -620,9 +782,8 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl constructor(); // (undocumented) dataSource: KbqTreeFlatDataSource; - filteredOptions: Observable; // (undocumented) - hasChild(_: number, nodeData: any): any; + hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean; get isEmpty(): boolean; // (undocumented) ngAfterViewInit(): void; @@ -633,12 +794,10 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl // (undocumented) onSelect(item: KbqTreeOption): void; open(): void; - searchControl: UntypedFormControl; + readonly searchControl: FormControl; readonly select: i0.Signal; get selected(): KbqSelectValue | null; // (undocumented) - template: any; - // (undocumented) treeControl: FlatTreeControl; // (undocumented) treeFlattener: KbqTreeFlattener; @@ -650,7 +809,7 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl } // @public (undocumented) -export type KbqPipeType = `${KbqPipeTypes}` | string; +export type KbqPipeType = `${KbqPipeTypes}` | (string & {}); // @public export enum KbqPipeTypes { @@ -702,6 +861,7 @@ export enum KbqSaveFilterStatuses { // @public (undocumented) export interface KbqSelectValue { + id?: string | number; // (undocumented) name: string; // (undocumented) From 336804778a2195645688d44233036c9d9daa0cfb Mon Sep 17 00:00:00 2001 From: lskramarov Date: Tue, 7 Jul 2026 23:18:06 +0300 Subject: [PATCH 02/16] fix: phase B --- .../components/filter-bar/filter-bar.spec.ts | 91 ++++++++++++ .../components/filter-bar/filters.spec.ts | 137 ++++++++++++++++-- .../filter-bar/pipes/pipe-date.spec.ts | 88 ++--------- .../filter-bar/pipes/pipe-datetime.spec.ts | 88 ++--------- .../pipes/pipe-multi-select.spec.ts | 118 +++++---------- .../filter-bar/pipes/pipe-multi-select.ts | 4 +- .../pipes/pipe-multi-tree-select.spec.ts | 136 +++-------------- .../filter-bar/pipes/pipe-select.spec.ts | 102 +++---------- .../pipes/pipe-states.spec-helper.ts | 129 +++++++++++++++++ .../filter-bar/pipes/pipe-text.spec.ts | 93 ++---------- .../filter-bar/pipes/pipe-tree-select.spec.ts | 88 ++--------- 11 files changed, 475 insertions(+), 599 deletions(-) create mode 100644 packages/components/filter-bar/pipes/pipe-states.spec-helper.ts diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index ee33f71dd3..dd25989bb2 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -3,16 +3,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { KBQ_LOCALE_SERVICE } from '@koobiq/components/core'; import { + KBQ_FILTER_BAR_CONFIGURATION, KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KbqFilter, KbqFilterBar, + KbqFilterBarConfiguration, KbqFilterBarModule, KbqPipe, KbqPipeTemplate, KbqPipeTypes } from '@koobiq/components/filter-bar'; import { KbqSearchExpandable, KbqSearchExpandableModule } from '@koobiq/components/search-expandable'; +import { BehaviorSubject } from 'rxjs'; const PIPE_TEMPLATE_ID = 'TestText'; @@ -548,4 +552,91 @@ describe('KbqFilterBar', () => { expect(localFixture.debugElement.query(By.directive(KbqSearchExpandable))).not.toBeNull(); }); }); + + // Precedence implemented by KbqFilterBar.updateLocaleParams: + // configuration = externalConfiguration (KBQ_FILTER_BAR_CONFIGURATION) || localeService.getParams('filterBar') + // The subscription to KBQ_LOCALE_SERVICE.changes re-runs updateLocaleParams on every locale emission. + describe('locale-change / externalConfiguration precedence', () => { + // Minimal stand-in for KbqLocaleService: a BehaviorSubject-backed `changes` stream plus + // `getParams`, returning a distinct configuration per locale id so swaps are observable. + class MockLocaleService { + readonly changes = new BehaviorSubject('locale-a'); + + private readonly params: Record = { + 'locale-a': { + ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + filters: { ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters, defaultName: 'Locale A name' } + }, + 'locale-b': { + ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + filters: { ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters, defaultName: 'Locale B name' } + } + }; + + getParams(): KbqFilterBarConfiguration { + return this.params[this.changes.value]; + } + + // Mirror of KbqLocaleService.setLocale: publish the new id through `changes`. + setLocale(id: string): void { + this.changes.next(id); + } + } + + const externalConfiguration: KbqFilterBarConfiguration = { + ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + filters: { ...KBQ_FILTER_BAR_DEFAULT_CONFIGURATION.filters, defaultName: 'External name' } + }; + + it('should update configuration when the locale service emits a change', () => { + const localeService = new MockLocaleService(); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, KbqFilterBarModule, TestComponent], + providers: [{ provide: KBQ_LOCALE_SERVICE, useValue: localeService }] + }); + + const localFixture = TestBed.createComponent(TestComponent); + + localFixture.detectChanges(); + + const filterBar = localFixture.debugElement.query(By.directive(KbqFilterBar)) + .componentInstance as KbqFilterBar; + + // Initial locale ('locale-a') is applied via the BehaviorSubject's replayed value. + expect(filterBar.configuration.filters.defaultName).toBe('Locale A name'); + + // Switching the locale must re-run updateLocaleParams and swap the configuration. + localeService.setLocale('locale-b'); + + expect(filterBar.configuration.filters.defaultName).toBe('Locale B name'); + }); + + it('should let externalConfiguration win over the locale service', () => { + const localeService = new MockLocaleService(); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, KbqFilterBarModule, TestComponent], + providers: [ + { provide: KBQ_LOCALE_SERVICE, useValue: localeService }, + { provide: KBQ_FILTER_BAR_CONFIGURATION, useValue: externalConfiguration } + ] + }); + + const localFixture = TestBed.createComponent(TestComponent); + + localFixture.detectChanges(); + + const filterBar = localFixture.debugElement.query(By.directive(KbqFilterBar)) + .componentInstance as KbqFilterBar; + + // externalConfiguration takes precedence over the locale-provided params. + expect(filterBar.configuration.filters.defaultName).toBe('External name'); + + // A locale change must NOT override the external configuration. + localeService.setLocale('locale-b'); + + expect(filterBar.configuration.filters.defaultName).toBe('External name'); + }); + }); }); diff --git a/packages/components/filter-bar/filters.spec.ts b/packages/components/filter-bar/filters.spec.ts index 75afdf1673..0bca369394 100644 --- a/packages/components/filter-bar/filters.spec.ts +++ b/packages/components/filter-bar/filters.spec.ts @@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, DebugElement, inject, viewChild } from '@ import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { KbqButton } from '@koobiq/components/button'; import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KbqFilter, @@ -13,6 +14,7 @@ import { KbqPipeTypes, KbqSaveFilterStatuses } from '@koobiq/components/filter-bar'; +import { KbqPopoverTrigger } from '@koobiq/components/popover'; const PIPE_TEMPLATE_ID = 'TestText'; @@ -113,6 +115,21 @@ describe('KbqFilters', () => { return filterBarDebugElement.componentInstance; }; + const getPopoverTrigger = (): KbqPopoverTrigger => { + // The popover trigger directive is declared on the main button host element. + return filtersDebugElement.query(By.directive(KbqPopoverTrigger)).injector.get(KbqPopoverTrigger); + }; + + const getMainButton = (): KbqButton => { + // The main button is the first kbq-button rendered in the filters template. + return filtersDebugElement.query(By.directive(KbqButton)).injector.get(KbqButton); + }; + + const getFilterActionsButton = (): KbqButton => { + // The actions button is the only kbq-button_action rendered for a saved (unchanged) filter. + return filtersDebugElement.query(By.css('.kbq-button_action')).injector.get(KbqButton); + }; + const initFixture = (activeFilter: KbqFilter | null = null, filters: KbqFilter[] = []) => { fixture = TestBed.createComponent(TestComponent); fixture.componentInstance.activeFilter = activeFilter; @@ -218,7 +235,7 @@ describe('KbqFilters', () => { }); it('should push structuredClone to filterBar.internalFilterChanges', () => { - const filter = createFilter([], { name: 'Selected' }); + const filter = createFilter([createPipe({ name: 'original' })], { name: 'Selected' }); initFixture(createFilter([]), [filter]); @@ -231,6 +248,12 @@ describe('KbqFilters', () => { expect(clonedFilter).not.toBe(filter); expect(clonedFilter!.name).toBe('Selected'); + + // Deep isolation: the pushed value is a structuredClone, so nested references must not be + // shared with the original. Mutating the clone must never leak back to the source filter. + expect(clonedFilter!.pipes).not.toBe(filter.pipes); + clonedFilter!.pipes[0].name = 'mutated'; + expect(filter.pipes[0].name).toBe('original'); }); }); @@ -532,10 +555,43 @@ describe('KbqFilters', () => { fixture.detectChanges(); flush(); - (component as any).popover().preventClose = true; + const popoverTrigger = getPopoverTrigger(); + + popoverTrigger.preventClose = true; + component.filterSavedSuccessfully(); + + expect(popoverTrigger.preventClose).toBe(false); + })); + + it('should restore focus after the deferred timeout', fakeAsync(() => { + const filter = createFilter([], { name: 'Test' }); + + initFixture(filter); + + const component = getFiltersComponent(); + + component.openSaveAsNewFilterPopover(); + fixture.detectChanges(); + flush(); + + // Capture a real trigger as the element that had focus before the popover opened. + const mainButton = getMainButton(); + + component.saveFocusedElement(mainButton); + expect(component.focusedElementBeforeIs(mainButton)).toBe(true); + + const restoreSpy = jest.spyOn(component, 'restoreFocus'); + component.filterSavedSuccessfully(); - expect((component as any).popover().preventClose).toBe(false); + // restoreFocus is scheduled via setTimeout, so it must NOT run synchronously. + expect(restoreSpy).not.toHaveBeenCalled(); + + flush(); + + // After the timer fires focus is restored and the captured reference is consumed (nulled). + expect(restoreSpy).toHaveBeenCalled(); + expect(component.focusedElementBeforeIs(mainButton)).toBe(false); })); }); @@ -568,10 +624,12 @@ describe('KbqFilters', () => { fixture.detectChanges(); flush(); - (component as any).popover().preventClose = true; + const popoverTrigger = getPopoverTrigger(); + + popoverTrigger.preventClose = true; component.filterSavedUnsuccessfully(); - expect((component as any).popover().preventClose).toBe(false); + expect(popoverTrigger.preventClose).toBe(false); })); it('should show error with provided error', fakeAsync(() => { @@ -610,6 +668,63 @@ describe('KbqFilters', () => { })); }); + describe('saveAsNew round-trip after a failed save (P2-26)', () => { + it('should surface a save error, clear it on edit, then emit onSave twice across the retry', fakeAsync(() => { + const filter = createFilter([], { name: 'Existing', saved: true }); + + initFixture(filter); + + const component = getFiltersComponent(); + const onSaveSpy = fixture.componentInstance.onSaveSpy; + + // Open the "save as new" popover and let the async popover setup settle. + component.openSaveAsNewFilterPopover(); + fixture.detectChanges(); + flush(); + + // 1. User types a name and triggers the first save. + component.filterName.setValue('First Attempt'); + component.saveAsNew(); + + // saveAsNew emits onSave once and locks the form while the host persists the filter. + expect(onSaveSpy).toHaveBeenCalledTimes(1); + expect(onSaveSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: KbqSaveFilterStatuses.NewFilter, + filter: expect.objectContaining({ name: 'First Attempt' }) + }) + ); + expect(component.isSaving).toBe(true); + expect(component.filterName.disabled).toBe(true); + + // 2. Host reports the save failed because the name already exists. + component.filterSavedUnsuccessfully({ nameAlreadyExists: true }); + + // The inline error is shown, the saving state is released and the field is editable again. + expect(component.showFilterSavingError).toBe(true); + expect(component.filterSavingErrorText).toBe(component.localeData.errorHint); + expect(component.isSaving).toBe(false); + expect(component.filterName.enabled).toBe(true); + + // 3. User edits the name; changing the value clears the lingering error (preparePopover subscription). + component.filterName.setValue('Second Attempt'); + + expect(component.showFilterSavingError).toBe(false); + + // 4. Retry succeeds: onSave fires a second time with the corrected name and no lingering error. + component.saveAsNew(); + + expect(onSaveSpy).toHaveBeenCalledTimes(2); + expect(onSaveSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: KbqSaveFilterStatuses.NewFilter, + filter: expect.objectContaining({ name: 'Second Attempt' }) + }) + ); + expect(component.showFilterSavingError).toBe(false); + })); + }); + describe('getFilteredOptions (via searchControl)', () => { const filtersList = [ createFilter([], { name: 'Alpha' }), @@ -709,7 +824,7 @@ describe('KbqFilters', () => { fixture.detectChanges(); const component = getFiltersComponent(); - const button = (component as any).mainButton; + const button = getMainButton(); component.saveFocusedElement(button); @@ -721,8 +836,8 @@ describe('KbqFilters', () => { fixture.detectChanges(); const component = getFiltersComponent(); - const mainButton = (component as any).mainButton; - const filterActionsButton = (component as any).filterActionsButton; + const mainButton = getMainButton(); + const filterActionsButton = getFilterActionsButton(); component.saveFocusedElement(mainButton); @@ -734,7 +849,7 @@ describe('KbqFilters', () => { fixture.detectChanges(); const component = getFiltersComponent(); - const button = (component as any).mainButton; + const button = getMainButton(); component.saveFocusedElement(button); component.saveFocusedElement(); @@ -755,7 +870,7 @@ describe('KbqFilters', () => { fixture.detectChanges(); flush(); - const hideSpy = jest.spyOn((component as any).popover(), 'hide'); + const hideSpy = jest.spyOn(getPopoverTrigger(), 'hide'); component.closePopover(false); flush(); @@ -900,7 +1015,7 @@ describe('KbqFilters', () => { initFixture(filter); const component = getFiltersComponent(); - const showSpy = jest.spyOn((component as any).popover(), 'show'); + const showSpy = jest.spyOn(getPopoverTrigger(), 'show'); component.openSaveAsNewFilterPopover(); fixture.detectChanges(); diff --git a/packages/components/filter-bar/pipes/pipe-date.spec.ts b/packages/components/filter-bar/pipes/pipe-date.spec.ts index 9bc83acb35..c5487b5a3f 100644 --- a/packages/components/filter-bar/pipes/pipe-date.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-date.spec.ts @@ -18,6 +18,7 @@ import { import { DateTime } from 'luxon'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeDateComponent } from './pipe-date'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; const PIPE_TEMPLATE_ID = 'TestDate'; @@ -141,87 +142,18 @@ describe('KbqPipeDateComponent', () => { }; }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'Date', + pipeClass: 'kbq-pipe__date', + createPipe, + createFilter, + nonEmptyValue: () => createCustomRangeValue().value, + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: createCustomRangeValue().value, - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: createCustomRangeValue().value, - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: PRESET_VALUES[0], - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ - name: 'disabled', - value: PRESET_VALUES[0], - cleanable: false, - removable: false, - disabled: true - }) - ]); - fixture.detectChanges(); - }); - - it('should render all Date pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__date'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('formattedValue', () => { diff --git a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts index e4ad2e834b..0ab9f7e21f 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts @@ -18,6 +18,7 @@ import { import { DateTime } from 'luxon'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeDatetimeComponent } from './pipe-datetime'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; const PIPE_TEMPLATE_ID = 'TestDatetime'; @@ -141,87 +142,18 @@ describe('KbqPipeDatetimeComponent', () => { }; }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'Datetime', + pipeClass: 'kbq-pipe__datetime', + createPipe, + createFilter, + nonEmptyValue: () => createCustomRangeValue().value, + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: createCustomRangeValue().value, - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: createCustomRangeValue().value, - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: PRESET_VALUES[0], - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ - name: 'disabled', - value: PRESET_VALUES[0], - cleanable: false, - removable: false, - disabled: true - }) - ]); - fixture.detectChanges(); - }); - - it('should render all Datetime pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__datetime'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('formattedValue', () => { diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts index 2a2923257b..d22dde31e0 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-select.spec.ts @@ -13,6 +13,7 @@ import { } from '@koobiq/components/filter-bar'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeMultiSelectComponent } from './pipe-multi-select'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; const SELECT_VALUES = [ { id: 1, name: 'Option 1', value: 'value1' }, @@ -127,87 +128,18 @@ describe('KbqPipeMultiSelectComponent', () => { fixture.detectChanges(); }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'MultiSelect', + pipeClass: 'kbq-pipe__multiselect', + createPipe, + createFilter, + nonEmptyValue: () => [SELECT_VALUES[0]], + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: [SELECT_VALUES[0]], - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: [SELECT_VALUES[1]], - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: [SELECT_VALUES[1]], - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ - name: 'disabled', - value: [SELECT_VALUES[1]], - cleanable: false, - removable: false, - disabled: true - }) - ]); - fixture.detectChanges(); - }); - - it('should render all MultiSelect pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__multiselect'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('isEmpty', () => { @@ -569,7 +501,7 @@ describe('KbqPipeMultiSelectComponent', () => { it('should update internalSelected on close when all options selected', fakeAsync(() => { fixture.componentInstance.selectedAllEqualsSelectedNothing = true; fixture.componentInstance.activeFilter = createFilter([ - createPipe({ name: 'test', value: ALL_VALUES, selectAll: true }) + createPipe({ name: 'test', value: [SELECT_VALUES[0]], selectAll: true }) ]); fixture.detectChanges(); @@ -579,10 +511,22 @@ describe('KbqPipeMultiSelectComponent', () => { const component = getPipeComponent(); + // Select every option. Under selectedAllEqualsSelectedNothing this collapses data.value + // to [], while internalSelected still holds the previously committed value (stale). + component.toggleSelectionAll(); + flush(); + fixture.detectChanges(); + + expect(component.allOptionsSelected).toBe(true); + expect(component.data.value).toEqual([]); + // internalSelected is not refreshed during selection, so `selected` is still the old value. + expect(component.selected).toEqual([SELECT_VALUES[0]]); + + // onClose refreshes internalSelected from the current data.value. component.onClose(); flush(); - expect(component.selected).toBeDefined(); + expect(component.selected).toEqual([]); })); it('should restore focus to the trigger button on close', fakeAsync(() => { @@ -643,11 +587,23 @@ describe('KbqPipeMultiSelectComponent', () => { expect(component.compareByValue({ id: 1 }, { id: 2 })).toBe(false); }); - it('should handle null gracefully', () => { + it('should return false when the first argument is null', () => { const component = getPipeComponent(); expect(component.compareByValue(null, { id: 1 })).toBe(false); }); + + it('should return false when the second argument is null', () => { + const component = getPipeComponent(); + + expect(component.compareByValue({ id: 1 }, null)).toBe(false); + }); + + it('should return false when both arguments are null', () => { + const component = getPipeComponent(); + + expect(component.compareByValue(null, null)).toBe(false); + }); }); describe('selectAllHandler', () => { diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.ts b/packages/components/filter-bar/pipes/pipe-multi-select.ts index b555b6db0a..a6a244ee1d 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-select.ts @@ -202,9 +202,9 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i this.stateChanges.next(); } - /** Comparator of selected options */ + /** Comparator of selected options. Two null/absent values never match (aligned with the select pipe). */ compareByValue = (o1: Pick | null, o2: Pick | null): boolean => - o1?.id === o2?.id; + !!o1 && !!o2 && o1.id === o2.id; /** handler for select all options in select */ selectAllHandler = (event: KeyboardEvent) => { diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts index 6fd496cdaa..1c9100dac3 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts @@ -16,6 +16,7 @@ import { import { kbqTreeSelectAllValue } from '@koobiq/components/tree'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeMultiTreeSelectComponent } from './pipe-multi-tree-select'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; const DEV_DATA_OBJECT = { 'No roles': 'value 0', @@ -38,19 +39,6 @@ const DEV_DATA_OBJECT = { const TREE_DATA: KbqTreeSelectNode[] = kbqBuildTree(DEV_DATA_OBJECT, 0); -const ALL_LEAF_VALUES = [ - 'value 0', - 'value 1', - 'value 2', - 'value 3', - 'value 4', - 'value 5', - 'value 6', - 'value 7', - 'value 8', - 'value 9' -]; - const PIPE_TEMPLATE_ID = 'TestMultiTreeSelect'; const createPipe = (overrides: Partial): KbqPipe => ({ @@ -154,81 +142,18 @@ describe('KbqPipeMultiTreeSelectComponent', () => { fixture.detectChanges(); }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'MultiTreeSelect', + pipeClass: 'kbq-pipe__multi-tree-select', + createPipe, + createFilter, + nonEmptyValue: () => ['value 0'], + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: ['value 0'], - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: ['value 2'], - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: ['value 2'], - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ name: 'disabled', value: ['value 2'], cleanable: false, removable: false, disabled: true }) - ]); - fixture.detectChanges(); - }); - - it('should render all MultiTreeSelect pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__multi-tree-select'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('isEmpty', () => { @@ -379,29 +304,6 @@ describe('KbqPipeMultiTreeSelectComponent', () => { expect(spy).toHaveBeenCalled(); })); - - it('should set data.value to empty array when all selected and selectedAllEqualsSelectedNothing', fakeAsync(() => { - fixture.componentInstance.selectedAllEqualsSelectedNothing = true; - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ name: 'test', value: [], selectAll: true }) - ]); - fixture.detectChanges(); - - openSelect(); - flush(); - fixture.detectChanges(); - - const component = getPipeComponent(); - - component.toggleSelectAllNode(); - flush(); - fixture.detectChanges(); - - expect(component.allOptionsSelected).toBe(true); - expect(component.selectedAllEqualsSelectedNothing).toBe(true); - - expect(component.data.value).toEqual([]); - })); }); describe('toggleSelectAllNode', () => { @@ -594,7 +496,7 @@ describe('KbqPipeMultiTreeSelectComponent', () => { it('should update internalSelected on close when all options selected', fakeAsync(() => { fixture.componentInstance.selectedAllEqualsSelectedNothing = true; fixture.componentInstance.activeFilter = createFilter([ - createPipe({ name: 'test', value: ALL_LEAF_VALUES, selectAll: true }) + createPipe({ name: 'test', value: ['value 0'], selectAll: true }) ]); fixture.detectChanges(); @@ -604,12 +506,22 @@ describe('KbqPipeMultiTreeSelectComponent', () => { const component = getPipeComponent(); - // call onClose directly + // Select every option. Under selectedAllEqualsSelectedNothing this collapses data.value + // to [], while internalSelected still holds the previously committed value (stale). + component.toggleSelectAllNode(); + flush(); + fixture.detectChanges(); + + expect(component.allOptionsSelected).toBe(true); + expect(component.data.value).toEqual([]); + // internalSelected is not refreshed during selection, so `selected` is still the old value. + expect(component.selected).toEqual(['value 0']); + + // onClose refreshes internalSelected from the current data.value. component.onClose(); flush(); - // after close with all selected, internalSelected should be updated - expect(component.selected).toBeDefined(); + expect(component.selected).toEqual([]); })); it('should restore focus to the trigger button on close', fakeAsync(() => { diff --git a/packages/components/filter-bar/pipes/pipe-select.spec.ts b/packages/components/filter-bar/pipes/pipe-select.spec.ts index 731f4fd7f0..d6649ab22b 100644 --- a/packages/components/filter-bar/pipes/pipe-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-select.spec.ts @@ -15,6 +15,7 @@ import { } from '@koobiq/components/filter-bar'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeSelectComponent } from './pipe-select'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; const SELECT_VALUES: KbqSelectValue[] = [ { name: 'Option 1', value: 'value1' }, @@ -119,87 +120,18 @@ describe('KbqPipeSelectComponent', () => { fixture.detectChanges(); }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'Select', + pipeClass: 'kbq-pipe__select', + createPipe, + createFilter, + nonEmptyValue: () => SELECT_VALUES[0], + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: SELECT_VALUES[0], - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: SELECT_VALUES[1], - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: SELECT_VALUES[1], - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ - name: 'disabled', - value: SELECT_VALUES[1], - cleanable: false, - removable: false, - disabled: true - }) - ]); - fixture.detectChanges(); - }); - - it('should render all Select pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__select'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('isEmpty', () => { @@ -381,16 +313,22 @@ describe('KbqPipeSelectComponent', () => { expect(component.compareByValue({ id: 1 }, { id: 2 })).toBe(false); }); - it('should return falsy when first argument is null', () => { + it('should return false when the first argument is null', () => { + const component = getPipeComponent(); + + expect(component.compareByValue(null, { id: 1 })).toBe(false); + }); + + it('should return false when the second argument is null', () => { const component = getPipeComponent(); - expect(component.compareByValue(null, { id: 1 })).toBeFalsy(); + expect(component.compareByValue({ id: 1 }, null)).toBe(false); }); - it('should return falsy when second argument is null', () => { + it('should return false when both arguments are null', () => { const component = getPipeComponent(); - expect(component.compareByValue({ id: 1 }, null)).toBeFalsy(); + expect(component.compareByValue(null, null)).toBe(false); }); }); diff --git a/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts b/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts new file mode 100644 index 0000000000..5b6f4c93da --- /dev/null +++ b/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts @@ -0,0 +1,129 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { KbqFilter, KbqPipe } from '@koobiq/components/filter-bar'; + +/** Minimal host contract shared by every pipe spec's `TestComponent`. */ +export interface KbqPipeStatesHost { + activeFilter: KbqFilter | null; +} + +/** Live, per-test context created by each spec inside the shared `beforeEach`. */ +export interface KbqPipeStatesContext { + /** Freshly created fixture whose host exposes `activeFilter`. */ + fixture: ComponentFixture; + /** Debug element of the `KbqFilterBar` rendered by the host. */ + filterBar: DebugElement; +} + +/** Configuration passed by a pipe spec to register the shared "Pipe states" suite. */ +export interface KbqPipeStatesConfig { + /** Human-readable pipe label, used only in the "should render all X pipes" test name. */ + label: string; + /** Type modifier class every rendered pipe of this kind carries, e.g. `'kbq-pipe__text'`. */ + pipeClass: string; + /** Spec factory that stamps the correct pipe `type`/`id` onto the given overrides. */ + createPipe: (overrides: Partial) => KbqPipe; + /** Spec factory that wraps pipes into a filter. */ + createFilter: (pipes: KbqPipe[]) => KbqFilter; + /** + * Produces a fresh, non-empty value valid for this pipe type. Used for the + * required/cleanable/removable/disabled fixtures; the empty fixture always uses `null`. + */ + nonEmptyValue: () => unknown; + /** + * Creates the fixture and resolves the filter-bar debug element. Invoked inside the shared + * `beforeEach`, i.e. after the spec's own `TestBed.configureTestingModule` has run. + */ + createContext: () => KbqPipeStatesContext; +} + +/** + * The five pipe states exercised by the shared suite, in the order they render inside the filter + * bar. Looking pipes up by name (see `pipeElementAt`) replaces the previous magic `[0..4]` indices. + */ +const PIPE_STATE_DESCRIPTORS = [ + { name: 'required', empty: false, cleanable: false, removable: false, disabled: false }, + { name: 'empty', empty: true, cleanable: true, removable: false, disabled: false }, + { name: 'cleanable', empty: false, cleanable: true, removable: false, disabled: false }, + { name: 'removable', empty: false, cleanable: false, removable: true, disabled: false }, + { name: 'disabled', empty: false, cleanable: false, removable: false, disabled: true } +] as const; + +type PipeStateName = (typeof PIPE_STATE_DESCRIPTORS)[number]['name']; + +/** + * Registers the shared "Pipe states" describe block for a single pipe spec. Every pipe renders the + * same five state fixtures and asserts the CSS modifier classes `KbqBasePipe` applies to the host + * `.kbq-pipe` element, so the block is identical modulo the per-type value and modifier class. + */ +export const registerPipeStatesTests = (config: KbqPipeStatesConfig): void => { + describe('Pipe states', () => { + let filterBar: DebugElement; + + beforeEach(() => { + const context = config.createContext(); + const pipes = PIPE_STATE_DESCRIPTORS.map((descriptor) => + config.createPipe({ + name: descriptor.name, + value: descriptor.empty ? null : config.nonEmptyValue(), + cleanable: descriptor.cleanable, + removable: descriptor.removable, + disabled: descriptor.disabled + }) + ); + + context.fixture.componentInstance.activeFilter = config.createFilter(pipes); + context.fixture.detectChanges(); + + filterBar = context.filterBar; + }); + + /** Native `.kbq-pipe` element of the fixture registered under the given state name. */ + const pipeElementAt = (name: PipeStateName): HTMLElement => { + const index = PIPE_STATE_DESCRIPTORS.findIndex((descriptor) => descriptor.name === name); + + return filterBar.queryAll(By.css('.kbq-pipe'))[index].nativeElement; + }; + + it(`should render all ${config.label} pipes`, () => { + const pipes = filterBar.queryAll(By.css('.kbq-pipe')); + + expect(pipes.length).toBe(PIPE_STATE_DESCRIPTORS.length); + pipes.forEach((pipe) => { + expect(pipe.nativeElement.classList).toContain(config.pipeClass); + }); + }); + + it('should apply required state (no special classes)', () => { + const required = pipeElementAt('required'); + + expect(required.classList).not.toContain('kbq-pipe_cleanable'); + expect(required.classList).not.toContain('kbq-pipe_removable'); + expect(required.classList).not.toContain('kbq-pipe_disabled'); + expect(required.classList).not.toContain('kbq-pipe_empty'); + }); + + it('should apply empty state', () => { + expect(pipeElementAt('empty').classList).toContain('kbq-pipe_empty'); + }); + + it('should apply cleanable state', () => { + const cleanable = pipeElementAt('cleanable'); + + expect(cleanable.classList).toContain('kbq-pipe_cleanable'); + expect(cleanable.classList).not.toContain('kbq-pipe_removable'); + }); + + it('should apply removable state', () => { + const removable = pipeElementAt('removable'); + + expect(removable.classList).toContain('kbq-pipe_removable'); + expect(removable.classList).not.toContain('kbq-pipe_cleanable'); + }); + + it('should apply disabled state', () => { + expect(pipeElementAt('disabled').classList).toContain('kbq-pipe_disabled'); + }); + }); +}; diff --git a/packages/components/filter-bar/pipes/pipe-text.spec.ts b/packages/components/filter-bar/pipes/pipe-text.spec.ts index 568cc8c75e..3752ae3879 100644 --- a/packages/components/filter-bar/pipes/pipe-text.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-text.spec.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef, Component, DebugElement, inject } from '@angular/cor import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ENTER, ESCAPE } from '@koobiq/components/core'; import { KbqFilter, KbqFilterBar, @@ -12,6 +13,7 @@ import { KbqPipeTypes } from '@koobiq/components/filter-bar'; import { KbqBasePipe } from './base-pipe'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; import { KbqPipeTextComponent } from './pipe-text'; const PIPE_TEMPLATE_ID = 'TestText'; @@ -102,81 +104,18 @@ describe('KbqPipeTextComponent', () => { return filterBarDebugElement.componentInstance; }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'Text', + pipeClass: 'kbq-pipe__text', + createPipe, + createFilter, + nonEmptyValue: () => 'some text', + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: 'some text', - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: 'some text', - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: 'some text', - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ name: 'disabled', value: 'some text', cleanable: false, removable: false, disabled: true }) - ]); - fixture.detectChanges(); - }); - - it('should render all Text pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__text'); - }); - }); - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('isEmpty', () => { @@ -343,7 +282,7 @@ describe('KbqPipeTextComponent', () => { const component = getPipeComponent(); const applySpy = jest.spyOn(component, 'onApply'); - const event = new KeyboardEvent('keydown', { keyCode: 13, ctrlKey: true }); + const event = new KeyboardEvent('keydown', { keyCode: ENTER, ctrlKey: true }); component.onKeydown(event); @@ -356,7 +295,7 @@ describe('KbqPipeTextComponent', () => { const component = getPipeComponent(); const applySpy = jest.spyOn(component, 'onApply'); - const event = new KeyboardEvent('keydown', { keyCode: 13, metaKey: true }); + const event = new KeyboardEvent('keydown', { keyCode: ENTER, metaKey: true }); component.onKeydown(event); @@ -369,7 +308,7 @@ describe('KbqPipeTextComponent', () => { const component = getPipeComponent(); const applySpy = jest.spyOn(component, 'onApply'); - const event = new KeyboardEvent('keydown', { keyCode: 13, ctrlKey: true }); + const event = new KeyboardEvent('keydown', { keyCode: ENTER, ctrlKey: true }); component.onKeydown(event); @@ -382,7 +321,7 @@ describe('KbqPipeTextComponent', () => { const component = getPipeComponent(); const applySpy = jest.spyOn(component, 'onApply'); - const event = new KeyboardEvent('keydown', { keyCode: 27, ctrlKey: true }); // Escape + const event = new KeyboardEvent('keydown', { keyCode: ESCAPE, ctrlKey: true }); component.onKeydown(event); @@ -395,7 +334,7 @@ describe('KbqPipeTextComponent', () => { const component = getPipeComponent(); const applySpy = jest.spyOn(component, 'onApply'); - const event = new KeyboardEvent('keydown', { keyCode: 13 }); + const event = new KeyboardEvent('keydown', { keyCode: ENTER }); component.onKeydown(event); diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts index 25eaca1577..ba7cb47f3f 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-tree-select.spec.ts @@ -16,6 +16,7 @@ import { } from '@koobiq/components/filter-bar'; import { KbqTreeOption } from '@koobiq/components/tree'; import { KbqBasePipe } from './base-pipe'; +import { registerPipeStatesTests } from './pipe-states.spec-helper'; import { KbqPipeTreeSelectComponent } from './pipe-tree-select'; const DEV_DATA_OBJECT = { @@ -131,87 +132,18 @@ describe('KbqPipeTreeSelectComponent', () => { return filterBarDebugElement.componentInstance; }; - describe('Pipe states', () => { - beforeEach(() => { + registerPipeStatesTests({ + label: 'TreeSelect', + pipeClass: 'kbq-pipe__tree-select', + createPipe, + createFilter, + nonEmptyValue: () => SINGLE_VALUE, + createContext: () => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); - fixture.componentInstance.activeFilter = createFilter([ - createPipe({ - name: 'required', - value: SINGLE_VALUE, - cleanable: false, - removable: false, - disabled: false - }), - createPipe({ name: 'empty', value: null, cleanable: true, removable: false, disabled: false }), - createPipe({ - name: 'cleanable', - value: SINGLE_VALUE, - cleanable: true, - removable: false, - disabled: false - }), - createPipe({ - name: 'removable', - value: SINGLE_VALUE, - cleanable: false, - removable: true, - disabled: false - }), - createPipe({ - name: 'disabled', - value: SINGLE_VALUE, - cleanable: false, - removable: false, - disabled: true - }) - ]); - fixture.detectChanges(); - }); - - it('should render all TreeSelect pipes', () => { - const pipes = filterBarDebugElement.queryAll(By.css('.kbq-pipe')); - - expect(pipes.length).toBe(5); - pipes.forEach((pipe) => { - expect(pipe.nativeElement.classList).toContain('kbq-pipe__tree-select'); - }); - }); - - it('should apply required state (no special classes)', () => { - const required = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[0]; - - expect(required.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_removable'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_disabled'); - expect(required.nativeElement.classList).not.toContain('kbq-pipe_empty'); - }); - - it('should apply empty state', () => { - const empty = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[1]; - expect(empty.nativeElement.classList).toContain('kbq-pipe_empty'); - }); - - it('should apply cleanable state', () => { - const cleanable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[2]; - - expect(cleanable.nativeElement.classList).toContain('kbq-pipe_cleanable'); - expect(cleanable.nativeElement.classList).not.toContain('kbq-pipe_removable'); - }); - - it('should apply removable state', () => { - const removable = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[3]; - - expect(removable.nativeElement.classList).toContain('kbq-pipe_removable'); - expect(removable.nativeElement.classList).not.toContain('kbq-pipe_cleanable'); - }); - - it('should apply disabled state', () => { - const disabled = filterBarDebugElement.queryAll(By.css('.kbq-pipe'))[4]; - - expect(disabled.nativeElement.classList).toContain('kbq-pipe_disabled'); - }); + return { fixture, filterBar: filterBarDebugElement }; + } }); describe('isEmpty', () => { From 6308e885adf75e9a43e18307ae65a8e9d79caabc Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 12:12:18 +0300 Subject: [PATCH 03/16] fix: phase C --- .../filter-bar/_filter-bar-theme.scss | 86 +++++++++++++++++++ .../components/filter-bar/filter-bar.scss | 10 ++- .../components/filter-bar/filter-bar.types.ts | 7 +- packages/components/filter-bar/filters.scss | 36 ++++---- .../filter-bar/pipes/base-pipe.scss | 41 +++------ .../filter-bar/pipes/pipe-date.scss | 22 ++--- .../components/filter-bar/pipes/pipe-date.ts | 2 +- .../filter-bar/pipes/pipe-datetime.ts | 2 +- .../pipes/pipe-multi-tree-select.scss | 4 +- .../filter-bar/pipes/pipe-multiselect.scss | 4 +- .../filter-bar/pipes/pipe-readonly.scss | 17 +--- .../filter-bar/pipes/pipe-select.scss | 4 +- .../filter-bar/pipes/pipe-tree-select.scss | 4 +- 13 files changed, 160 insertions(+), 79 deletions(-) diff --git a/packages/components/filter-bar/_filter-bar-theme.scss b/packages/components/filter-bar/_filter-bar-theme.scss index b0dd18ed2a..576f47742f 100644 --- a/packages/components/filter-bar/_filter-bar-theme.scss +++ b/packages/components/filter-bar/_filter-bar-theme.scss @@ -6,6 +6,92 @@ } } +// Shared scroll height for the select/tree overlay panels rendered outside the bar. +// Keeps the single magic value in one place (used by every select-family pipe panel). +@mixin kbq-filter-bar-panel-content-max-height() { + max-height: 404px; +} + +// Themable colour surfaces of a pipe. Kept out of the layout SCSS so every colour +// lives behind a theme hook (they still resolve through the global semantic tokens). +@mixin kbq-filter-bar-pipe-theme() { + .kbq-pipe { + .kbq-pipe__name { + color: var(--kbq-foreground-contrast-secondary); + } + + .kbq-pipe__separator { + color: var(--kbq-line-contrast-less); + } + + .kbq-select:not(:hover, .kbq-active), + .kbq-tree-select:not(:hover, .kbq-active), + .kbq-button:not(:hover, .kbq-active, [disabled]) { + & + .kbq-pipe-button:not(:hover, .kbq-active)::after { + background: var(--kbq-line-contrast-less); + } + } + + &.kbq-pipe_disabled { + .kbq-pipe__name, + .kbq-pipe__value { + color: var(--kbq-states-foreground-disabled); + } + } + } + + .kbq-pipe.kbq-pipe_removable, + .kbq-pipe.kbq-pipe_cleanable:not(.kbq-pipe_empty) { + .kbq-button.cdk-keyboard-focused::after { + background: var(--kbq-states-line-focus-theme); + } + + .kbq-button-icon.cdk-keyboard-focused::after { + background: var(--kbq-states-line-focus-theme); + } + } + + .kbq-pipe-tooltip__name { + color: var(--kbq-foreground-white-secondary); + } + + .kbq-pipe-tooltip__value { + color: var(--kbq-foreground-white); + } +} + +// Themable colour surfaces of a read-only pipe. +@mixin kbq-filter-bar-readonly-theme() { + .kbq-pipe.kbq-pipe__readonly:not(.kbq-pipe_cleanable) { + & .kbq-button-icon.kbq-button_filled:not(:hover), + & .kbq-button.kbq-button_filled, + & .kbq-button.kbq-button_filled:hover { + background: var(--kbq-states-background-disabled); + } + + & .kbq-pipe__name, + & .kbq-pipe__value { + color: var(--kbq-states-foreground-disabled); + } + } +} + +// Themable colour surfaces of the filters list (the changed-and-saved warning dot and the +// separator drawn between the trigger and its action button). +@mixin kbq-filter-bar-filters-theme() { + .kbq-filters { + .kbq-button_changed-saved-filter::before { + border-color: var(--kbq-background-bg); + + background: var(--kbq-icon-warning); + } + + .kbq-button:not(:hover, .kbq-active) + .kbq-button_action:not(:hover, .kbq-active)::after { + background: var(--kbq-line-contrast-less); + } + } +} + @mixin _icon($left-icon, $right-icon) { .kbq-icon.kbq-empty { color: $left-icon; diff --git a/packages/components/filter-bar/filter-bar.scss b/packages/components/filter-bar/filter-bar.scss index 3e0454c9d5..c46228ca07 100644 --- a/packages/components/filter-bar/filter-bar.scss +++ b/packages/components/filter-bar/filter-bar.scss @@ -32,12 +32,18 @@ } .kbq-pipe__popover { + // Token is declared on the overlay panel itself so it resolves outside `.kbq-filter-bar`. + --kbq-filter-bar-popover-width: 320px; + .kbq-popover__container { - min-width: 320px; - max-width: 320px; + min-width: var(--kbq-filter-bar-popover-width); + max-width: var(--kbq-filter-bar-popover-width); } .kbq-popover__content { + // Load-bearing `!important`: the text pipe sets `[defaultPaddings]="false"` (no base + // `_default-paddings` padding), but the custom-pipe docs example reuses this panel class with + // default paddings, where the base rule ties this one — `!important` keeps this padding winning. padding: var(--kbq-size-m) var(--kbq-size-m) 1px !important; } diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 23ed1b889c..582b7bef0a 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -11,7 +11,12 @@ import { KbqPipeSelectComponent } from './pipes/pipe-select'; import { KbqPipeTextComponent } from './pipes/pipe-text'; import { KbqPipeTreeSelectComponent } from './pipes/pipe-tree-select'; -/** default configuration of filter-bar */ +/** + * Default localized strings for the filter-bar, used when no `KBQ_LOCALE_SERVICE` (nor an explicit + * `KBQ_FILTER_BAR_CONFIGURATION`) is provided. These are the Russian (`ru-RU`) strings, matching the + * library-wide default-locale convention (every `KBQ_*_DEFAULT_CONFIGURATION` resolves to `ruRULocaleData`). + * Provide `KBQ_LOCALE_SERVICE` at the application root to localize the filter-bar for other locales. + */ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION = ruRULocaleData.filterBar; /** Shape of the localized strings consumed by the filter-bar and its pipes. */ diff --git a/packages/components/filter-bar/filters.scss b/packages/components/filter-bar/filters.scss index e4367a1aff..b46958d499 100644 --- a/packages/components/filter-bar/filters.scss +++ b/packages/components/filter-bar/filters.scss @@ -29,8 +29,10 @@ height: var(--kbq-size-m) !important; - margin-left: calc(var(--kbq-size-s) + var(--kbq-size-xs)) !important; - margin-right: var(--kbq-size-xs) !important; + // `!important` is load-bearing: it overrides the divider's own higher-specificity + // `.kbq-divider_paddings` margin rule. Logical props keep the override RTL-safe. + margin-inline-start: calc(var(--kbq-size-s) + var(--kbq-size-xs)) !important; + margin-inline-end: var(--kbq-size-xs) !important; } & .kbq-filter-bar__arrow { @@ -40,50 +42,50 @@ } .kbq-button.kbq-button_changed-filter { - border-bottom-right-radius: unset; - border-top-right-radius: unset; + border-end-end-radius: unset; + border-start-end-radius: unset; } .kbq-button-icon { - margin-left: -1px; + margin-inline-start: -1px; - border-bottom-left-radius: unset; - border-top-left-radius: unset; + border-end-start-radius: unset; + border-start-start-radius: unset; } .kbq-button_changed-saved-filter { &::before { content: ''; position: absolute; - right: -4px; + inset-inline-end: -4px; top: -4px; width: var(--kbq-size-s); height: var(--kbq-size-s); - border: 2px solid var(--kbq-background-bg); + border-width: 2px; + border-style: solid; border-radius: var(--kbq-size-xs); - - background: var(--kbq-icon-warning); } } .kbq-button:not(:hover, .kbq-active) + .kbq-button_action:not(:hover, .kbq-active)::after { content: ''; position: absolute; - left: 0; + inset-inline-start: 0; top: 10px; width: 1px; height: var(--kbq-size-m); - - background: var(--kbq-line-contrast-less); } } .kbq-filters__save-as-new-filter-popover { + // Token is declared on the overlay panel itself so it resolves outside `.kbq-filter-bar`. + --kbq-filter-bar-save-popover-width: 400px; + & .kbq-popover__container { - width: 400px; + width: var(--kbq-filter-bar-save-popover-width); } & .kbq-popover__content { @@ -92,5 +94,7 @@ } .kbq-filters__dropdown .kbq-dropdown-item { - padding-right: var(--kbq-size-s); + padding-inline-end: var(--kbq-size-s); } + +@include kbq-filter-bar-filters-theme(); diff --git a/packages/components/filter-bar/pipes/base-pipe.scss b/packages/components/filter-bar/pipes/base-pipe.scss index 7bc3aad3bb..dca9bdcacd 100644 --- a/packages/components/filter-bar/pipes/base-pipe.scss +++ b/packages/components/filter-bar/pipes/base-pipe.scss @@ -19,8 +19,6 @@ @include common.kbq-truncate-line(); flex: 1 1 auto; - - color: var(--kbq-foreground-contrast-secondary); } .kbq-pipe__value { @@ -42,8 +40,6 @@ height: var(--kbq-size-l); align-self: center; - - color: var(--kbq-line-contrast-less); } .kbq-select:not(:hover, .kbq-active), @@ -52,67 +48,50 @@ & + .kbq-pipe-button:not(:hover, .kbq-active)::after { content: ''; position: absolute; - left: 0; + inset-inline-start: 0; top: var(--kbq-size-s); width: 1px; height: var(--kbq-size-l); - - background: var(--kbq-line-contrast-less); } } .kbq-button.cdk-keyboard-focused { z-index: 1; } - - &.kbq-pipe_disabled { - .kbq-pipe__name, - .kbq-pipe__value { - color: var(--kbq-states-foreground-disabled); - } - } } .kbq-pipe.kbq-pipe_removable, .kbq-pipe.kbq-pipe_cleanable:not(.kbq-pipe_empty) { .kbq-button { - border-bottom-right-radius: unset; - border-top-right-radius: unset; - border-right: none; + border-end-end-radius: unset; + border-start-end-radius: unset; + border-inline-end: none; &.cdk-keyboard-focused::after { content: ''; position: absolute; - right: 0; + inset-inline-end: 0; top: 0; bottom: 0; width: 1px; - background: var(--kbq-states-line-focus-theme); } } .kbq-button-icon { - border-bottom-left-radius: unset; - border-top-left-radius: unset; - border-left: none; + border-end-start-radius: unset; + border-start-start-radius: unset; + border-inline-start: none; &.cdk-keyboard-focused::after { content: ''; position: absolute; - left: 0; + inset-inline-start: 0; top: 0; bottom: 0; width: 1px; - background: var(--kbq-states-line-focus-theme); } } } -.kbq-pipe-tooltip__name { - color: var(--kbq-foreground-white-secondary); -} - -.kbq-pipe-tooltip__value { - color: var(--kbq-foreground-white); -} +@include kbq-filter-bar-pipe-theme(); diff --git a/packages/components/filter-bar/pipes/pipe-date.scss b/packages/components/filter-bar/pipes/pipe-date.scss index b8ab894627..abcc014e48 100644 --- a/packages/components/filter-bar/pipes/pipe-date.scss +++ b/packages/components/filter-bar/pipes/pipe-date.scss @@ -4,6 +4,10 @@ @use '../../datepicker/datepicker-tokens' as *; .kbq-pipe-date__popover { + // Tokens are declared on the overlay panel itself so they resolve outside `.kbq-filter-bar`. + --kbq-filter-bar-date-period-width: 320px; + --kbq-filter-bar-date-field-width: 136px; + & .kbq-popover__container { max-height: unset; } @@ -16,8 +20,7 @@ margin: var(--kbq-size-xxs) 0; & .kbq-list-option { - margin-left: var(--kbq-size-xxs); - margin-right: var(--kbq-size-xxs); + margin-inline: var(--kbq-size-xxs); & .kbq-list-text { display: flex; @@ -25,7 +28,7 @@ } & .kbq-icon { - margin-left: var(--kbq-size-s); + margin-inline-start: var(--kbq-size-s); align-self: center; color: var(--kbq-icon-fade-contrast-color); @@ -34,7 +37,7 @@ } & .kbq-date-period { - width: 320px; + width: var(--kbq-filter-bar-date-period-width); & .kbq-date-period__header { padding: var(--kbq-size-xs) var(--kbq-size-xxs) var(--kbq-size-s); @@ -59,11 +62,11 @@ gap: var(--kbq-size-s); & .kbq-form__label { - padding-left: var(--kbq-size-l); + padding-inline-start: var(--kbq-size-l); } & .kbq-form__control { - padding-right: var(--kbq-size-l); + padding-inline-end: var(--kbq-size-l); } & .kbq-date-period__date, @@ -75,7 +78,7 @@ display: flex; & .kbq-date-period__date { - width: 136px; + width: var(--kbq-filter-bar-date-field-width); & .kbq-form-field__container { border-top-right-radius: unset; @@ -85,7 +88,7 @@ & .kbq-date-period__time.kbq-form-field-type-timepicker { flex: 1; - margin-left: -1px; + margin-inline-start: -1px; width: unset; & .kbq-form-field__container { @@ -109,8 +112,7 @@ & .kbq-hint { grid-column: 2 span; - padding-left: var(--kbq-size-m); - padding-right: var(--kbq-size-m); + padding-inline: var(--kbq-size-m); } } diff --git a/packages/components/filter-bar/pipes/pipe-date.ts b/packages/components/filter-bar/pipes/pipe-date.ts index 62224d70e7..d89fc54112 100644 --- a/packages/components/filter-bar/pipes/pipe-date.ts +++ b/packages/components/filter-bar/pipes/pipe-date.ts @@ -11,8 +11,8 @@ import { KbqPopoverModule } from '@koobiq/components/popover'; import { KbqTimepickerModule } from '@koobiq/components/timepicker'; import { KbqTitleModule } from '@koobiq/components/title'; import { KbqBasePipe } from './base-pipe'; -import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeButton } from './pipe-button'; +import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeState } from './pipe-state'; @Component({ diff --git a/packages/components/filter-bar/pipes/pipe-datetime.ts b/packages/components/filter-bar/pipes/pipe-datetime.ts index 1bb578bb9c..4bffeb9e76 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.ts @@ -11,8 +11,8 @@ import { KbqPopoverModule } from '@koobiq/components/popover'; import { KbqTimepickerModule } from '@koobiq/components/timepicker'; import { KbqTitleModule } from '@koobiq/components/title'; import { KbqBasePipe } from './base-pipe'; -import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeButton } from './pipe-button'; +import { KbqPipeDateBaseComponent } from './pipe-date-base'; import { KbqPipeState } from './pipe-state'; @Component({ diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss b/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss index 9c4bcf5748..be38b9f106 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.scss @@ -1,3 +1,5 @@ +@use '../filter-bar-theme' as *; + .kbq-pipe__multi-tree-select { .kbq-pipe__value { margin-inline-start: var(--kbq-size-xxs); @@ -15,5 +17,5 @@ } .kbq-pipe-multi-tree-select__panel .kbq-select__content { - max-height: 404px; + @include kbq-filter-bar-panel-content-max-height(); } diff --git a/packages/components/filter-bar/pipes/pipe-multiselect.scss b/packages/components/filter-bar/pipes/pipe-multiselect.scss index 8f3758c930..d5d6bd2ae0 100644 --- a/packages/components/filter-bar/pipes/pipe-multiselect.scss +++ b/packages/components/filter-bar/pipes/pipe-multiselect.scss @@ -1,3 +1,5 @@ +@use '../filter-bar-theme' as *; + .kbq-pipe__multiselect { .kbq-pipe__value { margin-inline-start: var(--kbq-size-xxs); @@ -11,5 +13,5 @@ } .kbq-pipe-multiselect__panel .kbq-select__content { - max-height: 404px; + @include kbq-filter-bar-panel-content-max-height(); } diff --git a/packages/components/filter-bar/pipes/pipe-readonly.scss b/packages/components/filter-bar/pipes/pipe-readonly.scss index 9cbb2488a7..b870f86522 100644 --- a/packages/components/filter-bar/pipes/pipe-readonly.scss +++ b/packages/components/filter-bar/pipes/pipe-readonly.scss @@ -1,18 +1,9 @@ +@use '../filter-bar-theme' as *; + .kbq-pipe.kbq-pipe__readonly { & .kbq-button { cursor: default; } - - &:not(.kbq-pipe_cleanable) { - & .kbq-button-icon.kbq-button_filled:not(:hover), - & .kbq-button.kbq-button_filled, - & .kbq-button.kbq-button_filled:hover { - background: var(--kbq-states-background-disabled); - } - - & .kbq-pipe__name, - & .kbq-pipe__value { - color: var(--kbq-states-foreground-disabled); - } - } } + +@include kbq-filter-bar-readonly-theme(); diff --git a/packages/components/filter-bar/pipes/pipe-select.scss b/packages/components/filter-bar/pipes/pipe-select.scss index e74c134d0a..e0d2d9a074 100644 --- a/packages/components/filter-bar/pipes/pipe-select.scss +++ b/packages/components/filter-bar/pipes/pipe-select.scss @@ -1,3 +1,5 @@ +@use '../filter-bar-theme' as *; + .kbq-pipe-select__panel .kbq-select__content { - max-height: 404px; + @include kbq-filter-bar-panel-content-max-height(); } diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.scss b/packages/components/filter-bar/pipes/pipe-tree-select.scss index 200cf343c4..5006a03966 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.scss +++ b/packages/components/filter-bar/pipes/pipe-tree-select.scss @@ -1,5 +1,7 @@ +@use '../filter-bar-theme' as *; + .kbq-pipe-tree-select__panel .kbq-tree-select__content { - max-height: 404px; + @include kbq-filter-bar-panel-content-max-height(); } .kbq-pipe__tree-select { From 3001f34cf49fd058f3207a2be392e5e6dc6e6fab Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 13:09:38 +0300 Subject: [PATCH 04/16] fix: P3-2 + P3-6 --- packages/components/filter-bar/filter-bar.spec.ts | 12 ++++++++++++ packages/components/filter-bar/filter-bar.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index dd25989bb2..a7fad6d600 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -427,6 +427,18 @@ describe('KbqFilterBar', () => { expect(filterBar.filter!.pipes[0].name).toBe('restored'); }); + + it('should not wipe the current filter when there is nothing to restore', () => { + const filterBar = getFilterBar(); + + filterBar.filter = createFilter([createPipe({ name: 'current', value: 'v' })]); + + // Nothing saved and no explicit arg → must be a no-op, not a silent `structuredClone(null)` wipe. + filterBar.restoreFilterState(); + + expect(filterBar.filter).not.toBeNull(); + expect(filterBar.filter!.pipes[0].name).toBe('current'); + }); }); describe('resetFilterChangedState', () => { diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index 501a40cba2..b320e8d9e0 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -215,7 +215,12 @@ export class KbqFilterBar { /** Restore previously saved filter state */ restoreFilterState(filter?: KbqFilter) { - this.filter = structuredClone(filter ?? this.savedFilter); + const state = filter ?? this.savedFilter; + + // Nothing to restore — bail out instead of wiping the current filter with `structuredClone(null)`. + if (!state) return; + + this.filter = structuredClone(state); } /** Set the filter state "changed" to false */ From 6857ed75d973a0dac3f7e91b804e52e111dbc595 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 13:46:22 +0300 Subject: [PATCH 05/16] fix: P2-19 --- .../filter-bar/filter-bar-button.ts | 6 +- packages/components/filter-bar/filter-bar.ts | 6 +- .../components/filter-bar/filter-bar.types.ts | 64 +++++++- .../components/filter-bar/filter-refresher.ts | 7 +- .../components/filter-bar/filter-reset.ts | 7 +- packages/components/filter-bar/pipe-add.ts | 5 +- .../components/filter-bar/pipes/base-pipe.ts | 6 +- .../pipes/multi-select-pipe-state.ts | 59 +++++++ .../filter-bar/pipes/pipe-button.ts | 4 +- .../filter-bar/pipes/pipe-multi-select.ts | 45 ++---- .../pipes/pipe-multi-tree-select.html | 6 +- .../pipes/pipe-multi-tree-select.spec.ts | 6 +- .../pipes/pipe-multi-tree-select.ts | 147 +++--------------- .../components/filter-bar/pipes/pipe-state.ts | 4 +- .../filter-bar/pipes/pipe-tree-select.ts | 102 ++---------- .../filter-bar/pipes/tree-select-pipe-base.ts | 96 ++++++++++++ packages/components/filter-bar/public-api.ts | 1 + .../components/filter-bar.api.md | 93 ++++++++--- 18 files changed, 363 insertions(+), 301 deletions(-) create mode 100644 packages/components/filter-bar/pipes/multi-select-pipe-state.ts create mode 100644 packages/components/filter-bar/pipes/tree-select-pipe-base.ts diff --git a/packages/components/filter-bar/filter-bar-button.ts b/packages/components/filter-bar/filter-bar-button.ts index 9a0108e03a..0793a42aa8 100644 --- a/packages/components/filter-bar/filter-bar-button.ts +++ b/packages/components/filter-bar/filter-bar-button.ts @@ -2,7 +2,7 @@ import { Directive, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; -import { KbqFilterBar } from './filter-bar'; +import { KBQ_FILTER_BAR_HOST } from './filter-bar.types'; import { KbqFilters } from './filters'; @Directive({ @@ -14,8 +14,8 @@ import { KbqFilters } from './filters'; }) export class KbqFilterBarButton { private readonly button = inject(KbqButton); - /** KbqFilterBar instance */ - private readonly filterBar = inject(KbqFilterBar); + /** KbqFilterBar host seam */ + private readonly filterBar = inject(KBQ_FILTER_BAR_HOST); /** KbqFilters instance */ protected readonly filters = inject(KbqFilters); diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index b320e8d9e0..a39629195c 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -4,6 +4,7 @@ import { ChangeDetectorRef, Component, contentChild, + forwardRef, inject, Input, input, @@ -17,8 +18,10 @@ import { BehaviorSubject, merge } from 'rxjs'; import { KBQ_FILTER_BAR_CONFIGURATION, KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + KBQ_FILTER_BAR_HOST, KbqFilter, KbqFilterBarConfiguration, + KbqFilterBarHost, KbqPipe, KbqPipeTemplate } from './filter-bar.types'; @@ -46,13 +49,14 @@ import { KbqFilters } from './filters'; `, styleUrls: ['filter-bar-tokens.scss', 'filter-bar.scss'], + providers: [{ provide: KBQ_FILTER_BAR_HOST, useExisting: forwardRef(() => KbqFilterBar) }], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'kbq-filter-bar' } }) -export class KbqFilterBar { +export class KbqFilterBar implements KbqFilterBarHost { /** @docs-private */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); /** @docs-private */ diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 582b7bef0a..6ce13928d2 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -1,5 +1,6 @@ -import { InjectionToken, Provider, TemplateRef, Type } from '@angular/core'; +import { InjectionToken, OutputEmitterRef, Provider, Signal, TemplateRef, Type } from '@angular/core'; import { ruRULocaleData } from '@koobiq/components/core'; +import { BehaviorSubject } from 'rxjs'; import { KbqFilterBar } from './filter-bar'; import type { KbqBasePipe } from './pipes/base-pipe'; import { KbqPipeDateComponent } from './pipes/pipe-date'; @@ -25,6 +26,67 @@ export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURAT /** Injection Token for providing configuration of filter-bar */ export const KBQ_FILTER_BAR_CONFIGURATION = new InjectionToken('KbqFilterBarConfiguration'); +/** + * Contract a pipe (or filter-bar sub-component) depends on instead of the concrete `KbqFilterBar`. + * `KbqFilterBar` provides itself as {@link KBQ_FILTER_BAR_HOST}, so a pipe can be unit-tested against a + * lightweight mock of this seam rather than a full bar. Members mirror the current `KbqFilterBar` surface; + * they are expected to slim down once the state model moves to signals. + */ +export interface KbqFilterBarHost { + /** Localized strings and configuration for the filter-bar and its pipes. */ + configuration: KbqFilterBarConfiguration; + + /** Currently selected filter. */ + filter: KbqFilter | null; + + /** Templates used when adding a pipe (also hold the option lists). */ + pipeTemplates: KbqPipeTemplate[]; + + /** Whether selecting all options counts as selecting nothing. */ + readonly selectedAllEqualsSelectedNothing: Signal; + + /** Whether the current filter is saved. */ + readonly isSaved: boolean; + /** Whether the current filter is changed. */ + readonly isChanged: boolean; + /** Whether the current filter is both saved and changed. */ + readonly isSavedAndChanged: boolean; + /** Whether the current filter is readonly. */ + readonly isReadOnly: boolean; + + /** Emits the two-way-bound `filter` value. */ + readonly filterChange: OutputEmitterRef; + /** Emits whenever a pipe value changes. */ + readonly onChangePipe: OutputEmitterRef; + /** Emits whenever a pipe is cleared. */ + readonly onClearPipe: OutputEmitterRef; + /** Emits whenever a select/multiselect pipe closes. */ + readonly onClosePipe: OutputEmitterRef; + + /** State-change bus. */ + readonly changes: BehaviorSubject; + /** Internal filter changes. */ + readonly internalFilterChanges: BehaviorSubject; + /** Internal pipe-template changes. */ + readonly internalTemplatesChanges: BehaviorSubject; + /** Requests opening a pipe after it is added. */ + readonly openPipe: BehaviorSubject; + /** Emits when the filter is reset. */ + readonly onResetFilter: BehaviorSubject; + + /** Removes a pipe from the current filter and emits the change. */ + removePipe(pipe: KbqPipe): void; + + /** Clears the current filter's "changed" flag. */ + resetFilterChangedState(): void; +} + +/** + * Injection token exposing the {@link KbqFilterBarHost} seam. `KbqFilterBar` provides itself via + * `useExisting`, so pipes `inject(KBQ_FILTER_BAR_HOST)` instead of depending on the concrete bar. + */ +export const KBQ_FILTER_BAR_HOST = new InjectionToken('KBQ_FILTER_BAR_HOST'); + /** Injection Token for providing pipes in filter-bar */ export const KBQ_FILTER_BAR_PIPES = new InjectionToken>>>( 'kbq-filter-bar-pipes' diff --git a/packages/components/filter-bar/filter-refresher.ts b/packages/components/filter-bar/filter-refresher.ts index 55612a483a..09734d33b0 100644 --- a/packages/components/filter-bar/filter-refresher.ts +++ b/packages/components/filter-bar/filter-refresher.ts @@ -1,8 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; -import { KbqFilterBar } from './filter-bar'; -import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION } from './filter-bar.types'; +import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, KBQ_FILTER_BAR_HOST } from './filter-bar.types'; @Component({ selector: 'kbq-filter-refresher, [kbq-filter-refresher]', @@ -36,8 +35,8 @@ import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION } from './filter-bar.types'; } }) export class KbqFilterRefresher { - /** KbqFilterBar instance */ - private readonly filterBar = inject(KbqFilterBar, { optional: true }); + /** KbqFilterBar host seam */ + private readonly filterBar = inject(KBQ_FILTER_BAR_HOST, { optional: true }); /** localized data * @docs-private */ diff --git a/packages/components/filter-bar/filter-reset.ts b/packages/components/filter-bar/filter-reset.ts index b954e44d75..e523ed4162 100644 --- a/packages/components/filter-bar/filter-reset.ts +++ b/packages/components/filter-bar/filter-reset.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { KbqFilterBar } from './filter-bar'; -import { KbqFilter } from './filter-bar.types'; +import { KBQ_FILTER_BAR_HOST, KbqFilter } from './filter-bar.types'; @Component({ selector: 'kbq-filter-reset', @@ -17,8 +16,8 @@ import { KbqFilter } from './filter-bar.types'; } }) export class KbqFilterReset { - /** KbqFilterBar instance */ - private readonly filterBar = inject(KbqFilterBar); + /** KbqFilterBar host seam */ + private readonly filterBar = inject(KBQ_FILTER_BAR_HOST); /** Event that is generated whenever the user reset a filter. */ readonly onResetFilter = output(); diff --git a/packages/components/filter-bar/pipe-add.ts b/packages/components/filter-bar/pipe-add.ts index 16fca90cf3..b7da08a58e 100644 --- a/packages/components/filter-bar/pipe-add.ts +++ b/packages/components/filter-bar/pipe-add.ts @@ -6,8 +6,7 @@ import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIcon } from '@koobiq/components/icon'; import { KbqSelect, KbqSelectModule } from '@koobiq/components/select'; import { KbqToolTipModule } from '@koobiq/components/tooltip'; -import { KbqFilterBar } from './filter-bar'; -import { KbqFilter, KbqPipe, KbqPipeTemplate } from './filter-bar.types'; +import { KBQ_FILTER_BAR_HOST, KbqFilter, KbqPipe, KbqPipeTemplate } from './filter-bar.types'; import { getId } from './pipes/base-pipe'; @Component({ @@ -58,7 +57,7 @@ import { getId } from './pipes/base-pipe'; }) export class KbqPipeAdd { /** KbqFilterBar instance */ - protected readonly filterBar = inject(KbqFilterBar); + protected readonly filterBar = inject(KBQ_FILTER_BAR_HOST); /** @docs-private */ readonly select = viewChild.required(KbqSelect); diff --git a/packages/components/filter-bar/pipes/base-pipe.ts b/packages/components/filter-bar/pipes/base-pipe.ts index 12843d564f..ce4c27184c 100644 --- a/packages/components/filter-bar/pipes/base-pipe.ts +++ b/packages/components/filter-bar/pipes/base-pipe.ts @@ -15,9 +15,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isMac } from '@koobiq/components/core'; import { Subject } from 'rxjs'; import { delay, filter } from 'rxjs/operators'; -import { KbqFilterBar } from '../filter-bar'; import { KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, + KBQ_FILTER_BAR_HOST, KbqFilterBarConfiguration, KbqPipeData, KbqPipeTemplate, @@ -50,7 +50,7 @@ export abstract class KbqBasePipe implements AfterViewInit { /** KbqFilterBar instance * @docs-private */ - protected readonly filterBar = inject(KbqFilterBar, { optional: true }); + protected readonly filterBar = inject(KBQ_FILTER_BAR_HOST, { optional: true }); /** @docs-private */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); /** @docs-private */ @@ -232,7 +232,7 @@ export abstract class KbqBasePipe implements AfterViewInit { }) export class KbqPipeMinWidth { /** KbqFilterBar instance */ - protected readonly filterBar = inject(KbqFilterBar, { optional: true }); + protected readonly filterBar = inject(KBQ_FILTER_BAR_HOST, { optional: true }); /** @docs-private */ protected readonly elementRef = inject>(ElementRef); diff --git a/packages/components/filter-bar/pipes/multi-select-pipe-state.ts b/packages/components/filter-bar/pipes/multi-select-pipe-state.ts new file mode 100644 index 0000000000..36870051f8 --- /dev/null +++ b/packages/components/filter-bar/pipes/multi-select-pipe-state.ts @@ -0,0 +1,59 @@ +import { KbqFilterBarHost, KbqPipeData, KbqSelectValue } from '../filter-bar.types'; + +/** The pieces {@link KbqMultiSelectPipeState} reads from its owning multi-select pipe. */ +export interface KbqMultiSelectPipeStateHost { + readonly data: KbqPipeData; + readonly filterBar: KbqFilterBarHost | null; + /** Whether every selectable option is currently selected (pipe-specific). */ + allOptionsSelected(): boolean; +} + +/** + * Shared "select all = select nothing" state for the multi-select pipes (`kbq-pipe-multi-select`, + * `kbq-pipe-multi-tree-select`). Extracted as a plain composition helper — not a base class — because the + * two pipes have different bases (`KbqBasePipe` vs `KbqTreeSelectPipeBase`) and cannot share a single + * `@Directive()` base (the diamond), while a mixin would risk breaking AOT view-query inheritance. + */ +export class KbqMultiSelectPipeState { + /** Snapshot of the committed selection, preserved across the "all selected → []" collapse. */ + internalSelected: KbqSelectValue[] | null = null; + + constructor(private readonly host: KbqMultiSelectPipeStateHost) {} + + /** Whether selecting every option is treated as selecting nothing. */ + get selectedAllEqualsSelectedNothing(): boolean { + return ( + this.host.data.selectedAllEqualsSelectedNothing ?? this.host.filterBar!.selectedAllEqualsSelectedNothing() + ); + } + + /** Value to display: the preserved snapshot under "all = nothing", otherwise the raw value. */ + get selected(): KbqSelectValue[] | null { + return this.selectedAllEqualsSelectedNothing ? this.internalSelected : this.host.data.value; + } + + /** Refreshes the snapshot from the current value (only under "all = nothing"). */ + updateInternalSelected(): void { + if (this.selectedAllEqualsSelectedNothing) { + this.internalSelected = this.host.data.value?.slice() || []; + } + } + + /** Emits the change, collapsing the value to `[]` when everything is selected under "all = nothing". */ + emitChangePipeEvent(): void { + if (this.selectedAllEqualsSelectedNothing && this.host.allOptionsSelected()) { + this.host.filterBar?.onChangePipe.emit({ ...this.host.data, value: [] }); + } else { + this.host.filterBar?.onChangePipe.emit(this.host.data); + } + } + + /** Whether the pipe reads as empty, given the base pipe's own emptiness. */ + isEmpty(baseIsEmpty: boolean): boolean { + return ( + baseIsEmpty || + (Array.isArray(this.host.data.value) && !this.host.data.value.length) || + (this.selectedAllEqualsSelectedNothing && this.host.allOptionsSelected()) + ); + } +} diff --git a/packages/components/filter-bar/pipes/pipe-button.ts b/packages/components/filter-bar/pipes/pipe-button.ts index fda87547cc..14c74c5182 100644 --- a/packages/components/filter-bar/pipes/pipe-button.ts +++ b/packages/components/filter-bar/pipes/pipe-button.ts @@ -12,7 +12,7 @@ import { KbqButtonModule } from '@koobiq/components/button'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { merge } from 'rxjs'; -import { KbqFilterBar } from '../filter-bar'; +import { KBQ_FILTER_BAR_HOST } from '../filter-bar.types'; import { KbqBasePipe } from './base-pipe'; import { KbqPipeState } from './pipe-state'; @@ -49,7 +49,7 @@ export class KbqPipeButton { /** KbqPipe instance */ protected readonly pipe = inject(KbqBasePipe); /** KbqFilterBar instance */ - protected readonly filterBar = inject(KbqFilterBar); + protected readonly filterBar = inject(KBQ_FILTER_BAR_HOST); /** @docs-private */ protected readonly changeDetectorRef = inject(ChangeDetectorRef); diff --git a/packages/components/filter-bar/pipes/pipe-multi-select.ts b/packages/components/filter-bar/pipes/pipe-multi-select.ts index a6a244ee1d..ca914bf434 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-select.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-select.ts @@ -22,6 +22,7 @@ import { merge, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { KbqSelectValue } from '../filter-bar.types'; import { KbqBasePipe } from './base-pipe'; +import { KbqMultiSelectPipeState } from './multi-select-pipe-state'; import { KbqPipeButton } from './pipe-button'; import { KbqPipeState } from './pipe-state'; @@ -68,20 +69,12 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i /** selected value */ get selected() { - if (this.selectedAllEqualsSelectedNothing) { - return this.internalSelected; - } - - return this.data.value; + return this.multiSelect.selected; } /** Whether the current pipe is empty. */ get isEmpty(): boolean { - return ( - super.isEmpty || - (Array.isArray(this.data.value) && !this.data.value.length) || - (this.selectedAllEqualsSelectedNothing && this.allOptionsSelected) - ); + return this.multiSelect.isEmpty(super.isEmpty); } /** state for checkbox 'select all'. */ @@ -116,7 +109,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i } get selectedAllEqualsSelectedNothing(): boolean { - return this.data.selectedAllEqualsSelectedNothing ?? this.filterBar!.selectedAllEqualsSelectedNothing(); + return this.multiSelect.selectedAllEqualsSelectedNothing; } private get visibleOptions(): KbqOption[] { @@ -124,11 +117,15 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i } private selectionAllInProgress = false; - private internalSelected: KbqSelectValue[] | null; + private readonly multiSelect = new KbqMultiSelectPipeState({ + data: this.data, + filterBar: this.filterBar, + allOptionsSelected: () => this.allOptionsSelected + }); /** @docs-private */ ngOnInit(): void { - this.updateInternalSelected(); + this.multiSelect.updateInternalSelected(); this.filteredOptions = merge(this.filterBar!.internalTemplatesChanges, this.searchControl.valueChanges).pipe( map(this.getFilteredOptions), @@ -154,7 +151,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i this.data.value = item; } - this.emitChangePipeEvent(); + this.multiSelect.emitChangePipeEvent(); this.stateChanges.next(); } @@ -163,7 +160,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i onClear() { this.data.value = []; - this.updateInternalSelected(); + this.multiSelect.updateInternalSelected(); this.filterBar?.onClearPipe.emit(this.data); this.filterBar?.onChangePipe.emit(this.data); @@ -196,7 +193,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i } if (emitEvent) { - this.emitChangePipeEvent(); + this.multiSelect.emitChangePipeEvent(); } this.stateChanges.next(); @@ -216,7 +213,7 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i /** @docs-private */ onClose() { if (this.allOptionsSelected) { - this.updateInternalSelected(); + this.multiSelect.updateInternalSelected(); } setTimeout(() => this.restoreTriggerFocus()); @@ -227,20 +224,6 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i this.select().open(); } - private updateInternalSelected() { - if (this.selectedAllEqualsSelectedNothing) { - this.internalSelected = this.data.value?.slice() || []; - } - } - - private emitChangePipeEvent() { - if (this.selectedAllEqualsSelectedNothing && this.allOptionsSelected) { - this.filterBar?.onChangePipe.emit({ ...this.data, value: [] }); - } else { - this.filterBar?.onChangePipe.emit(this.data); - } - } - private getFilteredOptions = (): KbqSelectValue[] => { const search = this.searchControl.value; diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.html b/packages/components/filter-bar/pipes/pipe-multi-tree-select.html index 1daf1f6100..023f0b91b8 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.html +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.html @@ -72,11 +72,7 @@ - + diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts index 1c9100dac3..c514e16702 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.spec.ts @@ -570,7 +570,7 @@ describe('KbqPipeMultiTreeSelectComponent', () => { })); }); - describe('isNodeHasChild / isNodeSelectAll', () => { + describe('hasChild / isNodeSelectAll', () => { beforeEach(() => { fixture = TestBed.createComponent(TestComponent); filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar)); @@ -584,14 +584,14 @@ describe('KbqPipeMultiTreeSelectComponent', () => { const component = getPipeComponent(); const expandableNode = { expandable: true, name: 'parent', value: 'v', level: 0 }; - expect(component.isNodeHasChild(0, expandableNode)).toBe(true); + expect(component.hasChild(0, expandableNode)).toBe(true); }); it('should return false for leaf nodes', () => { const component = getPipeComponent(); const leafNode = { expandable: false, name: 'leaf', value: 'v', level: 1 }; - expect(component.isNodeHasChild(0, leafNode)).toBe(false); + expect(component.hasChild(0, leafNode)).toBe(false); }); it('should return true for selectAll node', () => { diff --git a/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts b/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts index 8456d2b119..3639a73e27 100644 --- a/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts +++ b/packages/components/filter-bar/pipes/pipe-multi-tree-select.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqHighlightModule, KbqPseudoCheckboxModule, KbqPseudoCheckboxState } from '@koobiq/components/core'; @@ -8,19 +8,14 @@ import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; import { KbqTitleModule } from '@koobiq/components/title'; -import { - FlatTreeControl, - KbqTreeFlatDataSource, - KbqTreeFlattener, - KbqTreeModule, - kbqTreeSelectAllValue, - KbqTreeSelection -} from '@koobiq/components/tree'; -import { KbqTreeSelect, KbqTreeSelectModule } from '@koobiq/components/tree-select'; +import { KbqTreeModule, kbqTreeSelectAllValue, KbqTreeSelection } from '@koobiq/components/tree'; +import { KbqTreeSelectModule } from '@koobiq/components/tree-select'; import { KbqPipeTemplate, KbqSelectValue, KbqTreeSelectFlatNode, KbqTreeSelectNode } from '../filter-bar.types'; import { getId, KbqBasePipe, KbqPipeMinWidth } from './base-pipe'; +import { KbqMultiSelectPipeState } from './multi-select-pipe-state'; import { KbqPipeButton } from './pipe-button'; import { KbqPipeState } from './pipe-state'; +import { KbqTreeSelectPipeBase } from './tree-select-pipe-base'; @Component({ selector: 'kbq-pipe-multi-tree-select', @@ -52,37 +47,18 @@ import { KbqPipeState } from './pipe-state'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe implements OnInit, AfterViewInit { - /** control for search options */ - readonly searchControl = new FormControl(null); - - treeControl: FlatTreeControl; - treeFlattener: KbqTreeFlattener; - - dataSource: KbqTreeFlatDataSource; - - /** @docs-private */ - readonly select = viewChild.required(KbqTreeSelect); - +export class KbqPipeMultiTreeSelectComponent extends KbqTreeSelectPipeBase implements OnInit { /** @docs-private */ readonly tree = viewChild.required(KbqTreeSelection); /** selected value */ get selected() { - if (this.selectedAllEqualsSelectedNothing) { - return this.internalSelected; - } - - return this.data.value; + return this.multiSelect.selected; } /** Whether the current pipe is empty. */ get isEmpty(): boolean { - return ( - super.isEmpty || - (Array.isArray(this.data.value) && !this.data.value.length) || - (this.selectedAllEqualsSelectedNothing && this.allOptionsSelected) - ); + return this.multiSelect.isEmpty(super.isEmpty); } get selectAllCheckboxState(): KbqPseudoCheckboxState { @@ -112,7 +88,7 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe option.selected); } - private internalSelected: KbqSelectValue[] | null; + private readonly multiSelect = new KbqMultiSelectPipeState({ + data: this.data, + filterBar: this.filterBar, + allOptionsSelected: () => this.allOptionsSelected + }); constructor() { super(); - this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); - - this.treeControl = new FlatTreeControl( - this.getLevel, - this.isExpandable, - this.getValue, - this.getViewValue - ); - - this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); - + // See the field-init note in `KbqTreeSelectPipeBase`: subscribing here (after this class's + // `updateTemplates` initializer) ensures the initial replay writes `dataSource.data`. this.filterBar?.internalTemplatesChanges.pipe(takeUntilDestroyed()).subscribe(this.updateTemplates); } - ngOnInit(): void { - this.updateInternalSelected(); + override ngOnInit(): void { + super.ngOnInit(); - this.searchControl.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((value) => this.treeControl.filterNodes(value)); - } - - override ngAfterViewInit() { - super.ngAfterViewInit(); - - this.select() - .closedStream.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.filterBar?.onClosePipe.emit(this.data)); - } - - isNodeHasChild(_: number, nodeData: KbqTreeSelectFlatNode) { - return nodeData.expandable; + this.multiSelect.updateInternalSelected(); } isNodeSelectAll(_: number, nodeData: KbqTreeSelectFlatNode) { @@ -185,7 +142,7 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe this.select().open()); - } - override onClear() { super.onClear(); - this.updateInternalSelected(); - } - - /** @docs-private */ - onOpen() { - this.treeControl.expandAll(); + this.multiSelect.updateInternalSelected(); } /** @docs-private */ onClose() { if (this.allOptionsSelected) { - this.updateInternalSelected(); + this.multiSelect.updateInternalSelected(); } setTimeout(() => this.restoreTriggerFocus()); @@ -289,20 +236,6 @@ export class KbqPipeMultiTreeSelectComponent extends KbqBasePipe { - const flatNode = new KbqTreeSelectFlatNode(); - - flatNode.name = node.name; - flatNode.value = node.value; - flatNode.parent = parent ?? undefined; - flatNode.level = level; - flatNode.expandable = !!node.children; - - return flatNode; - }; - - private getLevel = (node: KbqTreeSelectFlatNode) => { - return node.level; - }; - - private isExpandable = (node: KbqTreeSelectFlatNode) => { - return node.expandable; - }; - - private getChildren = (node: KbqTreeSelectNode): KbqTreeSelectNode[] | null => { - return node.children; - }; - - private getValue = (node: KbqTreeSelectFlatNode): unknown => { - return node.value; - }; - - private getViewValue = (node: KbqTreeSelectFlatNode): string => { - return `${node.name}`; - }; } diff --git a/packages/components/filter-bar/pipes/pipe-state.ts b/packages/components/filter-bar/pipes/pipe-state.ts index 37f54ee624..77193b657a 100644 --- a/packages/components/filter-bar/pipes/pipe-state.ts +++ b/packages/components/filter-bar/pipes/pipe-state.ts @@ -2,7 +2,7 @@ import { DestroyRef, Directive, inject, Input, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; -import { KbqFilterBar } from '../filter-bar'; +import { KBQ_FILTER_BAR_HOST } from '../filter-bar.types'; import { KbqBasePipe } from './base-pipe'; @Directive({ @@ -17,7 +17,7 @@ export class KbqPipeState implements OnInit { private readonly pipe = inject(KbqBasePipe); /** KbqFilterBar instance * @docs-private */ - private readonly filterBar = inject(KbqFilterBar); + private readonly filterBar = inject(KBQ_FILTER_BAR_HOST); /** calculates and updates styles of button from pipe state */ // TODO: Skipped for migration because: diff --git a/packages/components/filter-bar/pipes/pipe-tree-select.ts b/packages/components/filter-bar/pipes/pipe-tree-select.ts index 4ee20fc1e6..0679996b7e 100644 --- a/packages/components/filter-bar/pipes/pipe-tree-select.ts +++ b/packages/components/filter-bar/pipes/pipe-tree-select.ts @@ -1,24 +1,19 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit, viewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqButtonModule } from '@koobiq/components/button'; import { KbqHighlightModule } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqInputModule } from '@koobiq/components/input'; import { KbqTitleModule } from '@koobiq/components/title'; -import { - FlatTreeControl, - KbqTreeFlatDataSource, - KbqTreeFlattener, - KbqTreeModule, - KbqTreeOption -} from '@koobiq/components/tree'; -import { KbqTreeSelect, KbqTreeSelectModule } from '@koobiq/components/tree-select'; -import { KbqPipeTemplate, KbqSelectValue, KbqTreeSelectFlatNode, KbqTreeSelectNode } from '../filter-bar.types'; +import { KbqTreeModule, KbqTreeOption } from '@koobiq/components/tree'; +import { KbqTreeSelectModule } from '@koobiq/components/tree-select'; +import { KbqPipeTemplate, KbqSelectValue, KbqTreeSelectNode } from '../filter-bar.types'; import { getId, KbqBasePipe, KbqPipeMinWidth } from './base-pipe'; import { KbqPipeButton } from './pipe-button'; import { KbqPipeState } from './pipe-state'; +import { KbqTreeSelectPipeBase } from './tree-select-pipe-base'; @Component({ selector: 'kbq-pipe-tree-select', @@ -48,18 +43,7 @@ import { KbqPipeState } from './pipe-state'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class KbqPipeTreeSelectComponent extends KbqBasePipe implements OnInit, AfterViewInit { - /** control for search options */ - readonly searchControl = new FormControl(null); - - treeControl: FlatTreeControl; - treeFlattener: KbqTreeFlattener; - - dataSource: KbqTreeFlatDataSource; - - /** @docs-private */ - readonly select = viewChild.required(KbqTreeSelect); - +export class KbqPipeTreeSelectComponent extends KbqTreeSelectPipeBase { /** selected value */ get selected() { return this.data.value; @@ -73,38 +57,11 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl constructor() { super(); - this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); - - this.treeControl = new FlatTreeControl( - this.getLevel, - this.isExpandable, - this.getValue, - this.getViewValue - ); - - this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); - + // See the field-init note in `KbqTreeSelectPipeBase`: subscribing here (after this class's + // `updateTemplates` initializer) ensures the initial replay writes `dataSource.data`. this.filterBar?.internalTemplatesChanges.pipe(takeUntilDestroyed()).subscribe(this.updateTemplates); } - ngOnInit(): void { - this.searchControl.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((value) => this.treeControl.filterNodes(value)); - } - - override ngAfterViewInit() { - super.ngAfterViewInit(); - - this.select() - .closedStream.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.filterBar?.onClosePipe.emit(this.data)); - } - - hasChild(_: number, nodeData: KbqTreeSelectFlatNode) { - return nodeData.expandable; - } - onSelect(item: KbqTreeOption) { this.data.value = item.value; this.filterBar?.onChangePipe.emit(this.data); @@ -125,45 +82,4 @@ export class KbqPipeTreeSelectComponent extends KbqBasePipe impl this.dataSource.data = template.values as KbqTreeSelectNode[]; } }; - - /** opens select */ - override open() { - setTimeout(() => this.select().open()); - } - - onOpen() { - this.treeControl.expandAll(); - } - - private transformer = (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => { - const flatNode = new KbqTreeSelectFlatNode(); - - flatNode.name = node.name; - flatNode.value = node.value; - flatNode.parent = parent ?? undefined; - flatNode.level = level; - flatNode.expandable = !!node.children; - - return flatNode; - }; - - private getLevel = (node: KbqTreeSelectFlatNode) => { - return node.level; - }; - - private isExpandable = (node: KbqTreeSelectFlatNode) => { - return node.expandable; - }; - - private getChildren = (node: KbqTreeSelectNode): KbqTreeSelectNode[] | null => { - return node.children; - }; - - private getValue = (node: KbqTreeSelectFlatNode): unknown => { - return node.value; - }; - - private getViewValue = (node: KbqTreeSelectFlatNode): string => { - return `${node.name}`; - }; } diff --git a/packages/components/filter-bar/pipes/tree-select-pipe-base.ts b/packages/components/filter-bar/pipes/tree-select-pipe-base.ts new file mode 100644 index 0000000000..30051ca63c --- /dev/null +++ b/packages/components/filter-bar/pipes/tree-select-pipe-base.ts @@ -0,0 +1,96 @@ +import { AfterViewInit, Directive, OnInit, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { FlatTreeControl, KbqTreeFlatDataSource, KbqTreeFlattener } from '@koobiq/components/tree'; +import { KbqTreeSelect } from '@koobiq/components/tree-select'; +import { KbqTreeSelectFlatNode, KbqTreeSelectNode } from '../filter-bar.types'; +import { KbqBasePipe } from './base-pipe'; + +/** + * Shared scaffolding for the tree-based select pipes (`kbq-pipe-tree-select`, + * `kbq-pipe-multi-tree-select`): the flat-tree control / flattener / data source, the node accessors, + * search-driven filtering and open handling. Subclasses provide the value-specific pieces (`selected`, + * `isEmpty`, `onSelect`, `updateTemplates`). + * + * The `internalTemplatesChanges` subscription is intentionally left in each subclass constructor: it must + * be set up after the subclass's `updateTemplates` field initializer runs, so the initial `BehaviorSubject` + * replay dispatches to the subclass override (which writes `dataSource.data`) rather than the base one. + */ +@Directive() +export abstract class KbqTreeSelectPipeBase extends KbqBasePipe implements OnInit, AfterViewInit { + /** control for search options */ + readonly searchControl = new FormControl(null); + + treeControl: FlatTreeControl; + treeFlattener: KbqTreeFlattener; + dataSource: KbqTreeFlatDataSource; + + /** @docs-private */ + readonly select = viewChild.required(KbqTreeSelect); + + constructor() { + super(); + + this.treeFlattener = new KbqTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); + + this.treeControl = new FlatTreeControl( + this.getLevel, + this.isExpandable, + this.getValue, + this.getViewValue + ); + + this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); + } + + ngOnInit(): void { + this.searchControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.treeControl.filterNodes(value)); + } + + override ngAfterViewInit() { + super.ngAfterViewInit(); + + this.select() + .closedStream.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.filterBar?.onClosePipe.emit(this.data)); + } + + /** tree-node predicate: whether the node has children */ + hasChild(_: number, nodeData: KbqTreeSelectFlatNode) { + return nodeData.expandable; + } + + /** opens the tree-select panel */ + override open() { + setTimeout(() => this.select().open()); + } + + /** @docs-private */ + onOpen() { + this.treeControl.expandAll(); + } + + protected transformer = (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => { + const flatNode = new KbqTreeSelectFlatNode(); + + flatNode.name = node.name; + flatNode.value = node.value; + flatNode.parent = parent ?? undefined; + flatNode.level = level; + flatNode.expandable = !!node.children; + + return flatNode; + }; + + protected getLevel = (node: KbqTreeSelectFlatNode) => node.level; + + protected isExpandable = (node: KbqTreeSelectFlatNode) => node.expandable; + + protected getChildren = (node: KbqTreeSelectNode): KbqTreeSelectNode[] | null => node.children; + + protected getValue = (node: KbqTreeSelectFlatNode): unknown => node.value; + + protected getViewValue = (node: KbqTreeSelectFlatNode): string => `${node.name}`; +} diff --git a/packages/components/filter-bar/public-api.ts b/packages/components/filter-bar/public-api.ts index e93c6cc9a1..9a351d17e9 100644 --- a/packages/components/filter-bar/public-api.ts +++ b/packages/components/filter-bar/public-api.ts @@ -18,3 +18,4 @@ export * from './pipes/pipe-select'; export * from './pipes/pipe-state'; export * from './pipes/pipe-text'; export * from './pipes/pipe-tree-select'; +export * from './pipes/tree-select-pipe-base'; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 9c7f153bf1..3e39004898 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -35,8 +35,10 @@ import { KbqTreeSelect } from '@koobiq/components/tree-select'; import * as _koobiq_components_core from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnInit } from '@angular/core'; +import { OutputEmitterRef } from '@angular/core'; import { PopUpPlacements } from '@koobiq/components/core'; import { Provider } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { Type } from '@angular/core'; @@ -147,6 +149,9 @@ export const KBQ_FILTER_BAR_DEFAULT_CONFIGURATION: { }; }; +// @public +export const KBQ_FILTER_BAR_HOST: InjectionToken; + // @public export const KBQ_FILTER_BAR_PIPES: InjectionToken>>>; @@ -162,7 +167,7 @@ export abstract class KbqBasePipe implements AfterViewInit { protected destroyed: boolean; protected readonly destroyRef: DestroyRef; protected readonly elementRef: ElementRef; - protected readonly filterBar: KbqFilterBar | null; + protected readonly filterBar: KbqFilterBarHost | null; protected readonly focusMonitor: FocusMonitor; get isEmpty(): boolean; isMac: boolean; @@ -215,7 +220,7 @@ export interface KbqFilter { } // @public (undocumented) -export class KbqFilterBar { +export class KbqFilterBar implements KbqFilterBarHost { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; readonly changes: BehaviorSubject; @@ -315,6 +320,29 @@ export class KbqFilterBarButton { // @public export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURATION; +// @public +export interface KbqFilterBarHost { + readonly changes: BehaviorSubject; + configuration: KbqFilterBarConfiguration; + filter: KbqFilter | null; + readonly filterChange: OutputEmitterRef; + readonly internalFilterChanges: BehaviorSubject; + readonly internalTemplatesChanges: BehaviorSubject; + readonly isChanged: boolean; + readonly isReadOnly: boolean; + readonly isSaved: boolean; + readonly isSavedAndChanged: boolean; + readonly onChangePipe: OutputEmitterRef; + readonly onClearPipe: OutputEmitterRef; + readonly onClosePipe: OutputEmitterRef; + readonly onResetFilter: BehaviorSubject; + readonly openPipe: BehaviorSubject; + pipeTemplates: KbqPipeTemplate[]; + removePipe(pipe: KbqPipe): void; + resetFilterChangedState(): void; + readonly selectedAllEqualsSelectedNothing: Signal; +} + // @public (undocumented) export class KbqFilterBarModule { // (undocumented) @@ -528,7 +556,7 @@ export class KbqPipeAdd { // (undocumented) addPipeFromTemplate(option: KbqOption): void; compareWith(o1: KbqPipe, o2: string): boolean; - protected readonly filterBar: KbqFilterBar; + protected readonly filterBar: KbqFilterBarHost; readonly filterTemplate: i0.InputSignal; readonly onAddPipe: i0.OutputEmitterRef; readonly select: i0.Signal; @@ -542,7 +570,7 @@ export class KbqPipeAdd { export class KbqPipeButton { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; - protected readonly filterBar: KbqFilterBar; + protected readonly filterBar: KbqFilterBarHost; get localeData(): { clearButtonTooltip: string; removeButtonTooltip: string; @@ -663,7 +691,7 @@ export class KbqPipeMinWidth { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; protected readonly elementRef: ElementRef; - protected readonly filterBar: KbqFilterBar | null; + protected readonly filterBar: KbqFilterBarHost | null; maxSymbolsForFitContent: number; protected minWidth: string; get textLength(): number; @@ -778,29 +806,12 @@ export class KbqPipeTextComponent extends KbqBasePipe implements } // @public (undocumented) -export class KbqPipeTreeSelectComponent extends KbqBasePipe implements OnInit, AfterViewInit { +export class KbqPipeTreeSelectComponent extends KbqTreeSelectPipeBase { constructor(); - // (undocumented) - dataSource: KbqTreeFlatDataSource; - // (undocumented) - hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean; get isEmpty(): boolean; // (undocumented) - ngAfterViewInit(): void; - // (undocumented) - ngOnInit(): void; - // (undocumented) - onOpen(): void; - // (undocumented) onSelect(item: KbqTreeOption): void; - open(): void; - readonly searchControl: FormControl; - readonly select: i0.Signal; get selected(): KbqSelectValue | null; - // (undocumented) - treeControl: FlatTreeControl; - // (undocumented) - treeFlattener: KbqTreeFlattener; updateTemplates: (templates: KbqPipeTemplate[] | null) => void; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; @@ -892,6 +903,42 @@ export interface KbqTreeSelectNode { value: unknown; } +// @public +export abstract class KbqTreeSelectPipeBase extends KbqBasePipe implements OnInit, AfterViewInit { + constructor(); + // (undocumented) + dataSource: KbqTreeFlatDataSource; + // (undocumented) + protected getChildren: (node: KbqTreeSelectNode) => KbqTreeSelectNode[] | null; + // (undocumented) + protected getLevel: (node: KbqTreeSelectFlatNode) => number; + // (undocumented) + protected getValue: (node: KbqTreeSelectFlatNode) => unknown; + // (undocumented) + protected getViewValue: (node: KbqTreeSelectFlatNode) => string; + hasChild(_: number, nodeData: KbqTreeSelectFlatNode): boolean; + // (undocumented) + protected isExpandable: (node: KbqTreeSelectFlatNode) => boolean; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + ngOnInit(): void; + onOpen(): void; + open(): void; + readonly searchControl: FormControl; + readonly select: i0.Signal; + // (undocumented) + protected transformer: (node: KbqTreeSelectNode, level: number, parent: KbqTreeSelectFlatNode | null) => KbqTreeSelectFlatNode; + // (undocumented) + treeControl: FlatTreeControl; + // (undocumented) + treeFlattener: KbqTreeFlattener; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration, never, never, {}, {}, never, never, true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + // (No @packageDocumentation comment for this package) ``` From 26acdca2ef3aa04de64ebb1fcc33e21edfa6c1bb Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 15:16:46 +0300 Subject: [PATCH 06/16] fix: P2-21 --- .../filter-bar/filter-save-popover.ts | 268 ++++++++++++++++++ packages/components/filter-bar/filters.html | 84 +----- packages/components/filter-bar/filters.ts | 203 ++++++------- packages/components/filter-bar/public-api.ts | 1 + .../components/filter-bar.api.md | 83 ++++-- 5 files changed, 439 insertions(+), 200 deletions(-) create mode 100644 packages/components/filter-bar/filter-save-popover.ts diff --git a/packages/components/filter-bar/filter-save-popover.ts b/packages/components/filter-bar/filter-save-popover.ts new file mode 100644 index 0000000000..18ceb95745 --- /dev/null +++ b/packages/components/filter-bar/filter-save-popover.ts @@ -0,0 +1,268 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + inject, + input, + output, + TemplateRef, + viewChild, + ViewEncapsulation +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { KbqAlertModule } from '@koobiq/components/alert'; +import { KbqButton, KbqButtonModule } from '@koobiq/components/button'; +import { KbqFormsModule } from '@koobiq/components/core'; +import { KbqInputModule } from '@koobiq/components/input'; +import { KbqPopoverTrigger } from '@koobiq/components/popover'; +import { merge } from 'rxjs'; +import { distinctUntilChanged, filter } from 'rxjs/operators'; +import { KbqFilterBar } from './filter-bar'; +import { KbqFilter, KbqSaveFilterError, KbqSaveFilterEvent, KbqSaveFilterStatuses } from './filter-bar.types'; + +/** + * The "save / rename filter" popover, extracted from `KbqFilters` (SRP). Owns the save form state, the + * name control, validation/error display and the save/rename flow. It declares the popover's header, + * content and footer templates and pushes them imperatively onto the trigger it is given (via the + * `popoverTrigger` input), so there is no cross-component template binding on the parent (which would + * otherwise risk `ExpressionChangedAfterItHasBeenCheckedError`). `KbqFilters` keeps the trigger (on its + * main button), focus management and the public `filterSavedSuccessfully`/`filterSavedUnsuccessfully` API, + * delegating to this component. + * + * @docs-private + */ +@Component({ + selector: 'kbq-filter-save-popover', + imports: [ReactiveFormsModule, KbqButtonModule, KbqFormsModule, KbqInputModule, KbqAlertModule], + template: ` + {{ popoverHeader }} + + + @if (showFilterSavingError) { + + {{ filterSavingErrorText }} + + } +
+
+ + + + + + @if (filterName.hasError('filterNameAlreadyExist')) { + {{ localeData.error }} + } + +
+
+
+ + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class KbqFilterSavePopover implements AfterViewInit { + private readonly destroyRef = inject(DestroyRef); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + + /** Popover trigger owned by the parent (`KbqFilters` main button). */ + readonly popoverTrigger = input.required(); + /** Filter bar, used for the save payload, the current filter and localized strings. */ + readonly filterBar = input.required(); + + /** Emits when the user saves/renames a filter. */ + readonly save = output(); + /** Emits when the popover closes; the boolean requests focus restoration by the parent. */ + readonly closed = output(); + + /** @docs-private */ + readonly headerTemplate = viewChild.required>('header'); + /** @docs-private */ + readonly contentTemplate = viewChild.required>('content'); + /** @docs-private */ + readonly footerTemplate = viewChild.required>('footer'); + + private readonly newFilterName = viewChild.required('newFilterName'); + private readonly saveFilterButton = viewChild.required('saveFilterButton'); + + /** new filter name for saving */ + filterName: FormControl = new FormControl(null); + + /** true if saving a new filter, false if saving changes in filter */ + saveNewFilter: boolean = false; + + showFilterSavingError: boolean = false; + filterSavingErrorText: string = ''; + + isSaving: boolean = false; + + /** localized data + * @docs-private */ + get localeData() { + return this.filterBar().configuration.filters; + } + + /** header of popover. Depends on the mode */ + get popoverHeader(): string { + return this.saveNewFilter ? this.localeData.saveAsNew : this.localeData.saveChanges; + } + + ngAfterViewInit(): void { + // Push the templates onto the parent's trigger imperatively (no parent-side binding). + const trigger = this.popoverTrigger(); + + trigger.header = this.headerTemplate(); + trigger.content = this.contentTemplate(); + trigger.footer = this.footerTemplate(); + } + + openSaveAsNewFilterPopover() { + this.saveNewFilter = true; + + this.preparePopover(); + } + + openChangeFilterNamePopover() { + this.saveNewFilter = false; + + this.preparePopover(); + } + + saveAsNew(event?: Event) { + if (this.filterName.invalid) return; + + const name = this.filterName.value || ''; + + // @todo default filter + const filter = structuredClone(this.filterBar().filter as KbqFilter) || { pipes: [] }; + + filter.name = name; + filter.saved = true; + filter.changed = false; + + this.isSaving = true; + this.popoverTrigger().preventClose = true; + this.filterName.disable(); + + if (this.saveNewFilter) { + this.save.emit({ filter, filterBar: this.filterBar(), status: KbqSaveFilterStatuses.NewFilter }); + } else { + this.save.emit({ filter, filterBar: this.filterBar(), status: KbqSaveFilterStatuses.NewName }); + } + + event?.preventDefault(); + } + + showError(error?: KbqSaveFilterError) { + if (error?.nameAlreadyExists) { + this.filterName.setErrors({ filterNameAlreadyExist: true }); + } + + this.showFilterSavingError = true; + + this.filterSavingErrorText = error?.text ?? this.filterBar().configuration.filters.errorHint; + } + + close = (restoreFocus: boolean = true) => { + this.popoverTrigger().hide(); + + this.closed.emit(restoreFocus); + + setTimeout(() => this.changeDetectorRef.detectChanges()); + + this.showFilterSavingError = false; + }; + + /** Hide the popover on a successful save. Focus restoration is handled by the parent. */ + savedSuccessfully() { + this.isSaving = false; + this.popoverTrigger().preventClose = false; + + this.popoverTrigger().hide(); + + this.changeDetectorRef.markForCheck(); + } + + /** Release the saving state after a failed save (the error itself is shown by the host via `showError`). */ + savedUnsuccessfully() { + this.isSaving = false; + this.popoverTrigger().preventClose = false; + + this.filterName.enable(); + setTimeout(() => this.saveFilterButton().focus()); + + this.changeDetectorRef.markForCheck(); + } + + private preparePopover() { + this.filterName = new FormControl(this.filterBar().filter?.name || '', Validators.required); + + this.filterName.valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => (this.showFilterSavingError = false)); + + const trigger = this.popoverTrigger(); + + trigger.show(); + + merge(...trigger.defaultClosingActions()) + .pipe( + filter(() => !this.isSaving), + takeUntilDestroyed(trigger.instanceDestroyRef) + ) + .subscribe(() => this.close(false)); + + trigger.visibleChange + .pipe( + filter((state) => !state), + takeUntilDestroyed(trigger.instanceDestroyRef) + ) + .subscribe(this.close); + + setTimeout(() => { + this.newFilterName().nativeElement.focus(); + this.filterName.setErrors(null); + }); + } +} diff --git a/packages/components/filter-bar/filters.html b/packages/components/filter-bar/filters.html index 0682686855..643f93a3e4 100644 --- a/packages/components/filter-bar/filters.html +++ b/packages/components/filter-bar/filters.html @@ -1,5 +1,6 @@ @@ -58,7 +56,7 @@ [attr.aria-label]="localeData.actionsTooltip" [color]="colors.Empty" [kbqDropdownTriggerFor]="filterActions" - [disabled]="isSaving" + [disabled]="savePopover.isSaving" [class.kbq-active]="focusedElementBeforeIs(filterActionsButton) && filterActionsOpened" [class]="{ 'kbq-button_changed-saved-filter': filterBar.isSavedAndChanged }" > @@ -108,7 +106,7 @@ - @@ -128,13 +126,13 @@ } } - @if (!filterBar.isReadOnly) { - @@ -158,62 +156,10 @@ } -{{ popoverHeader }} - - - @if (showFilterSavingError) { - - {{ filterSavingErrorText }} - - } -
-
- - - - - - @if (filterName.hasError('filterNameAlreadyExist')) { - {{ localeData.error }} - } - -
-
-
- - - - - + diff --git a/packages/components/filter-bar/filters.ts b/packages/components/filter-bar/filters.ts index 2dc9c0756e..fae551ab1f 100644 --- a/packages/components/filter-bar/filters.ts +++ b/packages/components/filter-bar/filters.ts @@ -14,8 +14,7 @@ import { ViewEncapsulation } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { KbqAlertModule } from '@koobiq/components/alert'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { KbqButton, KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors, KbqFormsModule, PopUpPlacements, PopUpSizes } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; @@ -26,10 +25,11 @@ import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover' import { KbqTitleModule } from '@koobiq/components/title'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { merge, Observable, of } from 'rxjs'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { KbqFilterBar } from './filter-bar'; import { KbqFilterBarButton } from './filter-bar-button'; import { KbqFilter, KbqSaveFilterError, KbqSaveFilterEvent, KbqSaveFilterStatuses } from './filter-bar.types'; +import { KbqFilterSavePopover } from './filter-save-popover'; @Component({ selector: 'kbq-filters', @@ -47,7 +47,7 @@ import { KbqFilter, KbqSaveFilterError, KbqSaveFilterEvent, KbqSaveFilterStatuse KbqPopoverModule, FormsModule, KbqFormsModule, - KbqAlertModule + KbqFilterSavePopover ], templateUrl: 'filters.html', styleUrls: ['filters.scss'], @@ -92,9 +92,10 @@ export class KbqFilters implements OnInit { /** @docs-private */ protected readonly filterActionsDropdown = viewChild('filterActionsButton'); + /** @docs-private */ + protected readonly savePopover = viewChild.required(KbqFilterSavePopover); + private readonly search = viewChild.required('search'); - private readonly newFilterName = viewChild.required('newFilterName'); - private readonly saveFilterButton = viewChild.required('saveFilterButton'); /** control for search filter */ readonly searchControl = new FormControl(null); @@ -106,17 +107,6 @@ export class KbqFilters implements OnInit { /** @docs-private */ protected readonly popoverOffset: number = 4; - /** new filter name for saving */ - filterName: FormControl; - - /** true if saving a new filter, false if saving changes in filter */ - saveNewFilter: boolean; - - showFilterSavingError: boolean = false; - filterSavingErrorText: string = ''; - - isSaving: boolean = false; - readonly filters = input.required(); /** Event that is generated whenever the user selects a filter. */ @@ -130,11 +120,6 @@ export class KbqFilters implements OnInit { /** Event that is generated whenever the user reset a filter changes. */ readonly onResetFilterChanges = output(); - /** header of popover. Depends on the mode */ - get popoverHeader(): string { - return this.saveNewFilter ? this.localeData.saveAsNew : this.localeData.saveChanges; - } - /** Component state. true if opened dropdown or popup */ get opened(): boolean { return this.popover()?.isOpen || this.dropdown()?.opened; @@ -216,41 +201,6 @@ export class KbqFilters implements OnInit { }); } - saveAsNew(event?: Event) { - if (this.filterName.invalid) return; - - const name = this.filterName.value || ''; - - // @todo default filter - const filter = structuredClone(this.filter as KbqFilter) || { pipes: [] }; - - filter.name = name; - filter.saved = true; - filter.changed = false; - - this.isSaving = true; - this.popover().preventClose = true; - this.filterName.disable(); - - if (this.saveNewFilter) { - this.onSave.emit({ filter, filterBar: this.filterBar, status: KbqSaveFilterStatuses.NewFilter }); - } else { - this.onSave.emit({ filter, filterBar: this.filterBar, status: KbqSaveFilterStatuses.NewName }); - } - - event?.preventDefault(); - } - - showError(error?: KbqSaveFilterError) { - if (error?.nameAlreadyExists) { - this.filterName.setErrors({ filterNameAlreadyExist: true }); - } - - this.showFilterSavingError = true; - - this.filterSavingErrorText = error?.text ?? this.filterBar.configuration.filters.errorHint; - } - restoreFocus() { // The popover can be opened from the dropdown item or programmatically, paths that never // capture a trigger via `saveFocusedElement`. Fall back to the main button so focus returns @@ -264,61 +214,17 @@ export class KbqFilters implements OnInit { this.focusedElementBeforeOpen = null; } - preparePopover() { - this.filterName = new FormControl(this.filter?.name || '', Validators.required); - - this.filterName.valueChanges - .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => (this.showFilterSavingError = false)); - - this.popover().show(); - - merge(...this.popover().defaultClosingActions()) - .pipe( - filter(() => !this.isSaving), - takeUntilDestroyed(this.popover().instanceDestroyRef) - ) - .subscribe(() => this.closePopover(false)); - - this.popover() - .visibleChange.pipe( - filter((state) => !state), - takeUntilDestroyed(this.popover().instanceDestroyRef) - ) - .subscribe(this.closePopover); - - setTimeout(() => { - this.newFilterName().nativeElement.focus(); - this.filterName.setErrors(null); - }); - } - - openSaveAsNewFilterPopover() { - this.saveNewFilter = true; - - this.preparePopover(); - } - - openChangeFilterNamePopover() { - this.saveNewFilter = false; - - this.preparePopover(); - } - /** @docs-private */ saveFocusedElement(button?: KbqButton) { this.focusedElementBeforeOpen = button || null; } - closePopover = (restoreFocus: boolean = true) => { - this.popover().hide(); - + /** Restore focus when the save popover reports it closed. @docs-private */ + onSavePopoverClosed(restoreFocus: boolean) { if (restoreFocus) this.restoreFocus(); - setTimeout(() => this.changeDetectorRef.detectChanges()); - - this.showFilterSavingError = false; - }; + this.changeDetectorRef.markForCheck(); + } /** @docs-private */ stopEventPropagation(event: Event) { @@ -355,12 +261,8 @@ export class KbqFilters implements OnInit { /** Hide the popup and restore focus. * Use this method in the onSave or onChangeFilter events after the data has been successfully saved. */ filterSavedSuccessfully() { - const popover = this.popover(); - - this.isSaving = false; - popover.preventClose = false; + this.savePopover().savedSuccessfully(); - popover.hide(); setTimeout(() => this.restoreFocus(), 0); this.changeDetectorRef.markForCheck(); @@ -368,16 +270,87 @@ export class KbqFilters implements OnInit { /** Shows an error. Use this method in the onSave or onChangeFilter events if saving data failed. */ filterSavedUnsuccessfully(error?: KbqSaveFilterError) { - this.isSaving = false; - this.popover().preventClose = false; - this.showError(error); - this.filterName.enable(); - setTimeout(() => this.saveFilterButton().focus()); + + this.savePopover().savedUnsuccessfully(); this.changeDetectorRef.markForCheck(); } + // Delegating facade to the extracted `KbqFilterSavePopover`: the save-popover state, templates and + // logic live in that child; these members keep `KbqFilters`' public surface for callers and the template. + + /** @docs-private */ + get filterName(): FormControl { + return this.savePopover().filterName; + } + + /** @docs-private */ + get saveNewFilter(): boolean { + return this.savePopover().saveNewFilter; + } + + /** @docs-private */ + set saveNewFilter(value: boolean) { + this.savePopover().saveNewFilter = value; + } + + /** @docs-private */ + get isSaving(): boolean { + return this.savePopover().isSaving; + } + + /** @docs-private */ + set isSaving(value: boolean) { + this.savePopover().isSaving = value; + } + + /** @docs-private */ + get showFilterSavingError(): boolean { + return this.savePopover().showFilterSavingError; + } + + /** @docs-private */ + set showFilterSavingError(value: boolean) { + this.savePopover().showFilterSavingError = value; + } + + /** @docs-private */ + get filterSavingErrorText(): string { + return this.savePopover().filterSavingErrorText; + } + + /** header of popover. Depends on the mode + * @docs-private */ + get popoverHeader(): string { + return this.savePopover().popoverHeader; + } + + /** @docs-private */ + openSaveAsNewFilterPopover() { + this.savePopover().openSaveAsNewFilterPopover(); + } + + /** @docs-private */ + openChangeFilterNamePopover() { + this.savePopover().openChangeFilterNamePopover(); + } + + /** @docs-private */ + saveAsNew(event?: Event) { + this.savePopover().saveAsNew(event); + } + + /** @docs-private */ + showError(error?: KbqSaveFilterError) { + this.savePopover().showError(error); + } + + /** @docs-private */ + closePopover(restoreFocus: boolean = true) { + this.savePopover().close(restoreFocus); + } + private getFilteredOptions(value: string | null): KbqFilter[] { return value ? this.filters().filter((filter) => filter.name.toLowerCase().includes(value.toLowerCase())) diff --git a/packages/components/filter-bar/public-api.ts b/packages/components/filter-bar/public-api.ts index 9a351d17e9..2d0f0b7779 100644 --- a/packages/components/filter-bar/public-api.ts +++ b/packages/components/filter-bar/public-api.ts @@ -4,6 +4,7 @@ export * from './filter-bar.module'; export * from './filter-bar.types'; export * from './filter-refresher'; export * from './filter-reset'; +export * from './filter-save-popover'; export * from './filters'; export * from './pipe-add'; export * from './pipe.directive'; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 3e39004898..2060e3f8be 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -427,8 +427,7 @@ export class KbqFilterReset { // @public (undocumented) export class KbqFilters implements OnInit { constructor(); - // (undocumented) - closePopover: (restoreFocus?: boolean) => void; + closePopover(restoreFocus?: boolean): void; protected readonly colors: typeof KbqComponentColors; protected readonly destroyRef: DestroyRef; protected readonly dropdown: i0.Signal; @@ -439,19 +438,18 @@ export class KbqFilters implements OnInit { get filterActionsOpened(): boolean; protected readonly filterBar: KbqFilterBar; filteredOptions: Observable; - filterName: FormControl; + get filterName(): FormControl; // (undocumented) readonly filters: i0.InputSignal; filterSavedSuccessfully(): void; filterSavedUnsuccessfully(error?: KbqSaveFilterError): void; - // (undocumented) - filterSavingErrorText: string; + get filterSavingErrorText(): string; focusedElementBeforeIs(button: KbqButton): boolean; protected readonly focusMonitor: FocusMonitor; get focusOrigin(): FocusOrigin; get isEmpty(): boolean; - // (undocumented) - isSaving: boolean; + get isSaving(): boolean; + set isSaving(value: boolean); get localeData(): { defaultName: string; saveNewFilterTooltip: string; @@ -478,11 +476,10 @@ export class KbqFilters implements OnInit { readonly onRemoveFilter: i0.OutputEmitterRef; readonly onResetFilterChanges: i0.OutputEmitterRef; readonly onSave: i0.OutputEmitterRef; + onSavePopoverClosed(restoreFocus: boolean): void; readonly onSelectFilter: i0.OutputEmitterRef; - // (undocumented) openChangeFilterNamePopover(): void; get opened(): boolean; - // (undocumented) openSaveAsNewFilterPopover(): void; protected readonly placements: typeof PopUpPlacements; protected readonly popover: i0.Signal; @@ -490,28 +487,26 @@ export class KbqFilters implements OnInit { protected readonly popoverOffset: number; protected readonly popoverSize = PopUpSizes.Medium; // (undocumented) - preparePopover(): void; - // (undocumented) removeFilter(): void; // (undocumented) resetFilterChanges(): void; // (undocumented) restoreFocus(): void; - // (undocumented) saveAsNew(event?: Event): void; // (undocumented) saveChanges(): void; saveFocusedElement(button?: KbqButton): void; - saveNewFilter: boolean; + get saveNewFilter(): boolean; + set saveNewFilter(value: boolean); protected readonly saveNewFilterButton: i0.Signal; + protected readonly savePopover: i0.Signal; readonly searchControl: FormControl; searchKeydownHandler(event: KeyboardEvent): void; // (undocumented) selectFilter(filter: KbqFilter): void; - // (undocumented) showError(error?: KbqSaveFilterError): void; - // (undocumented) - showFilterSavingError: boolean; + get showFilterSavingError(): boolean; + set showFilterSavingError(value: boolean); stopEventPropagation(event: Event): void; protected readonly styles: typeof KbqButtonStyles; // (undocumented) @@ -520,6 +515,62 @@ export class KbqFilters implements OnInit { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export class KbqFilterSavePopover implements AfterViewInit { + // (undocumented) + close: (restoreFocus?: boolean) => void; + readonly closed: i0.OutputEmitterRef; + readonly contentTemplate: i0.Signal>; + readonly filterBar: i0.InputSignal; + filterName: FormControl; + // (undocumented) + filterSavingErrorText: string; + readonly footerTemplate: i0.Signal>; + readonly headerTemplate: i0.Signal>; + // (undocumented) + isSaving: boolean; + get localeData(): { + defaultName: string; + saveNewFilterTooltip: string; + searchPlaceholder: string; + searchEmptyResult: string; + saveAsNewFilter: string; + saveChanges: string; + saveAsNew: string; + change: string; + resetChanges: string; + remove: string; + name: string; + error: string; + errorHint: string; + saveButton: string; + cancelButton: string; + actionsTooltip: string; + }; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + openChangeFilterNamePopover(): void; + // (undocumented) + openSaveAsNewFilterPopover(): void; + get popoverHeader(): string; + readonly popoverTrigger: i0.InputSignal; + readonly save: i0.OutputEmitterRef; + // (undocumented) + saveAsNew(event?: Event): void; + savedSuccessfully(): void; + savedUnsuccessfully(): void; + saveNewFilter: boolean; + // (undocumented) + showError(error?: KbqSaveFilterError): void; + // (undocumented) + showFilterSavingError: boolean; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public (undocumented) export interface KbqPipe { // (undocumented) From 2c40fb05edc6ffa138e84b83848c37c9c0ca294b Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 16:24:51 +0300 Subject: [PATCH 07/16] fix: P1-6 --- .../filter-bar/filter-bar-button.ts | 11 +- .../components/filter-bar/filter-bar.spec.ts | 80 ++++--- packages/components/filter-bar/filter-bar.ts | 86 ++++---- .../components/filter-bar/filter-bar.types.ts | 10 +- .../components/filter-bar/filter-reset.ts | 1 - packages/components/filter-bar/filters.html | 20 +- .../components/filter-bar/filters.spec.ts | 17 ++ packages/components/filter-bar/filters.ts | 17 +- .../components/filter-bar/pipe-add.spec.ts | 15 +- packages/components/filter-bar/pipe-add.ts | 49 +++-- .../components/filter-bar/pipes/base-pipe.ts | 15 +- .../filter-bar/pipes/pipe-button.ts | 15 +- .../components/filter-bar/pipes/pipe-state.ts | 16 +- .../components/filter-bar.api.md | 205 +++++++++--------- 14 files changed, 298 insertions(+), 259 deletions(-) diff --git a/packages/components/filter-bar/filter-bar-button.ts b/packages/components/filter-bar/filter-bar-button.ts index 0793a42aa8..c4ec177dd1 100644 --- a/packages/components/filter-bar/filter-bar-button.ts +++ b/packages/components/filter-bar/filter-bar-button.ts @@ -1,5 +1,4 @@ -import { Directive, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Directive, effect, inject } from '@angular/core'; import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; import { KBQ_FILTER_BAR_HOST } from './filter-bar.types'; @@ -20,11 +19,15 @@ export class KbqFilterBarButton { protected readonly filters = inject(KbqFilters); constructor() { - this.filterBar.changes.pipe(takeUntilDestroyed()).subscribe(() => { + // Reflect the current filter's saved/changed state in the button style. Reading `filterBar.filter` + // (a signal-backed accessor) subscribes this effect, replacing the retired `changes` bus. + effect(() => { + const filter = this.filterBar.filter; + this.button.kbqStyle = KbqButtonStyles.Outline; this.button.color = KbqComponentColors.ContrastFade; - if (this.filterBar.filter?.changed || this.filterBar.filter?.saved) { + if (filter?.changed || filter?.saved) { this.button.kbqStyle = 'changed-filter'; this.button.color = KbqComponentColors.Empty; } diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index a7fad6d600..264425a602 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, DebugElement, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, computed, DebugElement, inject } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -121,31 +121,44 @@ describe('KbqFilterBar', () => { fixture.detectChanges(); }); - it('should emit changes when filter is set', () => { + it('should recompute derived state when a new filter reference is set', () => { const filterBar = getFilterBar(); - const spy = jest.fn(); + let computeCount = 0; + // `filter` is signal-backed, so a `computed()` reading it recomputes on every new reference. + const probe = computed(() => { + computeCount++; - filterBar.changes.subscribe(spy); - spy.mockClear(); + return filterBar.filter; + }); + + probe(); + const before = computeCount; filterBar.filter = createFilter([]); - expect(spy).toHaveBeenCalled(); + probe(); + + expect(computeCount).toBe(before + 1); }); - it('should not emit changes when same filter reference is set', () => { + it('should not recompute derived state when the same filter reference is set again', () => { const filterBar = getFilterBar(); const filter = createFilter([]); + let computeCount = 0; + const probe = computed(() => { + computeCount++; - filterBar.filter = filter; - fixture.detectChanges(); - - const spy = jest.fn(); + return filterBar.filter; + }); - filterBar.changes.subscribe(spy); - spy.mockClear(); + filterBar.filter = filter; + probe(); + const before = computeCount; + // Setting the identical reference is deduped by the signal — no recompute. filterBar.filter = filter; - expect(spy).not.toHaveBeenCalled(); + probe(); + + expect(computeCount).toBe(before); }); it('should emit filterChange via internalFilterChanges subscription', () => { @@ -206,7 +219,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { saved: true }); - expect(filterBar.isSaved).toBe(true); + expect(filterBar.isSaved()).toBe(true); }); it('should return isSaved false when filter.saved is false', () => { @@ -214,7 +227,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { saved: false }); - expect(filterBar.isSaved).toBe(false); + expect(filterBar.isSaved()).toBe(false); }); it('should return isChanged true when filter.changed is true', () => { @@ -222,7 +235,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { changed: true }); - expect(filterBar.isChanged).toBe(true); + expect(filterBar.isChanged()).toBe(true); }); it('should return isChanged false when filter.changed is false', () => { @@ -230,7 +243,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { changed: false }); - expect(filterBar.isChanged).toBe(false); + expect(filterBar.isChanged()).toBe(false); }); it('should return isSavedAndChanged true only when both saved and changed', () => { @@ -238,7 +251,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { saved: true, changed: true }); - expect(filterBar.isSavedAndChanged).toBe(true); + expect(filterBar.isSavedAndChanged()).toBe(true); }); it('should return isSavedAndChanged false when only saved', () => { @@ -246,7 +259,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { saved: true, changed: false }); - expect(filterBar.isSavedAndChanged).toBe(false); + expect(filterBar.isSavedAndChanged()).toBe(false); }); it('should return isSavedAndChanged false when only changed', () => { @@ -254,7 +267,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { saved: false, changed: true }); - expect(filterBar.isSavedAndChanged).toBe(false); + expect(filterBar.isSavedAndChanged()).toBe(false); }); it('should return isReadOnly true when filter.readonly is true', () => { @@ -262,7 +275,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { readonly: true }); - expect(filterBar.isReadOnly).toBe(true); + expect(filterBar.isReadOnly()).toBe(true); }); it('should return isDisabled true when filter.disabled is true', () => { @@ -270,7 +283,7 @@ describe('KbqFilterBar', () => { filterBar.filter = createFilter([], { disabled: true }); - expect(filterBar.isDisabled).toBe(true); + expect(filterBar.isDisabled()).toBe(true); }); it('should return false for all when filter is null', () => { @@ -278,11 +291,11 @@ describe('KbqFilterBar', () => { filterBar.filter = null; - expect(filterBar.isSaved).toBe(false); - expect(filterBar.isChanged).toBe(false); - expect(filterBar.isSavedAndChanged).toBe(false); - expect(filterBar.isReadOnly).toBe(false); - expect(filterBar.isDisabled).toBe(false); + expect(filterBar.isSaved()).toBe(false); + expect(filterBar.isChanged()).toBe(false); + expect(filterBar.isSavedAndChanged()).toBe(false); + expect(filterBar.isReadOnly()).toBe(false); + expect(filterBar.isDisabled()).toBe(false); }); }); @@ -351,20 +364,19 @@ describe('KbqFilterBar', () => { expect(spy).toHaveBeenCalledWith(pipe); }); - it('should emit changes', () => { + it('should replace the filter with a new reference on remove', () => { const pipe = createPipe({ name: 'removable' }); const filterBar = getFilterBar(); filterBar.filter = createFilter([pipe]); - const spy = jest.fn(); - - filterBar.changes.subscribe(spy); - spy.mockClear(); + const before = filterBar.filter; filterBar.removePipe(pipe); - expect(spy).toHaveBeenCalled(); + // Immutable update: a new `filter` reference (not an in-place splice) drives signal reactivity. + expect(filterBar.filter).not.toBe(before); + expect(filterBar.filter!.pipes).toEqual([]); }); }); diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index a39629195c..410f8388a2 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -3,12 +3,14 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, contentChild, forwardRef, inject, Input, input, output, + signal, ViewEncapsulation } from '@angular/core'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -78,24 +80,27 @@ export class KbqFilterBar implements KbqFilterBarHost { * */ readonly selectedAllEqualsSelectedNothing = input(true, { transform: booleanAttribute }); + /** + * Signal backing the `filter` accessor input. Derived state (`isSaved`/`isChanged`/…) is exposed via + * `computed()` off this signal and change detection is driven by signal reactivity, which replaces the + * retired `changes` Subject and its manual `markForCheck` fan-out (P1-6). + */ + private readonly _filter = signal(null); + /** Filter that is currently selected */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input() get filter(): KbqFilter | null { - return this._filter; + return this._filter(); } set filter(value: KbqFilter | null) { - if (this._filter === value) return; + if (this._filter() === value) return; - this._filter = value; - - this.changes.next(); + this._filter.set(value); } - private _filter: KbqFilter | null; - /** An array of templates that are used when adding a pipe. Also contains lists of options to select (values). */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -126,37 +131,25 @@ export class KbqFilterBar implements KbqFilterBarHost { /** Event that emits whenever the select or multiselect pipe closed. */ readonly onClosePipe = output(); - /** Whether the current filter is saved and changed */ - get isSavedAndChanged(): boolean { - return this.isSaved && this.isChanged; - } - /** Whether the current filter is saved */ - get isSaved(): boolean { - return !!this.filter?.saved; - } + readonly isSaved = computed(() => !!this._filter()?.saved); /** Whether the current filter is changed */ - get isChanged(): boolean { - return !!this.filter?.changed; - } + readonly isChanged = computed(() => !!this._filter()?.changed); + + /** Whether the current filter is saved and changed */ + readonly isSavedAndChanged = computed(() => this.isSaved() && this.isChanged()); /** Whether the current filter is readonly */ - get isReadOnly(): boolean { - return !!this.filter?.readonly; - } + readonly isReadOnly = computed(() => !!this._filter()?.readonly); /** Whether the current filter is disabled */ - get isDisabled(): boolean { - return !!this.filter?.disabled; - } + readonly isDisabled = computed(() => !!this._filter()?.disabled); private savedFilter: KbqFilter | null = null; /** Event that emits whenever the filter is reset. */ readonly onResetFilter = new BehaviorSubject(false); - /** all changes */ - readonly changes = new BehaviorSubject(undefined); /** internal filter changes */ readonly internalFilterChanges = new BehaviorSubject(null); /** internal changes in templates */ @@ -167,7 +160,7 @@ export class KbqFilterBar implements KbqFilterBarHost { constructor() { this.internalFilterChanges.pipe(takeUntilDestroyed()).subscribe((filter) => { - this._filter = filter; + this._filter.set(filter); this.filterChange.emit(this.filter); }); @@ -178,38 +171,33 @@ export class KbqFilterBar implements KbqFilterBarHost { this.initDefaultParams(); } + // A pipe value change or removal marks the current filter as "changed". Produce a new filter + // reference (not an in-place mutation) so the `filter` signal — and every `computed()`/`effect()` + // reading it — reacts. This replaces the retired `changes` Subject + `markForCheck` fan-out (P1-6). merge(outputToObservable(this.onChangePipe), outputToObservable(this.onRemovePipe)) .pipe(takeUntilDestroyed()) .subscribe(() => { - if (this.filter) { - this.filter.changed = true; + const current = this.filter; + + if (current) { + this._filter.set({ ...current, changed: true }); + this.filterChange.emit(this.filter); } }); - - merge( - outputToObservable(this.filterChange), - outputToObservable(this.onChangePipe), - outputToObservable(this.onRemovePipe), - this.internalFilterChanges - ) - .pipe(takeUntilDestroyed()) - .subscribe(() => { - this.changes.next(); - this.changeDetectorRef.markForCheck(); - }); } /** Remove pipe from current filter and emit event */ removePipe(pipe: KbqPipe) { - if (!this.filter?.pipes.includes(pipe)) return; + const current = this.filter; - // Replace the array instead of splicing in place so `pipes` follows a new-reference-on-change - // model (friendlier to OnPush / signal reference-equality) and unknown pipes are left untouched. - this.filter.pipes = this.filter.pipes.filter((item) => item !== pipe); + if (!current?.pipes.includes(pipe)) return; + + // Replace the filter (and its `pipes` array) with new references instead of mutating in place, so + // the `filter` signal reacts; unknown pipes are left untouched. `onRemovePipe` then flags "changed". + this._filter.set({ ...current, pipes: current.pipes.filter((item) => item !== pipe) }); this.onRemovePipe.emit(pipe); - this.changes.next(); } /** Save current state of filter */ @@ -229,9 +217,11 @@ export class KbqFilterBar implements KbqFilterBarHost { /** Set the filter state "changed" to false */ resetFilterChangedState() { - if (!this.filter) return; + const current = this.filter; + + if (!current) return; - this.filter.changed = false; + this._filter.set({ ...current, changed: false }); } private updateLocaleParams = () => { diff --git a/packages/components/filter-bar/filter-bar.types.ts b/packages/components/filter-bar/filter-bar.types.ts index 6ce13928d2..9d6d1bb331 100644 --- a/packages/components/filter-bar/filter-bar.types.ts +++ b/packages/components/filter-bar/filter-bar.types.ts @@ -46,13 +46,13 @@ export interface KbqFilterBarHost { readonly selectedAllEqualsSelectedNothing: Signal; /** Whether the current filter is saved. */ - readonly isSaved: boolean; + readonly isSaved: Signal; /** Whether the current filter is changed. */ - readonly isChanged: boolean; + readonly isChanged: Signal; /** Whether the current filter is both saved and changed. */ - readonly isSavedAndChanged: boolean; + readonly isSavedAndChanged: Signal; /** Whether the current filter is readonly. */ - readonly isReadOnly: boolean; + readonly isReadOnly: Signal; /** Emits the two-way-bound `filter` value. */ readonly filterChange: OutputEmitterRef; @@ -63,8 +63,6 @@ export interface KbqFilterBarHost { /** Emits whenever a select/multiselect pipe closes. */ readonly onClosePipe: OutputEmitterRef; - /** State-change bus. */ - readonly changes: BehaviorSubject; /** Internal filter changes. */ readonly internalFilterChanges: BehaviorSubject; /** Internal pipe-template changes. */ diff --git a/packages/components/filter-bar/filter-reset.ts b/packages/components/filter-bar/filter-reset.ts index e523ed4162..debee33af0 100644 --- a/packages/components/filter-bar/filter-reset.ts +++ b/packages/components/filter-bar/filter-reset.ts @@ -31,7 +31,6 @@ export class KbqFilterReset { protected resetFilter() { this.onResetFilter.emit(this.filterBar.filter!); - this.filterBar.changes.next(); this.filterBar.onResetFilter.next(true); } } diff --git a/packages/components/filter-bar/filters.html b/packages/components/filter-bar/filters.html index 643f93a3e4..c7fbf19e26 100644 --- a/packages/components/filter-bar/filters.html +++ b/packages/components/filter-bar/filters.html @@ -30,7 +30,7 @@ -@if (filterBar.isChanged && !filterBar.isSaved) { +@if (filterBar.isChanged() && !filterBar.isSaved()) { @@ -113,15 +113,15 @@ - @if (filterBar.isSavedAndChanged && !filterBar.isReadOnly) { + @if (filterBar.isSavedAndChanged() && !filterBar.isReadOnly()) { @@ -131,24 +131,24 @@ {{ localeData.saveAsNew }} - @if (!filterBar.isReadOnly) { + @if (!filterBar.isReadOnly()) { } - @if (filterBar.isSavedAndChanged || !filterBar.isReadOnly) { + @if (filterBar.isSavedAndChanged() || !filterBar.isReadOnly()) { } - @if (filterBar.isSavedAndChanged) { + @if (filterBar.isSavedAndChanged()) { } - @if (!filterBar.isReadOnly) { + @if (!filterBar.isReadOnly()) { - @for (template of filterBar.pipeTemplates; track template) { + @for (template of filterBar.pipeTemplates(); track template) { this.filterBar.filter?.pipes.map((pipe: KbqPipe) => getId(pipe)) ?? []); + readonly addedPipes = computed(() => this.filterBar.filter()?.pipes.map((pipe: KbqPipe) => getId(pipe)) ?? []); /** * Visually-hidden live-region text announcing a newly-added pipe to assistive tech (WCAG 4.1.3). @@ -105,21 +105,21 @@ export class KbqPipeAdd { } else { option.select(); - const current = this.filterBar.filter ?? structuredClone(this.filterTemplate()); + const current = this.filterBar.filter() ?? structuredClone(this.filterTemplate()); - // Build a new filter reference (immutable add) so the `filter` signal reacts; keep `pipes` on a - // new-reference-on-change model instead of an in-place push. - this.filterBar.filter = { + // Build a new filter reference (immutable add) so the `filter` model reacts; keep `pipes` on a + // new-reference-on-change model instead of an in-place push. `model.set(...)` auto-emits + // `filterChange` for the two-way binding. + this.filterBar.filter.set({ ...current, changed: true, pipes: [ ...current.pipes, Object.assign({}, option.value, { values: undefined, valueTemplate: undefined, openOnAdd: true }) ] - }; + }); this.onAddPipe.emit(option.value); - this.filterBar.filterChange.emit(this.filterBar.filter); this.announcement.set( this.filterBar.configuration.add.addedAnnouncement.replace('{{ name }}', option.value.name) diff --git a/packages/components/filter-bar/pipes/base-pipe.ts b/packages/components/filter-bar/pipes/base-pipe.ts index 7c02212616..8d20564a47 100644 --- a/packages/components/filter-bar/pipes/base-pipe.ts +++ b/packages/components/filter-bar/pipes/base-pipe.ts @@ -254,7 +254,7 @@ export class KbqPipeMinWidth { // Recompute the min-width whenever the filter changes. The pipe's text content updates during CD, // so defer the read to the next macrotask (mirrors the old `changes.pipe(delay(0))`). Passing the // `filterBar.filter` read into the scheduler subscribes the effect, replacing the `changes` bus. - effect((onCleanup) => onCleanup(this.scheduleMinWidthUpdate(this.filterBar?.filter))); + effect((onCleanup) => onCleanup(this.scheduleMinWidthUpdate(this.filterBar?.filter()))); afterNextRender({ read: this.update }); } diff --git a/packages/components/filter-bar/pipes/pipe-button.ts b/packages/components/filter-bar/pipes/pipe-button.ts index 9439f5e355..3179282220 100644 --- a/packages/components/filter-bar/pipes/pipe-button.ts +++ b/packages/components/filter-bar/pipes/pipe-button.ts @@ -68,7 +68,7 @@ export class KbqPipeButton { // The pipe template renders plain `data` (not signals), so mark it for check when the filter // changes. Passing the `filterBar.filter` read into the handler subscribes this effect, replacing // the retired `changes` bus. - effect(() => this.markForCheckOnFilterChange(this.filterBar.filter)); + effect(() => this.markForCheckOnFilterChange(this.filterBar.filter())); } private markForCheckOnFilterChange(_filter: unknown): void { diff --git a/packages/components/filter-bar/pipes/pipe-state.ts b/packages/components/filter-bar/pipes/pipe-state.ts index 71d4e973e9..88b12a393a 100644 --- a/packages/components/filter-bar/pipes/pipe-state.ts +++ b/packages/components/filter-bar/pipes/pipe-state.ts @@ -1,4 +1,4 @@ -import { Directive, effect, inject, Input } from '@angular/core'; +import { Directive, effect, inject, input } from '@angular/core'; import { KbqButton, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; import { KBQ_FILTER_BAR_HOST } from '../filter-bar.types'; @@ -16,30 +16,17 @@ export class KbqPipeState { * @docs-private */ private readonly filterBar = inject(KBQ_FILTER_BAR_HOST); - /** calculates and updates styles of button from pipe state */ - // TODO: Skipped for migration because: - // Accessor inputs cannot be migrated as they are too complex. - @Input({ alias: 'kbqPipeState' }) - get state(): T | null { - return this._state; - } - - set state(pipe: T | null) { - this._state = pipe; - - this.updateState(); - } - - private _state: T | null = null; + /** Pipe state used to calculate/update the button style. */ + readonly state = input(null, { alias: 'kbqPipeState' }); constructor() { - // Re-derive the button style whenever the filter changes (a pipe's emptiness may change with it). - // Passing the `filterBar.filter` read into `updateState` subscribes this effect to the signal-backed - // accessor, replacing the retired `changes` bus. - effect(() => this.updateState(this.filterBar.filter)); + // Re-derive the button style whenever the filter OR the pipe state changes (a pipe's emptiness may + // change with either). Passing both reads into `updateState` subscribes this effect to both signals; + // the style itself derives from `pipe.isEmpty`. + effect(() => this.updateState(this.filterBar.filter(), this.state())); } - private updateState = (_filter?: unknown) => { + private updateState = (_filter?: unknown, _state?: unknown) => { this.button.kbqStyle = KbqButtonStyles.Outline; this.button.color = KbqComponentColors.ContrastFade; diff --git a/packages/docs-examples/components/filter-bar/filter-bar-overview/filter-bar-overview-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-overview/filter-bar-overview-example.ts index 3cba1f78aa..05b82bb4d3 100644 --- a/packages/docs-examples/components/filter-bar/filter-bar-overview/filter-bar-overview-example.ts +++ b/packages/docs-examples/components/filter-bar/filter-bar-overview/filter-bar-overview-example.ts @@ -325,9 +325,9 @@ export class FilterBarOverviewExample { saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) { // This logic simulates the behavior of the backend - if (filterBar.filter?.name !== filter.name) { + if (filterBar.filter()?.name !== filter.name) { this.filters.splice( - this.filters.findIndex(({ name }) => name === filterBar.filter?.name), + this.filters.findIndex(({ name }) => name === filterBar.filter()?.name), 1, filter! ); diff --git a/packages/docs-examples/components/filter-bar/filter-bar-saved-filters/filter-bar-saved-filters-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-saved-filters/filter-bar-saved-filters-example.ts index 9277a4e1b0..7d29d2873a 100644 --- a/packages/docs-examples/components/filter-bar/filter-bar-saved-filters/filter-bar-saved-filters-example.ts +++ b/packages/docs-examples/components/filter-bar/filter-bar-saved-filters/filter-bar-saved-filters-example.ts @@ -408,9 +408,9 @@ export class FilterBarSavedFiltersExample { saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) { // This logic simulates the behavior of the backend - if (filterBar.filter?.name !== filter.name) { + if (filterBar.filter()?.name !== filter.name) { this.filters.splice( - this.filters.findIndex(({ name }) => name === filterBar.filter?.name), + this.filters.findIndex(({ name }) => name === filterBar.filter()?.name), 1, filter! ); diff --git a/packages/docs-examples/components/filter-bar/filter-bar-uniq-pipes/filter-bar-uniq-pipes-example.ts b/packages/docs-examples/components/filter-bar/filter-bar-uniq-pipes/filter-bar-uniq-pipes-example.ts index c7142b15b2..91cf417983 100644 --- a/packages/docs-examples/components/filter-bar/filter-bar-uniq-pipes/filter-bar-uniq-pipes-example.ts +++ b/packages/docs-examples/components/filter-bar/filter-bar-uniq-pipes/filter-bar-uniq-pipes-example.ts @@ -385,9 +385,9 @@ export class FilterBarUniqPipesExample { saveCurrentFilterWithNewName(filter: KbqFilter, filterBar: KbqFilterBar) { // This logic simulates the behavior of the backend - if (filterBar.filter?.name !== filter.name) { + if (filterBar.filter()?.name !== filter.name) { this.filters.splice( - this.filters.findIndex(({ name }) => name === filterBar.filter?.name), + this.filters.findIndex(({ name }) => name === filterBar.filter()?.name), 1, filter! ); diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 4f03647fe5..5d5f0439e4 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -33,6 +33,7 @@ import { KbqTreeFlattener } from '@koobiq/components/tree'; import { KbqTreeOption } from '@koobiq/components/tree'; import { KbqTreeSelect } from '@koobiq/components/tree-select'; import * as _koobiq_components_core from '@koobiq/components/core'; +import { ModelSignal } from '@angular/core'; import { Observable } from 'rxjs'; import { OnInit } from '@angular/core'; import { OutputEmitterRef } from '@angular/core'; @@ -276,9 +277,7 @@ export class KbqFilterBar implements KbqFilterBarHost { backToPeriodSelection: string; }; } | null; - get filter(): KbqFilter | null; - set filter(value: KbqFilter | null); - readonly filterChange: _angular_core.OutputEmitterRef; + readonly filter: _angular_core.ModelSignal; readonly filterReset: _angular_core.Signal; readonly filters: _angular_core.Signal; readonly internalFilterChanges: BehaviorSubject; @@ -295,15 +294,14 @@ export class KbqFilterBar implements KbqFilterBarHost { readonly onRemovePipe: _angular_core.OutputEmitterRef; readonly onResetFilter: BehaviorSubject; readonly openPipe: BehaviorSubject; - get pipeTemplates(): KbqPipeTemplate[]; - set pipeTemplates(value: KbqPipeTemplate[]); + readonly pipeTemplates: _angular_core.InputSignal; removePipe(pipe: KbqPipe): void; resetFilterChangedState(): void; restoreFilterState(filter?: KbqFilter): void; saveFilterState(filter?: KbqFilter): void; readonly selectedAllEqualsSelectedNothing: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -325,8 +323,7 @@ export type KbqFilterBarConfiguration = typeof KBQ_FILTER_BAR_DEFAULT_CONFIGURAT // @public export interface KbqFilterBarHost { configuration: KbqFilterBarConfiguration; - filter: KbqFilter | null; - readonly filterChange: OutputEmitterRef; + readonly filter: ModelSignal; readonly internalFilterChanges: BehaviorSubject; readonly internalTemplatesChanges: BehaviorSubject; readonly isChanged: Signal; @@ -338,7 +335,7 @@ export interface KbqFilterBarHost { readonly onClosePipe: OutputEmitterRef; readonly onResetFilter: BehaviorSubject; readonly openPipe: BehaviorSubject; - pipeTemplates: KbqPipeTemplate[]; + readonly pipeTemplates: Signal; removePipe(pipe: KbqPipe): void; resetFilterChangedState(): void; readonly selectedAllEqualsSelectedNothing: Signal; @@ -818,10 +815,9 @@ export class KbqPipeSelectComponent extends KbqBasePipe implemen // @public (undocumented) export class KbqPipeState { constructor(); - get state(): T | null; - set state(pipe: T | null); + readonly state: _angular_core.InputSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[kbqPipeState]", never, { "state": { "alias": "kbqPipeState"; "required": false; }; }, {}, never, never, true, never>; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[kbqPipeState]", never, { "state": { "alias": "kbqPipeState"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration, never>; } From 99bd32c942946f81262d9b08072375949feedd4d Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 8 Jul 2026 21:06:30 +0300 Subject: [PATCH 11/16] fix: after review --- .../components/filter-bar/filter-bar.spec.ts | 73 ++++++++++++++++--- packages/components/filter-bar/filter-bar.ts | 33 ++++++--- .../filter-bar/filter-save-popover.ts | 2 + .../components/filter-bar/filters.spec.ts | 31 +++++++- packages/components/filter-bar/filters.ts | 16 +++- .../components/filter-bar/pipe-add.spec.ts | 25 +++++++ packages/components/filter-bar/pipe-add.ts | 10 ++- .../filter-bar/pipes/pipe-date.scss | 12 ++- packages/components/filter-bar/public-api.ts | 1 + .../components/filter-bar.api.md | 38 ++++++++++ 10 files changed, 208 insertions(+), 33 deletions(-) diff --git a/packages/components/filter-bar/filter-bar.spec.ts b/packages/components/filter-bar/filter-bar.spec.ts index 46ad4cd49d..2e2c6ab252 100644 --- a/packages/components/filter-bar/filter-bar.spec.ts +++ b/packages/components/filter-bar/filter-bar.spec.ts @@ -121,41 +121,44 @@ describe('KbqFilterBar', () => { fixture.detectChanges(); }); - it('should recompute derived state when a new filter reference is set', () => { + it('should recompute a derived computed (isChanged) when the underlying flag changes', () => { const filterBar = getFilterBar(); let computeCount = 0; - // `filter` is signal-backed, so a `computed()` reading it recomputes on every new reference. + // Probe a REAL derived computed so this guards the derived-state reactivity itself, not merely + // that `filter` is signal-backed (reverting isChanged to a plain getter would fail this). const probe = computed(() => { computeCount++; - return filterBar.filter(); + return filterBar.isChanged(); }); + filterBar.filter.set(createFilter([], { changed: false })); probe(); const before = computeCount; - filterBar.filter.set(createFilter([])); + filterBar.filter.set(createFilter([], { changed: true })); probe(); expect(computeCount).toBe(before + 1); + expect(filterBar.isChanged()).toBe(true); }); - it('should not recompute derived state when the same filter reference is set again', () => { + it('should NOT recompute a derived computed (isChanged) when its value is unchanged', () => { const filterBar = getFilterBar(); - const filter = createFilter([]); let computeCount = 0; const probe = computed(() => { computeCount++; - return filterBar.filter(); + return filterBar.isChanged(); }); - filterBar.filter.set(filter); + filterBar.filter.set(createFilter([], { changed: true })); probe(); const before = computeCount; - // Setting the identical reference is deduped by the signal — no recompute. - filterBar.filter.set(filter); + // A new filter reference whose `changed` is still true — `isChanged()` yields the same value, + // so the `computed()` chain dedupes and a consumer reading only `isChanged()` is not re-run. + filterBar.filter.set(createFilter([], { changed: true })); probe(); expect(computeCount).toBe(before); @@ -381,6 +384,25 @@ describe('KbqFilterBar', () => { expect(filterBar.filter()).not.toBe(before); expect(filterBar.filter()!.pipes).toEqual([]); }); + + it('should emit filterChange exactly once, carrying changed=true, on remove', () => { + const pipe = createPipe({ name: 'removable' }); + const filterBar = getFilterBar(); + + // Saved, unmodified filter: a stale first emission (changed=false) would be observable here. + filterBar.filter.set(createFilter([pipe], { saved: true, changed: false })); + + const spy = jest.fn(); + + filterBar.filter.subscribe(spy); + spy.mockClear(); + + filterBar.removePipe(pipe); + + // A single `set` folds in `changed: true`; onRemovePipe no longer triggers a second emission. + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ changed: true, pipes: [] })); + }); }); describe('saveFilterState / restoreFilterState', () => { @@ -531,13 +553,42 @@ describe('KbqFilterBar', () => { fixture.detectChanges(); }); - it('should default to true', () => { + it('should reflect the bound value', () => { const filterBar = getFilterBar(); expect(filterBar.selectedAllEqualsSelectedNothing()).toBe(true); }); }); + describe('selectedAllEqualsSelectedNothing default', () => { + @Component({ + selector: 'test-app-default-select-all', + imports: [KbqFilterBarModule], + template: ` + + ` + }) + class TestComponentDefaultSelectAll {} + + it('should default to true when the input is left unbound', () => { + // Render WITHOUT binding the input, so this exercises the declared `input(true)` + // default rather than a round-tripped binding. + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, KbqFilterBarModule, TestComponentDefaultSelectAll] + }); + + const defaultFixture = TestBed.createComponent(TestComponentDefaultSelectAll); + + defaultFixture.detectChanges(); + + const filterBar: KbqFilterBar = defaultFixture.debugElement.query( + By.directive(KbqFilterBar) + ).componentInstance; + + expect(filterBar.selectedAllEqualsSelectedNothing()).toBe(true); + }); + }); + // Replacement for the projection coverage that used to live in filter-search.spec.ts // (removed in v20.0.0 when KbqFilterBarSearch was deleted in favor of kbq-search-expandable). describe('search-expandable projection slot', () => { diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index cf0fdf3423..7a6d6a5d18 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -16,7 +16,7 @@ import { import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { KBQ_LOCALE_SERVICE } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; -import { BehaviorSubject, merge } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { KBQ_FILTER_BAR_CONFIGURATION, KBQ_FILTER_BAR_DEFAULT_CONFIGURATION, @@ -142,10 +142,11 @@ export class KbqFilterBar implements KbqFilterBarHost { this.initDefaultParams(); } - // A pipe value change or removal marks the current filter as "changed". Produce a new filter - // reference (not an in-place mutation) so the `filter` model — and every `computed()`/`effect()` - // reading it — reacts. `model.set(...)` auto-emits `filterChange` for the two-way binding. - merge(outputToObservable(this.onChangePipe), outputToObservable(this.onRemovePipe)) + // A pipe value change marks the current filter as "changed". Produce a new filter reference (not an + // in-place mutation) so the `filter` model — and every `computed()`/`effect()` reading it — reacts. + // `removePipe` owns its own `changed` flag in a single `set` (one `filterChange` emission), so only + // `onChangePipe` feeds this subscriber. `model.set(...)` auto-emits `filterChange` for the binding. + outputToObservable(this.onChangePipe) .pipe(takeUntilDestroyed()) .subscribe(() => { const current = this.filter(); @@ -162,19 +163,31 @@ export class KbqFilterBar implements KbqFilterBarHost { if (!current?.pipes.includes(pipe)) return; - // Replace the filter (and its `pipes` array) with new references instead of mutating in place, so - // the `filter` signal reacts; unknown pipes are left untouched. `onRemovePipe` then flags "changed". - this.filter.set({ ...current, pipes: current.pipes.filter((item) => item !== pipe) }); + // Replace the filter (and its `pipes` array) with new references instead of mutating in place, so the + // `filter` signal reacts; unknown pipes are left untouched. Fold in `changed: true` here — the removal + // IS the change — so it happens in a single `set` (one `filterChange` emission) rather than relying on + // `onRemovePipe` to trigger a second update. + this.filter.set({ ...current, changed: true, pipes: current.pipes.filter((item) => item !== pipe) }); this.onRemovePipe.emit(pipe); } - /** Save current state of filter */ + /** + * Save current state of filter. + * + * Deep-clones the filter via `structuredClone`, so any pipe `value` payload handed to save/restore must be + * structured-cloneable (plain data — no functions, DOM nodes, class instances or `TemplateRef`s), otherwise + * `structuredClone` throws `DataCloneError`. All built-in pipes produce cloneable values. + */ saveFilterState(filter?: KbqFilter) { this.savedFilter = structuredClone(filter ?? this.filter()); } - /** Restore previously saved filter state */ + /** + * Restore previously saved filter state. + * + * @see `saveFilterState` — pipe `value` payloads must be structured-cloneable. + */ restoreFilterState(filter?: KbqFilter) { const state = filter ?? this.savedFilter; diff --git a/packages/components/filter-bar/filter-save-popover.ts b/packages/components/filter-bar/filter-save-popover.ts index 2805988947..02a6670b3e 100644 --- a/packages/components/filter-bar/filter-save-popover.ts +++ b/packages/components/filter-bar/filter-save-popover.ts @@ -175,6 +175,8 @@ export class KbqFilterSavePopover implements AfterViewInit { const name = this.filterName.value || ''; // @todo default filter + // Deep-clones the current filter; pipe `value` payloads must be structured-cloneable (see + // `KbqFilterBar.saveFilterState`). All built-in pipes produce cloneable values. const filter = structuredClone(this.filterBar().filter() as KbqFilter) || { pipes: [] }; filter.name = name; diff --git a/packages/components/filter-bar/filters.spec.ts b/packages/components/filter-bar/filters.spec.ts index d6e853d5bd..dd1373ff29 100644 --- a/packages/components/filter-bar/filters.spec.ts +++ b/packages/components/filter-bar/filters.spec.ts @@ -252,7 +252,11 @@ describe('KbqFilters', () => { }); it('should push an isolated shallow copy (not a deep structuredClone) to internalFilterChanges', () => { - const filter = createFilter([createPipe({ name: 'original', value: 'A' })], { name: 'Selected' }); + // A non-cloneable payload (a function) is what actually distinguishes a shallow copy from a deep + // clone: `structuredClone` (or the suite's JSON-based stub) would drop/alter it, the shallow copy + // preserves it by reference. This is the exact guarantee that lets us drop the per-selection clone. + const valueFn = () => 'A'; + const filter = createFilter([createPipe({ name: 'original', value: valueFn })], { name: 'Selected' }); initFixture(createFilter([]), [filter]); @@ -269,13 +273,16 @@ describe('KbqFilters', () => { expect(activeFilter!.pipes[0]).not.toBe(filter.pipes[0]); expect(activeFilter!.name).toBe('Selected'); + // The non-cloneable `value` survives by reference — a deep clone would have dropped/altered it. + expect(activeFilter!.pipes[0].value).toBe(valueFn); + // Pipes write their `value` by reassignment, so editing the active copy never leaks to the // saved source (the guarantee that lets us drop the per-selection `structuredClone`). activeFilter!.pipes[0].name = 'mutated'; activeFilter!.pipes[0].value = 'B'; expect(filter.pipes[0].name).toBe('original'); - expect(filter.pipes[0].value).toBe('A'); + expect(filter.pipes[0].value).toBe(valueFn); }); }); @@ -688,6 +695,25 @@ describe('KbqFilters', () => { expect(component.filterName.enabled).toBe(true); })); + + it('should keep the inline "name already exists" error after re-enabling the control', fakeAsync(() => { + const filter = createFilter([], { name: 'Test' }); + + initFixture(filter); + + const component = getFiltersComponent(); + + component.openSaveAsNewFilterPopover(); + fixture.detectChanges(); + flush(); + + component.filterName.setValue('Existing'); + component.filterSavedUnsuccessfully({ nameAlreadyExists: true }); + + // `enable()` re-runs the validators, so the custom error must be applied AFTER it — otherwise the + // inline `` never renders. Reverting the showError/enable order fails this. + expect(component.filterName.hasError('filterNameAlreadyExist')).toBe(true); + })); }); describe('saveAsNew round-trip after a failed save (P2-26)', () => { @@ -725,6 +751,7 @@ describe('KbqFilters', () => { // The inline error is shown, the saving state is released and the field is editable again. expect(component.showFilterSavingError).toBe(true); expect(component.filterSavingErrorText).toBe(component.localeData.errorHint); + expect(component.filterName.hasError('filterNameAlreadyExist')).toBe(true); expect(component.isSaving).toBe(false); expect(component.filterName.enabled).toBe(true); diff --git a/packages/components/filter-bar/filters.ts b/packages/components/filter-bar/filters.ts index 46f5945010..9185e36590 100644 --- a/packages/components/filter-bar/filters.ts +++ b/packages/components/filter-bar/filters.ts @@ -111,7 +111,14 @@ export class KbqFilters implements OnInit { /** Event that is generated whenever the user selects a filter. */ readonly onSelectFilter = output(); - /** Event that is generated whenever the user save a filter. */ + /** + * Event that is generated whenever the user save a filter. + * + * The emitted `filter` shares its pipe objects with the live filter by design (so the rendered pipes keep + * their identity across a save instead of being destroyed and recreated). Consumers that persist + * `event.filter` as an immutable record should snapshot it first (e.g. via `KbqFilterBar.saveFilterState` + * or a structural copy) — otherwise later in-place pipe edits will mutate the stored reference. + */ readonly onSave = output(); /** Event that is generated whenever the user change a filter. */ readonly onChangeFilter = output(); @@ -274,10 +281,13 @@ export class KbqFilters implements OnInit { /** Shows an error. Use this method in the onSave or onChangeFilter events if saving data failed. */ filterSavedUnsuccessfully(error?: KbqSaveFilterError) { - this.showError(error); - + // Re-enable the name control FIRST: `enable()` re-runs the control's validators and would wipe a + // custom `filterNameAlreadyExist` error set beforehand, so apply the error AFTER re-enabling — + // otherwise the inline "name already exists" message never renders. this.savePopover().savedUnsuccessfully(); + this.showError(error); + this.changeDetectorRef.markForCheck(); } diff --git a/packages/components/filter-bar/pipe-add.spec.ts b/packages/components/filter-bar/pipe-add.spec.ts index 20b56b5044..f01f0ad4f0 100644 --- a/packages/components/filter-bar/pipe-add.spec.ts +++ b/packages/components/filter-bar/pipe-add.spec.ts @@ -385,6 +385,31 @@ describe('KbqPipeAdd', () => { expect(liveRegion.nativeElement.textContent.trim()).toBe('Фильтр PipeA добавлен'); })); + it('should clear the live region before re-announcing so identical messages re-trigger AT', fakeAsync(() => { + const pipeAdd = getPipeAdd(); + const liveText = () => + fixture.debugElement + .query(By.directive(KbqPipeAdd)) + .query(By.css('[aria-live="polite"]')) + .nativeElement.textContent.trim(); + + pipeAdd.select().open(); + flush(); + fixture.detectChanges(); + + (document.querySelectorAll('.kbq-option')[0] as HTMLElement).click(); + + // The region is cleared synchronously on add ... + fixture.detectChanges(); + expect(liveText()).toBe(''); + + // ... then re-filled on the next macrotask, so even an identical consecutive message changes the + // text node and is re-announced (a plain same-string signal `set` would be a no-op AT never sees). + flush(); + fixture.detectChanges(); + expect(liveText()).toBe('Фильтр PipeA добавлен'); + })); + it('should call filterBar.openPipe.next when option is already selected', fakeAsync(() => { const filterBar = getFilterBar(); const pipeAdd = getPipeAdd(); diff --git a/packages/components/filter-bar/pipe-add.ts b/packages/components/filter-bar/pipe-add.ts index a541ae9433..79ddbd5540 100644 --- a/packages/components/filter-bar/pipe-add.ts +++ b/packages/components/filter-bar/pipe-add.ts @@ -121,9 +121,13 @@ export class KbqPipeAdd { this.onAddPipe.emit(option.value); - this.announcement.set( - this.filterBar.configuration.add.addedAnnouncement.replace('{{ name }}', option.value.name) - ); + const message = this.filterBar.configuration.add.addedAnnouncement.replace('{{ name }}', option.value.name); + + // Empty then re-fill on the next tick so an identical consecutive message still changes the + // live-region text node and is re-announced (a same-string `set` would be an `Object.is` no-op + // that assistive tech never picks up). + this.announcement.set(''); + setTimeout(() => this.announcement.set(message)); } this.select().close(); diff --git a/packages/components/filter-bar/pipes/pipe-date.scss b/packages/components/filter-bar/pipes/pipe-date.scss index abcc014e48..9d6f48868e 100644 --- a/packages/components/filter-bar/pipes/pipe-date.scss +++ b/packages/components/filter-bar/pipes/pipe-date.scss @@ -81,8 +81,10 @@ width: var(--kbq-filter-bar-date-field-width); & .kbq-form-field__container { - border-top-right-radius: unset; - border-bottom-right-radius: unset; + // Square off the seam (inline-end) — logical so it follows the inline-start overlap + // below under RTL (was border-top/bottom-right-radius). + border-start-end-radius: unset; + border-end-end-radius: unset; } } @@ -92,8 +94,10 @@ width: unset; & .kbq-form-field__container { - border-top-left-radius: unset; - border-bottom-left-radius: unset; + // Square off the seam (inline-start) — logical counterpart of the date field above + // (was border-top/bottom-left-radius). + border-start-start-radius: unset; + border-end-start-radius: unset; } } } diff --git a/packages/components/filter-bar/public-api.ts b/packages/components/filter-bar/public-api.ts index 2d0f0b7779..3610574f59 100644 --- a/packages/components/filter-bar/public-api.ts +++ b/packages/components/filter-bar/public-api.ts @@ -14,6 +14,7 @@ export * from './pipes/pipe-date'; export * from './pipes/pipe-date-base'; export * from './pipes/pipe-datetime'; export * from './pipes/pipe-multi-select'; +export * from './pipes/pipe-multi-tree-select'; export * from './pipes/pipe-readonly'; export * from './pipes/pipe-select'; export * from './pipes/pipe-state'; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 5d5f0439e4..173bf85a64 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -32,6 +32,7 @@ import { KbqTreeFlatDataSource } from '@koobiq/components/tree'; import { KbqTreeFlattener } from '@koobiq/components/tree'; import { KbqTreeOption } from '@koobiq/components/tree'; import { KbqTreeSelect } from '@koobiq/components/tree-select'; +import { KbqTreeSelection } from '@koobiq/components/tree'; import * as _koobiq_components_core from '@koobiq/components/core'; import { ModelSignal } from '@angular/core'; import { Observable } from 'rxjs'; @@ -782,6 +783,43 @@ export class KbqPipeMultiSelectComponent extends KbqBasePipe i static ɵfac: _angular_core.ɵɵFactoryDeclaration; } +// @public (undocumented) +export class KbqPipeMultiTreeSelectComponent extends KbqTreeSelectPipeBase implements OnInit { + constructor(); + get allOptionsSelected(): boolean; + get allVisibleOptionsSelected(): boolean; + get isEmpty(): boolean; + // (undocumented) + isNodeSelectAll(_: number, nodeData: KbqTreeSelectFlatNode): boolean; + // (undocumented) + ngOnInit(): void; + // (undocumented) + get numberOfSelectedLeaves(): number; + // (undocumented) + onClear(): void; + onClose(): void; + // (undocumented) + onSelect(input: { + value: any; + }): void; + // (undocumented) + searchKeydownHandler(): void; + // (undocumented) + get selectAllCheckboxState(): KbqPseudoCheckboxState; + selectAllHandler: (event: KeyboardEvent) => void; + get selected(): KbqSelectValue[] | null; + // (undocumented) + get selectedAllEqualsSelectedNothing(): boolean; + // (undocumented) + toggleSelectAllNode(emitEvent?: boolean): void; + readonly tree: _angular_core.Signal; + updateTemplates: (templates: KbqPipeTemplate[] | null) => void; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + // @public (undocumented) export class KbqPipeReadonlyComponent extends KbqBasePipe { // (undocumented) From 2cc9c9e2e609185b4814e42c03e76d019e0a25ee Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 9 Jul 2026 13:38:35 +0300 Subject: [PATCH 12/16] fix(filter-bar): address review findings (focus ring, save clone, API alias, specs) - restore `KbqFilterBarRefresher` as a `@deprecated` alias of `KbqFilterRefresher` so the public export rename is non-breaking (P2-1) - preserve the keyboard focus ring on programmatic focus: `showPeriod()` and `savedUnsuccessfully()` use `KbqButton.focusViaKeyboard()` instead of native `.focus()` (P2-2, P3-1) - guard `showPeriod()`'s deferred required-view-query reads with the `destroyed` flag to avoid NG0951 on teardown (P3-5) - `saveAsNew()` uses a shallow structural copy instead of `structuredClone`, matching `selectFilter()` so non-cloneable custom pipe values survive a save (P3-4) - tests: assert outside the `subscribe` callback in pipe-select spec, tighten `toBe(false)` in filters spec (+ `!!` the `opened` getter), and stub `focusViaKeyboard` in the date/datetime `showPeriod` specs (P3-2, P3-3) --- .../components/filter-bar/filter-save-popover.ts | 13 +++++++++---- packages/components/filter-bar/filters.spec.ts | 4 ++-- packages/components/filter-bar/filters.ts | 2 +- .../components/filter-bar/pipes/pipe-date-base.ts | 6 +++++- .../components/filter-bar/pipes/pipe-date.spec.ts | 6 +++--- .../filter-bar/pipes/pipe-datetime.spec.ts | 6 +++--- .../components/filter-bar/pipes/pipe-select.spec.ts | 6 +++++- packages/components/filter-bar/public-api.ts | 2 ++ tools/public_api_guard/components/filter-bar.api.md | 4 +++- 9 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/components/filter-bar/filter-save-popover.ts b/packages/components/filter-bar/filter-save-popover.ts index 02a6670b3e..19f452599e 100644 --- a/packages/components/filter-bar/filter-save-popover.ts +++ b/packages/components/filter-bar/filter-save-popover.ts @@ -175,9 +175,13 @@ export class KbqFilterSavePopover implements AfterViewInit { const name = this.filterName.value || ''; // @todo default filter - // Deep-clones the current filter; pipe `value` payloads must be structured-cloneable (see - // `KbqFilterBar.saveFilterState`). All built-in pipes produce cloneable values. - const filter = structuredClone(this.filterBar().filter() as KbqFilter) || { pipes: [] }; + // Shallow structural copy (new filter + new pipes array + per-pipe shallow copy), mirroring + // `KbqFilters.selectFilter`. Avoids `structuredClone` so non-cloneable custom pipe `value` + // payloads survive a save; every pipe writes `value` by reassignment, never in place. + const current = this.filterBar().filter(); + const filter: KbqFilter = current + ? { ...current, pipes: current.pipes.map((pipe) => ({ ...pipe })) } + : ({ pipes: [] } as unknown as KbqFilter); filter.name = name; filter.saved = true; @@ -232,7 +236,8 @@ export class KbqFilterSavePopover implements AfterViewInit { this.popoverTrigger().preventClose = false; this.filterName.enable(); - setTimeout(() => this.saveFilterButton().focus()); + // Focus via FocusMonitor (keyboard origin) so the focus ring is preserved on the retry path. + setTimeout(() => this.saveFilterButton().focusViaKeyboard()); this.changeDetectorRef.markForCheck(); } diff --git a/packages/components/filter-bar/filters.spec.ts b/packages/components/filter-bar/filters.spec.ts index dd1373ff29..4b3652ac46 100644 --- a/packages/components/filter-bar/filters.spec.ts +++ b/packages/components/filter-bar/filters.spec.ts @@ -219,7 +219,7 @@ describe('KbqFilters', () => { it('should return false when neither popover nor dropdown is open', () => { initFixture(); - expect(getFiltersComponent().opened).toBeFalsy(); + expect(getFiltersComponent().opened).toBe(false); }); }); @@ -227,7 +227,7 @@ describe('KbqFilters', () => { it('should return false by default', () => { initFixture(); - expect(getFiltersComponent().filterActionsOpened).toBeFalsy(); + expect(getFiltersComponent().filterActionsOpened).toBe(false); }); }); diff --git a/packages/components/filter-bar/filters.ts b/packages/components/filter-bar/filters.ts index 9185e36590..d32e195fb5 100644 --- a/packages/components/filter-bar/filters.ts +++ b/packages/components/filter-bar/filters.ts @@ -129,7 +129,7 @@ export class KbqFilters implements OnInit { /** Component state. true if opened dropdown or popup */ get opened(): boolean { - return this.popover()?.isOpen || this.dropdown()?.opened; + return !!(this.popover()?.isOpen || this.dropdown()?.opened); } /** Component state. true if opened dropdown or popup of filterActions */ diff --git a/packages/components/filter-bar/pipes/pipe-date-base.ts b/packages/components/filter-bar/pipes/pipe-date-base.ts index 36be0ff78a..ba0012d893 100644 --- a/packages/components/filter-bar/pipes/pipe-date-base.ts +++ b/packages/components/filter-bar/pipes/pipe-date-base.ts @@ -166,8 +166,12 @@ export abstract class KbqPipeDateBaseComponent extends KbqBasePipe { + if (this.destroyed) return; + this.popover().updatePosition(true); - this.returnButton().focus(); + // Focus via FocusMonitor (keyboard origin) so the focus ring is preserved; a native + // `.focus()` bypasses FocusMonitor and drops the `.cdk-keyboard-focused` ring. + this.returnButton().focusViaKeyboard(); }); } diff --git a/packages/components/filter-bar/pipes/pipe-date.spec.ts b/packages/components/filter-bar/pipes/pipe-date.spec.ts index c5487b5a3f..76d6b1289c 100644 --- a/packages/components/filter-bar/pipes/pipe-date.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-date.spec.ts @@ -264,10 +264,10 @@ describe('KbqPipeDateComponent', () => { const component = getPipeComponent(); const internal = asInternal(component); const updatePosition = jest.fn(); - const focus = jest.fn(); + const focusViaKeyboard = jest.fn(); internal.popover = () => ({ updatePosition }); - internal.returnButton = () => ({ focus }); + internal.returnButton = () => ({ focusViaKeyboard }); internal.showStartCalendar = true; internal.showEndCalendar = true; @@ -283,7 +283,7 @@ describe('KbqPipeDateComponent', () => { flush(); expect(updatePosition).toHaveBeenCalledWith(true); - expect(focus).toHaveBeenCalled(); + expect(focusViaKeyboard).toHaveBeenCalled(); })); }); diff --git a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts index 0ab9f7e21f..23e9793b0a 100644 --- a/packages/components/filter-bar/pipes/pipe-datetime.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-datetime.spec.ts @@ -264,10 +264,10 @@ describe('KbqPipeDatetimeComponent', () => { const component = getPipeComponent(); const internal = asInternal(component); const updatePosition = jest.fn(); - const focus = jest.fn(); + const focusViaKeyboard = jest.fn(); internal.popover = () => ({ updatePosition }); - internal.returnButton = () => ({ focus }); + internal.returnButton = () => ({ focusViaKeyboard }); internal.showStartCalendar = true; internal.showEndCalendar = true; @@ -283,7 +283,7 @@ describe('KbqPipeDatetimeComponent', () => { flush(); expect(updatePosition).toHaveBeenCalledWith(true); - expect(focus).toHaveBeenCalled(); + expect(focusViaKeyboard).toHaveBeenCalled(); })); }); diff --git a/packages/components/filter-bar/pipes/pipe-select.spec.ts b/packages/components/filter-bar/pipes/pipe-select.spec.ts index d6649ab22b..deecb41b92 100644 --- a/packages/components/filter-bar/pipes/pipe-select.spec.ts +++ b/packages/components/filter-bar/pipes/pipe-select.spec.ts @@ -366,10 +366,14 @@ describe('KbqPipeSelectComponent', () => { fixture.detectChanges(); const component = getPipeComponent(); + let lastFiltered: any[] = []; component.filteredOptions.subscribe((filtered) => { - expect(filtered.length).toBe(SELECT_VALUES.length); + lastFiltered = filtered; }); + flush(); + + expect(lastFiltered.length).toBe(SELECT_VALUES.length); })); it('should filter options by search text', fakeAsync(() => { diff --git a/packages/components/filter-bar/public-api.ts b/packages/components/filter-bar/public-api.ts index 3610574f59..4ecf89e243 100644 --- a/packages/components/filter-bar/public-api.ts +++ b/packages/components/filter-bar/public-api.ts @@ -3,6 +3,8 @@ export * from './filter-bar-button'; export * from './filter-bar.module'; export * from './filter-bar.types'; export * from './filter-refresher'; +/** @deprecated Use `KbqFilterRefresher` instead. Backward-compatible alias; will be removed in the next major. */ +export { KbqFilterRefresher as KbqFilterBarRefresher } from './filter-refresher'; export * from './filter-reset'; export * from './filter-save-popover'; export * from './filters'; diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index 173bf85a64..af066986e3 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -356,7 +356,7 @@ export class KbqFilterBarModule { export const kbqFilterBarPipesProvider: () => Provider; // @public (undocumented) -export class KbqFilterRefresher { +class KbqFilterRefresher { protected get localeData(): { reset: { buttonName: string; @@ -411,6 +411,8 @@ export class KbqFilterRefresher { // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } +export { KbqFilterRefresher as KbqFilterBarRefresher } +export { KbqFilterRefresher } // @public (undocumented) export class KbqFilterReset { From 5aaadfc4d13edaa2c3e6f6ef9696b78be2a79738 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 9 Jul 2026 14:38:31 +0300 Subject: [PATCH 13/16] fix: names in spec files --- .../filter-bar/pipes/pipe-states.spec-helper.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts b/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts index 5b6f4c93da..fb71eb2d14 100644 --- a/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts +++ b/packages/components/filter-bar/pipes/pipe-states.spec-helper.ts @@ -4,20 +4,20 @@ import { By } from '@angular/platform-browser'; import { KbqFilter, KbqPipe } from '@koobiq/components/filter-bar'; /** Minimal host contract shared by every pipe spec's `TestComponent`. */ -export interface KbqPipeStatesHost { +export interface PipeStatesHost { activeFilter: KbqFilter | null; } /** Live, per-test context created by each spec inside the shared `beforeEach`. */ -export interface KbqPipeStatesContext { +export interface PipeStatesContext { /** Freshly created fixture whose host exposes `activeFilter`. */ - fixture: ComponentFixture; + fixture: ComponentFixture; /** Debug element of the `KbqFilterBar` rendered by the host. */ filterBar: DebugElement; } /** Configuration passed by a pipe spec to register the shared "Pipe states" suite. */ -export interface KbqPipeStatesConfig { +export interface PipeStatesConfig { /** Human-readable pipe label, used only in the "should render all X pipes" test name. */ label: string; /** Type modifier class every rendered pipe of this kind carries, e.g. `'kbq-pipe__text'`. */ @@ -35,7 +35,7 @@ export interface KbqPipeStatesConfig { * Creates the fixture and resolves the filter-bar debug element. Invoked inside the shared * `beforeEach`, i.e. after the spec's own `TestBed.configureTestingModule` has run. */ - createContext: () => KbqPipeStatesContext; + createContext: () => PipeStatesContext; } /** @@ -57,7 +57,7 @@ type PipeStateName = (typeof PIPE_STATE_DESCRIPTORS)[number]['name']; * same five state fixtures and asserts the CSS modifier classes `KbqBasePipe` applies to the host * `.kbq-pipe` element, so the block is identical modulo the per-type value and modifier class. */ -export const registerPipeStatesTests = (config: KbqPipeStatesConfig): void => { +export const registerPipeStatesTests = (config: PipeStatesConfig): void => { describe('Pipe states', () => { let filterBar: DebugElement; From c7d059b8ae3e36af0ae6cdc096a22c3ae1569170 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 9 Jul 2026 18:37:15 +0300 Subject: [PATCH 14/16] fix(tree-select): enable option hover highlight without a wrapping form-field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option hover-to-focus in kbq-tree-select is gated by KbqTreeSelection.inSelect, which was derived solely from a wrapping kbq-form-field (KBQ_FORM_FIELD_REF). The filter-bar tree-select pipes (KbqPipeTreeSelectComponent, KbqPipeMultiTreeSelectComponent) render the tree-select bare, so inSelect stayed false and hovering options stopped highlighting them — a regression from #DS-4631, which moved the highlight from CSS :hover to the .kbq-focused/inSelect path. KbqTreeSelect now marks its projected KbqTreeSelection as inSelect in ngAfterContentInit, so hover works whenever the tree lives inside a select panel, regardless of a form-field wrapper. Standalone kbq-tree-selection is unaffected. --- .../tree-select/tree-select.component.spec.ts | 69 ++++++++++++++++++- .../tree-select/tree-select.component.ts | 4 ++ .../tree/tree-selection.component.ts | 9 ++- tools/public_api_guard/components/tree.api.md | 2 +- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index dcbd0367ee..788542c718 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -69,7 +69,8 @@ import { KbqTreeFlatDataSource, KbqTreeFlattener, KbqTreeModule, - KbqTreeOption + KbqTreeOption, + KbqTreeSelection } from '@koobiq/components/tree'; import { Observable, Subject, map, of, timer } from 'rxjs'; import { KbqTreeSelect, KbqTreeSelectChange, KbqTreeSelectPanelWidth } from './tree-select.component'; @@ -358,6 +359,40 @@ class BasicTreeSelect { } } +/** + * Tree-select rendered WITHOUT a wrapping `kbq-form-field` — mirrors how the filter-bar tree-select + * pipes render the control bare. Used to guard option hover-to-focus in that setup (#DS-5302). + */ +@Component({ + selector: 'tree-select-without-form-field', + imports: [ + KbqTreeModule, + KbqTreeSelectModule + ], + template: ` + + + + {{ treeControl.getViewValue(node) }} + + + + ` +}) +class TreeSelectWithoutFormField { + treeControl = new FlatTreeControl(getLevel, isExpandable, getValue, getValue); + treeFlattener = new KbqTreeFlattener(transformer, getLevel, isExpandable, getChildren); + dataSource: KbqTreeFlatDataSource; + + readonly select = viewChild.required(KbqTreeSelect); + readonly treeSelection = viewChild(KbqTreeSelection); + + constructor() { + this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); + this.dataSource.data = buildFileTree(TREE_DATA, 0); + } +} + @Component({ selector: 'basic-events', imports: [ @@ -4707,4 +4742,36 @@ describe('KbqTreeSelect', () => { expect(pane.style.width).toBe('344px'); }); }); + + describe('option hover without a wrapping form-field (#DS-5302)', () => { + beforeEach(() => { + configureKbqTreeSelectTestingModule([TreeSelectWithoutFormField]); + }); + + it('highlights the hovered option even though the tree-select has no form-field ancestor', fakeAsync(() => { + const fixture = TestBed.createComponent(TreeSelectWithoutFormField); + + fixture.detectChanges(); + + const trigger = fixture.debugElement.query(By.directive(KbqTreeSelect)).nativeElement; + + trigger.click(); + fixture.detectChanges(); + flush(); + + // Hover-to-focus on tree-select options is gated by `KbqTreeSelection.inSelect`. It used to + // be derived solely from a wrapping `kbq-form-field`, so a bare tree-select (like the + // filter-bar pipes) left it false and hover did nothing. The host `KbqTreeSelect` now + // forces it true. + expect(fixture.componentInstance.treeSelection()?.inSelect).toBe(true); + + const option = overlayContainerElement.querySelector('kbq-tree-option') as HTMLElement; + + dispatchFakeEvent(option, 'mouseenter'); + flush(); + fixture.detectChanges(); + + expect(option.classList).toContain('kbq-focused'); + })); + }); }); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index fab3848f14..a47e7e432b 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -762,6 +762,10 @@ export class KbqTreeSelect this.options = tree.renderedOptions; tree.autoSelect = this.autoSelect; + // Tree lives inside the select panel: enable hover-to-focus on options even without a + // wrapping form-field (e.g. filter-bar pipes render the tree-select bare). + tree.inSelect = true; + if (tree.multipleMode === null) { // setTimeout need for prevent an error "NG0100: ExpressionChangedAfterItHasBeenCheckedError" setTimeout(() => (this.tree()!.multipleMode = this.multiSelection ? MultipleMode.CHECKBOX : null)); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index a6e7c093a0..e882f5c7ac 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -140,8 +140,13 @@ export class KbqTreeSelection private clipboard = inject(Clipboard, { optional: true }); protected readonly focusMonitor = inject(FocusMonitor); - /** Indicates whether this component is placed inside a KbqFormField component. */ - protected readonly inSelect = !!inject(KBQ_FORM_FIELD_REF, { optional: true, host: true }); + /** + * Whether this tree-selection is rendered inside a select panel (enables option + * hover-to-focus). Defaults to detecting a wrapping `KbqFormField`, and is forced to `true` + * by the host `KbqTreeSelect` so hover works even when the tree-select has no form-field + * wrapper (e.g. the filter-bar pipes render the tree-select bare). + */ + inSelect = !!inject(KBQ_FORM_FIELD_REF, { optional: true, host: true }); renderedOptions = new QueryList(); diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index 0aec89e862..96bb497cb2 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -563,7 +563,7 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc getSelectedValues(): any[]; // (undocumented) highlightSelectedOption(): void; - protected readonly inSelect: boolean; + inSelect: boolean; // (undocumented) get isEmpty(): boolean; // (undocumented) From 48e3f0d6434a415babf4a5271c0269fb2b093bcf Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 9 Jul 2026 19:28:30 +0300 Subject: [PATCH 15/16] fix: after review --- packages/components/filter-bar/filter-bar.ts | 7 +++++++ .../components/filter-bar/filter-save-popover.ts | 12 ++++++------ .../filter-bar/pipes/multi-select-pipe-state.ts | 4 +++- .../components/filter-bar/pipes/pipe-button.ts | 14 ++++++-------- .../filter-bar/pipes/tree-select-pipe-base.ts | 8 ++++++-- .../public_api_guard/components/filter-bar.api.md | 2 ++ 6 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/components/filter-bar/filter-bar.ts b/packages/components/filter-bar/filter-bar.ts index 7a6d6a5d18..fc6abde120 100644 --- a/packages/components/filter-bar/filter-bar.ts +++ b/packages/components/filter-bar/filter-bar.ts @@ -116,6 +116,13 @@ export class KbqFilterBar implements KbqFilterBarHost { private savedFilter: KbqFilter | null = null; + /** + * All changes. + * @deprecated noop. Reactivity is driven by the `filter` signal now; this never emits and will be + * removed in the next major. + */ + readonly changes = new BehaviorSubject(undefined); + /** Event that emits whenever the filter is reset. */ readonly onResetFilter = new BehaviorSubject(false); /** internal filter changes */ diff --git a/packages/components/filter-bar/filter-save-popover.ts b/packages/components/filter-bar/filter-save-popover.ts index 19f452599e..5b45864fdb 100644 --- a/packages/components/filter-bar/filter-save-popover.ts +++ b/packages/components/filter-bar/filter-save-popover.ts @@ -3,7 +3,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - DestroyRef, ElementRef, inject, input, @@ -103,7 +102,6 @@ import { KbqFilter, KbqSaveFilterError, KbqSaveFilterEvent, KbqSaveFilterStatuse encapsulation: ViewEncapsulation.None }) export class KbqFilterSavePopover implements AfterViewInit { - private readonly destroyRef = inject(DestroyRef); private readonly changeDetectorRef = inject(ChangeDetectorRef); /** Popover trigger owned by the parent (`KbqFilters` main button). */ @@ -245,14 +243,16 @@ export class KbqFilterSavePopover implements AfterViewInit { private preparePopover() { this.filterName = new FormControl(this.filterBar().filter()?.name || '', Validators.required); - this.filterName.valueChanges - .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => (this.showFilterSavingError = false)); - const trigger = this.popoverTrigger(); trigger.show(); + // Tie to the popover instance (recreated each open) so this per-open control's subscription + // doesn't accumulate across open/close cycles. + this.filterName.valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed(trigger.instanceDestroyRef)) + .subscribe(() => (this.showFilterSavingError = false)); + merge(...trigger.defaultClosingActions()) .pipe( filter(() => !this.isSaving), diff --git a/packages/components/filter-bar/pipes/multi-select-pipe-state.ts b/packages/components/filter-bar/pipes/multi-select-pipe-state.ts index 36870051f8..8ed5bb138e 100644 --- a/packages/components/filter-bar/pipes/multi-select-pipe-state.ts +++ b/packages/components/filter-bar/pipes/multi-select-pipe-state.ts @@ -23,7 +23,9 @@ export class KbqMultiSelectPipeState { /** Whether selecting every option is treated as selecting nothing. */ get selectedAllEqualsSelectedNothing(): boolean { return ( - this.host.data.selectedAllEqualsSelectedNothing ?? this.host.filterBar!.selectedAllEqualsSelectedNothing() + this.host.data.selectedAllEqualsSelectedNothing ?? + this.host.filterBar?.selectedAllEqualsSelectedNothing() ?? + true ); } diff --git a/packages/components/filter-bar/pipes/pipe-button.ts b/packages/components/filter-bar/pipes/pipe-button.ts index 3179282220..3d0807ecf8 100644 --- a/packages/components/filter-bar/pipes/pipe-button.ts +++ b/packages/components/filter-bar/pipes/pipe-button.ts @@ -65,13 +65,11 @@ export class KbqPipeButton { constructor() { this.pipe.stateChanges.pipe(takeUntilDestroyed()).subscribe(() => this.changeDetectorRef.markForCheck()); - // The pipe template renders plain `data` (not signals), so mark it for check when the filter - // changes. Passing the `filterBar.filter` read into the handler subscribes this effect, replacing - // the retired `changes` bus. - effect(() => this.markForCheckOnFilterChange(this.filterBar.filter())); - } - - private markForCheckOnFilterChange(_filter: unknown): void { - this.changeDetectorRef.markForCheck(); + // Reading the filter signal subscribes this effect; the pipe template renders plain `data` + // (not signals), so mark it for check whenever the filter changes — replaces the retired `changes` bus. + effect(() => { + this.filterBar.filter(); + this.changeDetectorRef.markForCheck(); + }); } } diff --git a/packages/components/filter-bar/pipes/tree-select-pipe-base.ts b/packages/components/filter-bar/pipes/tree-select-pipe-base.ts index 30051ca63c..63b7891d63 100644 --- a/packages/components/filter-bar/pipes/tree-select-pipe-base.ts +++ b/packages/components/filter-bar/pipes/tree-select-pipe-base.ts @@ -64,7 +64,11 @@ export abstract class KbqTreeSelectPipeBase extends KbqBasePipe implements /** opens the tree-select panel */ override open() { - setTimeout(() => this.select().open()); + setTimeout(() => { + if (this.destroyed) return; + + this.select().open(); + }); } /** @docs-private */ @@ -92,5 +96,5 @@ export abstract class KbqTreeSelectPipeBase extends KbqBasePipe implements protected getValue = (node: KbqTreeSelectFlatNode): unknown => node.value; - protected getViewValue = (node: KbqTreeSelectFlatNode): string => `${node.name}`; + protected getViewValue = (node: KbqTreeSelectFlatNode): string => node.name; } diff --git a/tools/public_api_guard/components/filter-bar.api.md b/tools/public_api_guard/components/filter-bar.api.md index af066986e3..bd2c9c21ae 100644 --- a/tools/public_api_guard/components/filter-bar.api.md +++ b/tools/public_api_guard/components/filter-bar.api.md @@ -227,6 +227,8 @@ export interface KbqFilter { export class KbqFilterBar implements KbqFilterBarHost { constructor(); protected readonly changeDetectorRef: ChangeDetectorRef; + // @deprecated + readonly changes: BehaviorSubject; configuration: KbqFilterBarConfiguration; // (undocumented) readonly externalConfiguration: { From f1538c38e1cf3e3b3eecd61b0a65488d911fa276 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Fri, 10 Jul 2026 15:07:53 +0300 Subject: [PATCH 16/16] fix: added schematics --- packages/schematics/src/collection.json | 5 + packages/schematics/src/migrations.json | 5 + .../migrations/filter-bar-signals/README.md | 66 +++ .../src/migrations/filter-bar-signals/data.ts | 82 ++++ .../filter-bar-signals/index.spec.ts | 223 ++++++++++ .../migrations/filter-bar-signals/index.ts | 407 ++++++++++++++++++ .../migrations/filter-bar-signals/schema.json | 20 + .../migrations/filter-bar-signals/schema.ts | 6 + 8 files changed, 814 insertions(+) create mode 100644 packages/schematics/src/migrations/filter-bar-signals/README.md create mode 100644 packages/schematics/src/migrations/filter-bar-signals/data.ts create mode 100644 packages/schematics/src/migrations/filter-bar-signals/index.spec.ts create mode 100644 packages/schematics/src/migrations/filter-bar-signals/index.ts create mode 100644 packages/schematics/src/migrations/filter-bar-signals/schema.json create mode 100644 packages/schematics/src/migrations/filter-bar-signals/schema.ts diff --git a/packages/schematics/src/collection.json b/packages/schematics/src/collection.json index 5454b21c0e..cb4f0c4f57 100644 --- a/packages/schematics/src/collection.json +++ b/packages/schematics/src/collection.json @@ -41,6 +41,11 @@ "description": "Migrates deprecated API removed in @koobiq/components v20.0.0", "factory": "./migrations/v20-upgrade/index", "schema": "./migrations/v20-upgrade/schema.json" + }, + "filter-bar-signals": { + "description": "Migrates KbqFilterBar consumers to the signal-based API (filter/pipeTemplates/isChanged/... reads become calls)", + "factory": "./migrations/filter-bar-signals/index", + "schema": "./migrations/filter-bar-signals/schema.json" } } } diff --git a/packages/schematics/src/migrations.json b/packages/schematics/src/migrations.json index 1a670abe91..11dde72404 100644 --- a/packages/schematics/src/migrations.json +++ b/packages/schematics/src/migrations.json @@ -5,6 +5,11 @@ "version": "20.0.0-0", "description": "Migrates code from deprecated APIs removed in @koobiq/components v20.0.0. Handles package moves (@koobiq/cdk/{a11y,keycodes,testing} → @koobiq/components/core, navbar-ic, risk-level, experimental form-field), TypeScript identifier renames, template selector renames (e.g. , ), attribute renames (kbqFormFieldWithoutBorders → noBorders), tooltip modifier inputs, and emits warnings for structural changes that need manual migration (onSaveAsNew, file-upload customValidation/errors, app-switcher apps).", "factory": "./migrations/v20-upgrade/index" + }, + "filter-bar-signals": { + "version": "21.0.0-0", + "description": "Migrates KbqFilterBar consumers to the signal-based API. Rewrites programmatic reads of the now-signal members (filter, pipeTemplates, isChanged, isDisabled, isReadOnly, isSaved, isSavedAndChanged) to calls and `filter = v` writes to `filter.set(v)` on receivers typed KbqFilterBar, including reads via template reference variables on . Renames KbqFilterBarRefresher → KbqFilterRefresher and warns on changes/preparePopover/view-query cases that need manual migration.", + "factory": "./migrations/filter-bar-signals/index" } } } diff --git a/packages/schematics/src/migrations/filter-bar-signals/README.md b/packages/schematics/src/migrations/filter-bar-signals/README.md new file mode 100644 index 0000000000..f25218db2d --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/README.md @@ -0,0 +1,66 @@ +# filter-bar-signals + +Migration schematic invoked automatically by `ng update @koobiq/components@21`. +Migrates consumers of `KbqFilterBar` to its signal-based public API. + +## Why + +`KbqFilterBar` moved several members from accessors/getters to signals. Reading +them programmatically now requires a call, and assigning `filter` now goes +through `.set(...)`. Template _bindings_ (`[filter]`, `[(filter)]`, +`[pipeTemplates]`) keep working — only reads break. + +| Member | Before | After | +| --------------------------------------------------------------------------- | -------- | ----------------- | +| `filter` | accessor | `ModelSignal` | +| `pipeTemplates` | accessor | `InputSignal` | +| `isChanged` / `isDisabled` / `isReadOnly` / `isSaved` / `isSavedAndChanged` | getter | `Signal` | + +## What it does + +The schematic walks every `.ts` and `.html` file in the project (skipping +`node_modules` and `dist`) and, for files that reference the filter bar: + +- **TypeScript reads/writes.** For a receiver whose static type is annotated + `KbqFilterBar` / `KbqFilterBarHost` (method/function params, class fields — + including `@ViewChild(KbqFilterBar) x: KbqFilterBar` and constructor + parameter-properties — and typed locals), a read of a signal member becomes a + call and a `filter` write becomes `.set(...)`: + - `filterBar.filter` → `filterBar.filter()` (incl. optional chain `filterBar.filter?.name` → `filterBar.filter()?.name`) + - `filterBar.filter = next` → `filterBar.filter.set(next)` + - `this.filterBar.isChanged` → `this.filterBar.isChanged()` +- **Template reference reads.** For a `#ref` bound to ``, reads + of signal members through that ref are rewritten in the same template + (external `.html` and inline `template:` strings): `ref.isChanged` → + `ref.isChanged()`. +- **Rename.** `KbqFilterBarRefresher` → `KbqFilterRefresher` (the old name is + still re-exported as an alias, so this is optional cleanup). + +All rewrites are idempotent — running twice does not double the call. + +## What it does _not_ do (warn-only) + +Some changes require code restructuring and are surfaced as warnings without +auto-fixing: + +| Pattern | Manual migration | +| -------------------------------- | -------------------------------------------------------------------------------------------------- | +| `.changes` (KbqFilterBar) | Deprecated no-op; read `filterBar.filter()` inside an `effect(...)`, or listen to `(filterChange)` | +| `.preparePopover()` (KbqFilters) | Removed; use `openSaveAsNewFilterPopover()` / `openChangeFilterNamePopover()` | +| `viewChild(KbqFilterBar)` query | Returns the instance, so reads are a double call: `this.filterBar().filter()` — verify manually | +| `KBQ_FILTER_BAR_PIPES` | Now typed `Map>`; wrap direct providers in `new Map([...])` | + +## Running it manually + +``` +ng generate @koobiq/components:filter-bar-signals --project my-app +``` + +Pass `--fix=false` to see what would change without writing files. + +## Limitations + +Receivers are matched by explicit `KbqFilterBar` type annotation only (no +cross-package type inference), so aliased/inferred receivers +(`const fb = this.filterBar; fb.filter`) are left untouched and covered by the +warnings above. After running, **always inspect the diff** before committing. diff --git a/packages/schematics/src/migrations/filter-bar-signals/data.ts b/packages/schematics/src/migrations/filter-bar-signals/data.ts new file mode 100644 index 0000000000..7bb4119c1b --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/data.ts @@ -0,0 +1,82 @@ +/** + * Data for the `filter-bar-signals` migration. + * + * `KbqFilterBar` moved several members from accessors/getters to signals. Reading them programmatically + * (in `.ts` code or via a template reference variable) now requires a call: + * + * - `filterBar.filter` → `filterBar.filter()` (write `= v` → `.set(v)`) + * - `filterBar.pipeTemplates` → `filterBar.pipeTemplates()` + * - `filterBar.isChanged` → `filterBar.isChanged()` (also isDisabled/isReadOnly/isSaved/isSavedAndChanged) + * + * Template *bindings* (`[filter]`, `[(filter)]`, `[pipeTemplates]`) keep working — only reads break. + */ + +/** Members of `KbqFilterBar` that became signals; a read of any of them must become a call. */ +export const SIGNAL_MEMBERS: readonly string[] = [ + 'filter', + 'pipeTemplates', + 'isChanged', + 'isDisabled', + 'isReadOnly', + 'isSaved', + 'isSavedAndChanged' +]; + +/** Signal members that are writable (`model()`); `x.filter = v` migrates to `x.filter.set(v)`. */ +export const WRITABLE_MEMBERS: ReadonlySet = new Set(['filter']); + +/** TypeScript type annotations that mark a receiver as a filter bar. */ +export const FILTER_BAR_TYPE = 'KbqFilterBar'; +export const FILTER_BAR_HOST_TYPE = 'KbqFilterBarHost'; + +/** Element selector whose template reference variables (`#ref`) point at a filter bar. */ +export const FILTER_BAR_ELEMENT = 'kbq-filter-bar'; + +export interface Replacement { + from: string; + to: string; +} + +/** + * Safe, idempotent identifier renames applied to `.ts` files. The `\b…\b` boundaries make each rule + * idempotent. `KbqFilterBarRefresher` is still re-exported as an alias of `KbqFilterRefresher`, so this + * is optional cleanup that can never break a consumer build. + */ +export const tsRenameReplacements: Replacement[] = [{ from: '\\bKbqFilterBarRefresher\\b', to: 'KbqFilterRefresher' }]; + +export interface WarnPattern { + pattern: string; + message: string; +} + +/** + * Patterns that can't be auto-fixed reliably — surfaced with file locations (in both `fix` and dry-run + * mode). Only evaluated for files that reference the filter bar, so the broad `.changes` pattern stays scoped. + */ +export const warnPatterns: WarnPattern[] = [ + { + pattern: '\\.changes\\b', + message: + 'KbqFilterBar.changes is deprecated and no longer emits. Reactivity is driven by the `filter` ' + + 'signal now — read it with `filterBar.filter()` inside an `effect(...)`, or listen to the ' + + '`(filterChange)` output. (Best-effort match — ignore if this `.changes` is unrelated.)' + }, + { + pattern: '\\.preparePopover\\(', + message: + 'KbqFilters.preparePopover() was removed. Use openSaveAsNewFilterPopover() / ' + + 'openChangeFilterNamePopover() instead.' + }, + { + pattern: '(?:viewChild|ViewChild)[^\\n;]*\\bKbqFilterBar\\b', + message: + 'A KbqFilterBar view/signal query returns the component instance, whose filter is now a signal — ' + + 'reading it is a double call, e.g. `this.filterBar().filter()`. Verify query reads manually.' + }, + { + pattern: '\\bKBQ_FILTER_BAR_PIPES\\b', + message: + 'KBQ_FILTER_BAR_PIPES is now typed as Map> (was an array of tuples). ' + + 'If you provide it directly, wrap the entries in `new Map([...])`.' + } +]; diff --git a/packages/schematics/src/migrations/filter-bar-signals/index.spec.ts b/packages/schematics/src/migrations/filter-bar-signals/index.spec.ts new file mode 100644 index 0000000000..5747fb2dfa --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/index.spec.ts @@ -0,0 +1,223 @@ +import { workspaces } from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { getWorkspace } from '@schematics/angular/utility/workspace'; +import * as path from 'path'; +import { createTestApp } from '../../utils/testing'; +import { Schema } from './schema'; + +const collectionPath = path.join(__dirname, '../../collection.json'); +const SCHEMATIC_NAME = 'filter-bar-signals'; + +describe(SCHEMATIC_NAME, () => { + let runner: SchematicTestRunner; + let appTree: Tree; + let projects: workspaces.ProjectDefinitionCollection; + let messages: string[]; + + beforeEach(async () => { + runner = new SchematicTestRunner('schematics', collectionPath); + appTree = await createTestApp(runner, { style: 'scss' }); + const workspace = await getWorkspace(appTree); + + projects = workspace.projects as unknown as workspaces.ProjectDefinitionCollection; + + messages = []; + runner.logger.subscribe((entry) => messages.push(entry.message)); + }); + + function paths(project: workspaces.ProjectDefinition) { + // The exact file names from @schematics/angular:application vary across versions + // (app.ts vs app.component.ts, app.html vs app.component.html). Discover them from the tree. + const root = `/${project.root}/src/app`; + const ts = appTree.exists(`${root}/app.ts`) ? `${root}/app.ts` : `${root}/app.component.ts`; + const html = appTree.exists(`${root}/app.html`) ? `${root}/app.html` : `${root}/app.component.html`; + + return { ts, html }; + } + + async function run(fix: boolean = true): Promise { + const [first] = projects.keys(); + + return runner.runSchematic(SCHEMATIC_NAME, { project: first, fix } satisfies Schema, appTree); + } + + function firstTsPath(): string { + const [first] = projects.keys(); + + return paths(projects.get(first)!).ts; + } + + function firstHtmlPath(): string { + const [first] = projects.keys(); + + return paths(projects.get(first)!).html; + } + + it('rewrites reads on a parameter typed KbqFilterBar (incl. optional chain) to calls', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' check(filterBar: KbqFilterBar) {\n' + + ' return filterBar.filter?.name;\n' + + ' }\n' + + '}\n' + ); + + const updated = (await run()).readText(ts); + + expect(updated).toContain('filterBar.filter()?.name'); + expect(updated).not.toMatch(/filterBar\.filter\?\./); + }); + + it('rewrites a `filter` write to `filter.set(...)`', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqFilterBar, KbqFilter } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' apply(filterBar: KbqFilterBar, next: KbqFilter) {\n' + + ' filterBar.filter = next;\n' + + ' }\n' + + '}\n' + ); + + const updated = (await run()).readText(ts); + + expect(updated).toContain('filterBar.filter.set(next);'); + }); + + it('rewrites reads on a @ViewChild field (this.filterBar) and the computed signal getters', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { ViewChild } from '@angular/core';\n" + + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' @ViewChild(KbqFilterBar) filterBar: KbqFilterBar;\n' + + ' get changed() {\n' + + ' return this.filterBar.isChanged && this.filterBar.filter;\n' + + ' }\n' + + '}\n' + ); + + const updated = (await run()).readText(ts); + + expect(updated).toContain('this.filterBar.isChanged() && this.filterBar.filter()'); + }); + + it('leaves unrelated .filter (e.g. Array.prototype.filter) untouched', async () => { + const ts = firstTsPath(); + const source = + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' ids(filterBar: KbqFilterBar) {\n' + + ' const list = [1, 2, 3];\n' + + ' return list.filter((x) => x > 1);\n' + + ' }\n' + + '}\n'; + + appTree.overwrite(ts, source); + + const updated = (await run()).readText(ts); + + expect(updated).toContain('list.filter((x) => x > 1)'); + expect(updated).not.toContain('list.filter()'); + }); + + it('is idempotent — a second run does not double the call', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' check(filterBar: KbqFilterBar) {\n' + + ' return filterBar.filter?.name;\n' + + ' }\n' + + '}\n' + ); + + const once = (await run()).readText(ts); + + appTree.overwrite(ts, once); + + const twice = (await run()).readText(ts); + + expect(twice).toBe(once); + expect(twice).not.toContain('filter()()'); + }); + + it('rewrites reads via a template reference variable on ', async () => { + const html = firstHtmlPath(); + + appTree.overwrite( + html, + '\n' + '{{ filterBar.isChanged }}\n' + ); + + const updated = (await run()).readText(html); + + expect(updated).toContain('{{ filterBar.isChanged() }}'); + }); + + it('renames KbqFilterBarRefresher to KbqFilterRefresher', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqFilterBar, KbqFilterBarRefresher } from '@koobiq/components/filter-bar';\n" + + 'const refs = [KbqFilterBar, KbqFilterBarRefresher];\n' + ); + + const updated = (await run()).readText(ts); + + expect(updated).toContain('KbqFilterRefresher'); + expect(updated).not.toContain('KbqFilterBarRefresher'); + }); + + it('warns about the deprecated `changes` and the removed `preparePopover`', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' wire(filterBar: KbqFilterBar) {\n' + + ' filterBar.changes.subscribe(() => {});\n' + + ' filterBar.preparePopover();\n' + + ' }\n' + + '}\n' + ); + + await run(); + + const all = messages.join('\n'); + + expect(all).toContain('KbqFilterBar.changes is deprecated'); + expect(all).toContain('preparePopover() was removed'); + }); + + it('does not write files in dry-run mode (fix = false) but reports would-update', async () => { + const ts = firstTsPath(); + const source = + "import { KbqFilterBar } from '@koobiq/components/filter-bar';\n" + + 'class Demo {\n' + + ' check(filterBar: KbqFilterBar) {\n' + + ' return filterBar.filter;\n' + + ' }\n' + + '}\n'; + + appTree.overwrite(ts, source); + + const updated = (await run(false)).readText(ts); + + expect(updated).toBe(source); + expect(messages.join('\n')).toContain('would update'); + }); +}); diff --git a/packages/schematics/src/migrations/filter-bar-signals/index.ts b/packages/schematics/src/migrations/filter-bar-signals/index.ts new file mode 100644 index 0000000000..5483e7adaa --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/index.ts @@ -0,0 +1,407 @@ +import { Path } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import ts from 'typescript'; +import { visitAll, Visitor } from '../../utils/ast'; +import { logMessage } from '../../utils/messages'; +import { setupOptions } from '../../utils/package-config'; +import { forEachClass, parseTemplate } from '../../utils/typescript'; +import { + FILTER_BAR_ELEMENT, + FILTER_BAR_HOST_TYPE, + FILTER_BAR_TYPE, + SIGNAL_MEMBERS, + tsRenameReplacements, + warnPatterns, + WRITABLE_MEMBERS +} from './data'; +import { Schema } from './schema'; + +const TS_EXT = '.ts'; +const HTML_EXT = '.html'; + +/** A text-span edit on the original file content. Applied right-to-left so offsets stay valid. */ +interface Edit { + start: number; + end: number; + text: string; +} + +/** A receiver whose static type is a filter bar, valid within `[start, end]` of the source. */ +interface Receiver { + /** Source text of the receiver expression, e.g. `filterBar` or `this.filterBar`. */ + text: string; + start: number; + end: number; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Applies text-span edits to `content`, right-to-left, so earlier edits don't shift later offsets. */ +function applyEdits(content: string, edits: Edit[]): string { + const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end); + let result = content; + + for (const { start, end, text } of sorted) { + result = result.slice(0, start) + text + result.slice(end); + } + + return result; +} + +const isFunctionLike = (node: ts.Node): boolean => + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node); + +/** Walks up from `node` to the nearest ancestor matching `predicate`. */ +function findAncestor(node: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node | undefined { + let current = node.parent; + + while (current) { + if (predicate(current)) return current; + current = current.parent; + } + + return undefined; +} + +/** Whether a type annotation refers to the filter bar (`KbqFilterBar` / `KbqFilterBarHost`). */ +function isFilterBarType(type: ts.TypeNode | undefined): boolean { + return ( + !!type && + ts.isTypeReferenceNode(type) && + ts.isIdentifier(type.typeName) && + (type.typeName.text === FILTER_BAR_TYPE || type.typeName.text === FILTER_BAR_HOST_TYPE) + ); +} + +const FIELD_MODIFIERS = new Set([ + ts.SyntaxKind.PrivateKeyword, + ts.SyntaxKind.PublicKeyword, + ts.SyntaxKind.ProtectedKeyword, + ts.SyntaxKind.ReadonlyKeyword +]); + +/** + * Collects the receivers whose static type is a filter bar, by explicit annotation only (no cross-package + * type resolution): method/function params, class fields (incl. `@ViewChild(KbqFilterBar) x: KbqFilterBar` + * and constructor parameter-properties) and typed locals. + */ +function collectReceivers(sourceFile: ts.SourceFile): Receiver[] { + const receivers: Receiver[] = []; + const add = (text: string, scope: ts.Node) => + receivers.push({ text, start: scope.getStart(sourceFile), end: scope.getEnd() }); + + const visit = (node: ts.Node): void => { + if (ts.isParameter(node) && ts.isIdentifier(node.name) && isFilterBarType(node.type)) { + add(node.name.text, findAncestor(node, isFunctionLike) ?? sourceFile); + + // A constructor parameter-property is also a class field, reachable as `this.`. + if (node.modifiers?.some((modifier) => FIELD_MODIFIERS.has(modifier.kind))) { + const owner = findAncestor(node, ts.isClassDeclaration); + + if (owner) add(`this.${node.name.text}`, owner); + } + } else if (ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name) && isFilterBarType(node.type)) { + const owner = findAncestor(node, ts.isClassDeclaration); + + if (owner) add(`this.${node.name.text}`, owner); + } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isFilterBarType(node.type)) { + add(node.name.text, findAncestor(node, isFunctionLike) ?? sourceFile); + } + + node.forEachChild(visit); + }; + + visit(sourceFile); + + return receivers; +} + +/** Classifies a matched property access and appends the resulting edit(s). */ +function classifyAccess(node: ts.PropertyAccessExpression, sourceFile: ts.SourceFile, edits: Edit[]): void { + const parent = node.parent; + + // Already migrated: `x.filter()` (call) or `x.filter.set(...)` — leave alone (idempotent). + if (ts.isCallExpression(parent) && parent.expression === node) return; + if (ts.isPropertyAccessExpression(parent) && parent.expression === node && parent.name.text === 'set') return; + + // Write target: `x.filter = RHS` → `x.filter.set(RHS)` (writable members only). + if ( + ts.isBinaryExpression(parent) && + parent.left === node && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + if (WRITABLE_MEMBERS.has(node.name.text)) { + const rhs = parent.right; + + // Replace ` = ` with `.set(` and close after the right-hand side; keeping the RHS span intact + // lets any nested read edits inside it still apply. + edits.push({ start: node.getEnd(), end: rhs.getStart(sourceFile), text: '.set(' }); + edits.push({ start: rhs.getEnd(), end: rhs.getEnd(), text: ')' }); + } + + return; + } + + // Read (incl. optional chain `x?.filter`): append `()`. + edits.push({ start: node.getEnd(), end: node.getEnd(), text: '()' }); +} + +/** Collects edits for every read/write of a signal member on a known filter-bar receiver. */ +function collectAccessEdits(sourceFile: ts.SourceFile, receivers: Receiver[]): Edit[] { + const edits: Edit[] = []; + + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.name) && + SIGNAL_MEMBERS.includes(node.name.text) + ) { + const receiverText = node.expression.getText(sourceFile); + const start = node.getStart(sourceFile); + const end = node.getEnd(); + const inScope = receivers.some((r) => r.text === receiverText && start >= r.start && end <= r.end); + + if (inScope) classifyAccess(node, sourceFile, edits); + } + + node.forEachChild(visit); + }; + + visit(sourceFile); + + return edits; +} + +/** Pass A — rewrite programmatic reads/writes of filter-bar signal members in TypeScript code. */ +function migrateTsExpressions(content: string, fileName: string): string { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const receivers = collectReceivers(sourceFile); + + if (receivers.length === 0) return content; + + const edits = collectAccessEdits(sourceFile, receivers); + + return edits.length > 0 ? applyEdits(content, edits) : content; +} + +/** Collects template reference variable names bound to a `` element. */ +class FilterBarRefCollector implements Visitor { + readonly refs = new Set(); + + visitElement(element: any): void { + if (element.name === FILTER_BAR_ELEMENT) { + for (const attr of element.attrs ?? []) { + if (typeof attr.name !== 'string') continue; + + if (attr.name.startsWith('#')) this.refs.add(attr.name.slice(1)); + else if (attr.name.startsWith('ref-')) this.refs.add(attr.name.slice(4)); + } + } + + this.visitChildren(element); + } + + visitBlock(block: any): void { + this.visitChildren(block); + } + + private visitChildren(node: any): void { + for (const child of node.children ?? []) { + child.visit(this); + } + } + + visitAttribute(): void {} + visitText(): void {} + visitComment(): void {} + visitExpansion(): void {} + visitExpansionCase(): void {} + visitBlockParameter(): void {} + visitLetDeclaration(): void {} +} + +/** Rewrites `ref.member` reads to `ref.member()` for the given refs, scoped to those exact identifiers. */ +function rewriteRefReads(template: string, refs: string[]): { content: string; changed: boolean } { + const members = SIGNAL_MEMBERS.join('|'); + let content = template; + let changed = false; + + for (const ref of refs) { + // `\bref\.(member)\b(?!\s*\()` — skip anything already invoked, so the rewrite is idempotent. + const pattern = new RegExp(`\\b(${escapeRegExp(ref)})\\.(${members})\\b(?!\\s*\\()`, 'g'); + const next = content.replace(pattern, '$1.$2()'); + + if (next !== content) { + content = next; + changed = true; + } + } + + return { content, changed }; +} + +/** Pass B (core) — parse a template, discover filter-bar refs, rewrite their signal reads. */ +async function migrateTemplate(template: string): Promise<{ content: string; changed: boolean }> { + if (!template.includes(FILTER_BAR_ELEMENT)) return { content: template, changed: false }; + + const parsed = await parseTemplate(template); + + if (!parsed.tree) return { content: template, changed: false }; + + const collector = new FilterBarRefCollector(); + + visitAll(collector, (parsed.tree as { rootNodes: unknown[] }).rootNodes); + + if (collector.refs.size === 0) return { content: template, changed: false }; + + return rewriteRefReads(template, [...collector.refs]); +} + +/** Interior `[start, end]` ranges of inline `@Component({ template: '…' })` string literals. */ +function collectInlineTemplateRanges(sourceFile: ts.SourceFile): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + + forEachClass(sourceFile, (node) => { + const decorator = ts + .getDecorators(node) + ?.find( + (dec) => + ts.isCallExpression(dec.expression) && + ts.isIdentifier(dec.expression.expression) && + dec.expression.expression.text === 'Component' + ); + + if (!decorator || !ts.isCallExpression(decorator.expression)) return; + + const [arg] = decorator.expression.arguments; + + if (!arg || !ts.isObjectLiteralExpression(arg)) return; + + for (const prop of arg.properties) { + if ( + ts.isPropertyAssignment(prop) && + (ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)) && + prop.name.text === 'template' && + ts.isStringLiteralLike(prop.initializer) && + prop.initializer.text + ) { + // +1 / -1 to exclude the opening/closing quote characters. + ranges.push({ start: prop.initializer.getStart(sourceFile) + 1, end: prop.initializer.getEnd() - 1 }); + } + } + }); + + return ranges; +} + +/** Pass B (inline) — rewrite filter-bar ref reads inside inline component templates. */ +async function migrateInlineTemplates(content: string, fileName: string): Promise { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const ranges = collectInlineTemplateRanges(sourceFile).sort((a, b) => b.start - a.start); + let result = content; + + for (const { start, end } of ranges) { + const { content: rewritten, changed } = await migrateTemplate(result.slice(start, end)); + + if (changed) { + result = result.slice(0, start) + rewritten + result.slice(end); + } + } + + return result; +} + +/** Pass C — safe identifier renames. */ +function applyRenames(content: string): string { + let result = content; + + for (const { from, to } of tsRenameReplacements) { + result = result.replace(new RegExp(from, 'g'), to); + } + + return result; +} + +function logWarnings(context: SchematicContext, filePath: string, content: string): void { + for (const { pattern, message } of warnPatterns) { + if (new RegExp(pattern).test(content)) { + logMessage(context.logger, [`[filter-bar-signals] ${filePath}`, ` ${message}`]); + } + } +} + +/** A `.ts` file is a filter-bar consumer if it names the type or imports the package. */ +function referencesFilterBar(content: string): boolean { + return /\bKbqFilterBar\b/.test(content) || content.includes('@koobiq/components/filter-bar'); +} + +export default function filterBarSignals(options: Schema): Rule { + return async (tree: Tree, context: SchematicContext) => { + const { project, fix } = options; + const projectDefinition = await setupOptions(project, tree); + const root = projectDefinition?.root ?? ''; + const rootDir = root ? tree.getDir(root as Path) : tree.root; + + const tsPaths: string[] = []; + const htmlPaths: string[] = []; + + rootDir.visit((filePath) => { + if (filePath.includes('node_modules') || filePath.includes('/dist/')) return; + + if (filePath.endsWith(TS_EXT)) tsPaths.push(filePath); + else if (filePath.endsWith(HTML_EXT)) htmlPaths.push(filePath); + }); + + let touched = 0; + + const commit = (filePath: string, original: string, updated: string) => { + if (updated === original) return; + + touched++; + + if (fix) { + tree.overwrite(filePath, updated); + } else { + logMessage(context.logger, [`[filter-bar-signals] would update ${filePath} (run with --fix to apply)`]); + } + }; + + for (const filePath of tsPaths) { + const original = tree.read(filePath)?.toString(); + + if (!original || !referencesFilterBar(original)) continue; + + logWarnings(context, filePath, original); + + let content = migrateTsExpressions(original, filePath); + + content = await migrateInlineTemplates(content, filePath); + content = applyRenames(content); + + commit(filePath, original, content); + } + + for (const filePath of htmlPaths) { + const original = tree.read(filePath)?.toString(); + + if (!original) continue; + + const { content, changed } = await migrateTemplate(original); + + if (changed) commit(filePath, original, content); + } + + logMessage(context.logger, [ + `[filter-bar-signals] processed tree under "${root || ''}", ` + + `${fix ? 'updated' : 'would update'} ${touched} file(s).` + ]); + }; +} diff --git a/packages/schematics/src/migrations/filter-bar-signals/schema.json b/packages/schematics/src/migrations/filter-bar-signals/schema.json new file mode 100644 index 0000000000..252681c04c --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "koobiq-components-filter-bar-signals", + "title": "Koobiq components filter-bar signals migration", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Name of the project to migrate. If omitted, the migration runs over the whole tree.", + "$default": { + "$source": "projectName" + } + }, + "fix": { + "type": "boolean", + "default": true, + "description": "When true, applies all auto-fix replacements. When false, prints what would change without writing." + } + } +} diff --git a/packages/schematics/src/migrations/filter-bar-signals/schema.ts b/packages/schematics/src/migrations/filter-bar-signals/schema.ts new file mode 100644 index 0000000000..b9f56acac5 --- /dev/null +++ b/packages/schematics/src/migrations/filter-bar-signals/schema.ts @@ -0,0 +1,6 @@ +export interface Schema { + /** Name of the project to migrate. */ + project?: string; + /** When true, applies replacements; when false, only logs what would change. */ + fix: boolean; +}