From efe7e3119e61a756369e3403ea3fc17fd33a3022 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 15:16:55 +0400 Subject: [PATCH 01/13] feat: intial implementation Signed-off-by: Sobyt483 --- docs/declarative-table-card.md | 132 ++++- .../stories/declarative-table-card.stories.ts | 106 +++- .../declarative-table-card.component.html | 74 ++- .../declarative-table-card.component.scss | 7 +- .../declarative-table-card.component.spec.ts | 549 +++++++++++++++--- .../declarative-table-card.component.ts | 82 ++- .../ngx/declarative-ui/table-card/index.ts | 1 + .../table-card/models/configs.ts | 25 +- .../table-card/models/search-config.ts | 26 + .../ngx/declarative-ui/table/models/index.ts | 1 + .../table/models/table-config.ts | 16 + 11 files changed, 897 insertions(+), 122 deletions(-) create mode 100644 projects/ngx/declarative-ui/table-card/models/search-config.ts create mode 100644 projects/ngx/declarative-ui/table/models/table-config.ts diff --git a/docs/declarative-table-card.md b/docs/declarative-table-card.md index 0323f47..1c2afdf 100644 --- a/docs/declarative-table-card.md +++ b/docs/declarative-table-card.md @@ -91,6 +91,8 @@ import { (editSubmit)="onEditSubmit($event, tableCard)" (deleteSubmit)="onDeleteSubmit($event, tableCard)" (searchChanged)="onSearch($event)" + (searchSubmit)="onSearch($event)" + (scopeChanged)="onSearch($event)" /> `, }) @@ -164,6 +166,11 @@ export class MyComponent { await this.deletePod(pod); tableCard.closeDeleteDialog(); } + + onSearch({ value, scope }: { value: string; scope?: string }): void { + // Re-fetch / filter `pods` based on the current search text and scope. + this.reloadPods({ query: value, scope }); + } } ``` @@ -189,7 +196,9 @@ export class MyComponent { | `createSubmit` | `Record` | Fires when the create dialog Save button is clicked | | `editSubmit` | `{ resource: T; value: Record }` | Fires when the edit dialog Save button is clicked | | `deleteSubmit` | `T` | Fires when the delete dialog Delete button is clicked | -| `searchChanged` | `string` | Emits 300 ms after the search input changes | +| `searchChanged` | `{ value: string; scope?: string }` | Emits 300 ms after the search input changes; `scope` reflects the currently active scope (if any) | +| `searchSubmit` | `{ value: string; scope?: string }` | Emits synchronously when the user submits the search (Enter or search icon) | +| `scopeChanged` | `{ value: string; scope?: string }` | Emits synchronously when the user picks a different scope from the dropdown; `value` is the current in-flight search text | | `tableRowClicked` | `T` | Emits when a table row is clicked | | `loadMoreResources` | - | Emits when the user triggers load more | | `paginationLimitChanged` | `number` | Emits when the user changes page size | @@ -211,15 +220,43 @@ Submit events do not close dialogs automatically. Close the dialog after success ```ts interface TableCardConfig { - header: string; + header?: string; headerTooltip?: string; tableConfig: TableConfig; buttonSettings?: TableCardButtonSettings; + searchConfig?: TableCardSearchConfig; createResourceFormConfig?: ResourceFormConfig; editResourceFormConfig?: ResourceFormConfig; deleteResourceConfirmationConfig?: DeleteResourceConfirmationConfig; } +/** One option in the `` scopes dropdown. */ +interface Scope { + /** Visible label shown in the dropdown. */ + label: string; + /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ + value?: string; +} + +/** Configuration for the `` element rendered in the table-card header. */ +interface TableCardSearchConfig { + /** ARIA name for the search input. */ + accessibleName?: string; + /** Placeholder text shown when the input is empty. */ + placeholder?: string; + /** When `true`, the clear icon is shown inside the input. Default: `true`. */ + showClearIcon?: boolean; + /** Initial / controlled scope `value` (matches one of `scopes[].value`). */ + scopeValue?: string; + /** Initial / controlled search text value. */ + value?: string; + /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ + scopes?: Scope[]; + /** When `true`, `` is always visible in the toolbar. + * When `false` (default), the search is hidden behind a search-toggle icon button; clicking it expands the search and clicking it again (or losing focus on an empty input) collapses the search. Collapse preserves the entered text and active scope — re-expanding restores the in-flight query. Use the built-in clear icon (`showClearIcon`) to clear the value. */ + alwaysOnDisplay?: boolean; +} + interface TableConfig { fields: TableFieldDefinition[]; totalItemsCount?: number; @@ -244,6 +281,97 @@ interface TableCardFormState { `ResourceFormConfig` is static. Keep runtime errors in `createFormState` / `editFormState`. The submit button is disabled when any entry in `fieldErrors` is truthy. +> `TableConfig` is declared in `declarative-ui/table` and re-exported from `declarative-ui/table-card`. Importing it from `@openmfp/webcomponents` (the public-api barrel) continues to work unchanged. + +--- + +## Search & Scopes + +When `searchConfig` is set on `TableCardConfig`, the card renders a [``](https://ui5.github.io/webcomponents/components/fiori/Search/) element in the toolbar. Omit `searchConfig` to hide the search entirely. The previous `resourcesSearchable` boolean has been removed. + +### Visibility (`alwaysOnDisplay`) + +| `alwaysOnDisplay` | Toolbar UX | +| ----------------- | ---------- | +| `true` | `` is rendered inline at all times. No toggle button is shown. | +| `false` (default) | The search is hidden behind a search-toggle icon button. Clicking the button expands the input; clicking it again — or blurring an empty input — collapses it. `buttonSettings.searchButton` overrides the toggle button's icon, text, and design. | + +### Collapse preserves state + +Collapsing the search (toggle button or blur-on-empty) does **not** clear the entered text or the active scope. Re-expanding the search restores the same in-flight query. To clear the value the user clicks the built-in clear icon inside `` (`showClearIcon` defaults to `true`), which fires `searchChanged` with an empty `value` through the normal 300 ms debounce. + +### Event contract + +The host owns data fetching and filtering. The card forwards user actions verbatim: + +| Event | When | Payload | +| --------------- | ---- | ------- | +| `searchChanged` | 300 ms after the input value changes (typing or clear icon) | `{ value, scope }` where `scope` is the currently active scope | +| `searchSubmit` | User presses Enter or clicks the search icon (synchronous) | `{ value, scope }` | +| `scopeChanged` | User picks a different scope from the dropdown (synchronous) | `{ value, scope }` where `value` is the current in-flight search text | + +### Example — "My Contributions" / "All" scopes + +```ts +import { + DeclarativeTableCard, + TableCardConfig, +} from '@openmfp/webcomponents'; + +@Component({ + imports: [DeclarativeTableCard], + template: ` + + `, +}) +export class MyComponent { + pods: Pod[] = []; + + config: TableCardConfig = { + header: 'Pods', + tableConfig: { + fields: [ + { label: 'Name', property: 'metadata.name' }, + { label: 'Namespace', property: 'metadata.namespace' }, + ], + }, + searchConfig: { + placeholder: 'Search pods…', + accessibleName: 'Search pods', + scopeValue: 'all', + scopes: [ + { label: 'All', value: 'all' }, + { label: 'My Contributions', value: 'mine' }, + ], + }, + }; + + onSearchChanged({ value, scope }: { value: string; scope?: string }): void { + // Debounced — call your list/search endpoint here. + this.reloadPods({ query: value, scope }); + } + + onSearchSubmit({ value, scope }: { value: string; scope?: string }): void { + // Synchronous — fired on Enter or the search icon. Useful for forcing + // an immediate refresh that bypasses the 300 ms debounce. + this.reloadPods({ query: value, scope }); + } + + onScopeChanged({ value, scope }: { value: string; scope?: string }): void { + // Synchronous — re-fetch using the new scope and the current in-flight text. + this.reloadPods({ query: value, scope }); + } +} +``` + +Set `alwaysOnDisplay: true` on `searchConfig` to skip the toggle UX and render `` inline. Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. + --- ## Actions column diff --git a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts index 658f1d4..3c3fa39 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -6,6 +6,7 @@ import type { ResourceFormConfig, TableCardConfig, TableCardFormState, + TableCardSearchConfig, TableConfig, } from '../table-card/models/configs'; import type { TableFieldDefinition } from '../table/models'; @@ -140,7 +141,6 @@ const BASE_TABLE_CONFIG: TableConfig = { const BASE_CONFIG: TableCardConfig = { header: 'Pods', - resourcesSearchable: true, tableConfig: BASE_TABLE_CONFIG, }; @@ -472,3 +472,107 @@ export const WithPagination: Story = { }, }, }; + +// --------------------------------------------------------------------------- +// Search wrapper component (for stories wiring outputs to console) +// --------------------------------------------------------------------------- + +@Component({ + selector: 'mfp-declarative-table-card-search-story', + imports: [DeclarativeTableCard], + template: ` + + `, +}) +class DeclarativeTableCardSearchStory { + @Input() config!: TableCardConfig; + @Input() resources: GenericResource[] = []; + + onSearchChanged(event: { value: string; scope?: string }): void { + console.log('[searchChanged]', event); + } + + onSearchSubmit(event: { value: string; scope?: string }): void { + console.log('[searchSubmit]', event); + } + + onScopeChanged(event: { value: string; scope?: string }): void { + console.log('[scopeChanged]', event); + } +} + +type SearchStory = StoryObj; + +/** + * Search toggle UX: a search icon button reveals `` on click. + * Collapsing preserves the entered text — re-expanding restores the in-flight query. + * Use the built-in clear icon (×) to clear the value. + */ +export const WithSearch: SearchStory = { + render: (args) => ({ + props: args, + component: DeclarativeTableCardSearchStory, + }), + args: { + config: { + ...BASE_CONFIG, + searchConfig: { + placeholder: 'Search pods…', + } satisfies TableCardSearchConfig, + }, + resources: PODS, + }, +}; + +/** + * `alwaysOnDisplay: true` — `` is rendered inline in the toolbar with no toggle button. + */ +export const WithSearchAlwaysOn: SearchStory = { + render: (args) => ({ + props: args, + component: DeclarativeTableCardSearchStory, + }), + args: { + config: { + ...BASE_CONFIG, + searchConfig: { + placeholder: 'Search pods…', + alwaysOnDisplay: true, + } satisfies TableCardSearchConfig, + }, + resources: PODS, + }, +}; + +/** + * Scopes dropdown lists "All" and "My Contributions" next to the search input. + * Selecting a scope emits `scopeChanged`; submitting the form emits `searchSubmit`. + * Both events carry `{ value, scope }`. `searchChanged` fires after 300 ms debounce. + * Open the Actions tab to observe all three outputs. + */ +export const WithSearchAndScopes: SearchStory = { + render: (args) => ({ + props: args, + component: DeclarativeTableCardSearchStory, + }), + args: { + config: { + ...BASE_CONFIG, + searchConfig: { + placeholder: 'Search pods…', + scopes: [ + { label: 'All', value: 'all' }, + { label: 'My Contributions', value: 'mine' }, + ], + scopeValue: 'all', + } satisfies TableCardSearchConfig, + }, + resources: PODS, + }, +}; diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html index ebb3bec..48a85ea 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html @@ -14,27 +14,63 @@ }
- @if (config().resourcesSearchable) { - @if (searchExpanded()) { - + @for (s of sc.scopes ?? []; track s.value) { + + } + + } @else { + @if (searchExpanded()) { + + @for (s of sc.scopes ?? []; track s.value) { + + } + + } + } - } @if (createFormConfig()) { ` events do. `Event.target` is read-only in + * the browser, so we build a plain object and cast it. + */ +function fakeSearchEvent(opts: { value?: string; scopeValue?: string } = {}): Event { + return { target: { value: opts.value ?? '', scopeValue: opts.scopeValue } } as unknown as Event; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('DeclarativeTableCard', () => { beforeEach(async () => { + vi.useFakeTimers(); await TestBed.configureTestingModule({ imports: [ DeclarativeTableCard as unknown as typeof DeclarativeTableCard, @@ -146,6 +164,10 @@ describe('DeclarativeTableCard', () => { }).compileComponents(); }); + afterEach(() => { + vi.useRealTimers(); + }); + // ------------------------------------------------------------------------- // 1. Component creation // ------------------------------------------------------------------------- @@ -162,9 +184,7 @@ describe('DeclarativeTableCard', () => { describe('DOM: mfp-declarative-table', () => { it('renders mfp-declarative-table in the host element', () => { const { fixture } = setup(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - expect(root.querySelector('mfp-declarative-table')).not.toBeNull(); + expect(root(fixture).querySelector('mfp-declarative-table')).not.toBeNull(); }); }); @@ -175,9 +195,7 @@ describe('DeclarativeTableCard', () => { describe('header input', () => { it('renders the header title when header is provided', () => { const { fixture } = setup({ header: 'My Pods' }); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const title = root.querySelector('.card__title'); + const title = root(fixture).querySelector('.card__title'); expect(title).not.toBeNull(); expect(title?.textContent?.trim()).toBe('My Pods'); }); @@ -194,26 +212,22 @@ describe('DeclarativeTableCard', () => { headerTooltip: 'Some tooltip', }); fixture.detectChanges(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const icon = root.querySelector('ui5-icon[name="hint"]'); + const icon = root(fixture).querySelector('ui5-icon[name="hint"]'); expect(icon).not.toBeNull(); expect(icon?.getAttribute('accessible-name')).toBe('Some tooltip'); }); it('does not render info icon when headerTooltip is not provided', () => { const { fixture } = setup({ headerTooltip: undefined }); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - expect(root.querySelector('ui5-icon[name="hint"]')).toBeNull(); + expect(root(fixture).querySelector('ui5-icon[name="hint"]')).toBeNull(); }); }); // ------------------------------------------------------------------------- - // 5. Search behaviour + // 5. Search behaviour — toggle UX and state machine // ------------------------------------------------------------------------- - describe('search', () => { + describe('search toggle UX', () => { it('searchExpanded starts as false', () => { const { component } = setup(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -231,78 +245,479 @@ describe('DeclarativeTableCard', () => { const { component } = setup(); component.toggleSearch(); // expand component.toggleSearch(); // collapse - // searchCollapsing should be set; searchExpanded still true until animation ends // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((component as any).searchCollapsing()).toBe(true); }); - it('onSearchBlur() collapses search when value is empty', () => { + it('onSearchAnimationEnd() transitions state to collapsed after collapse animation', () => { const { component } = setup(); - component.toggleSearch(); + component.toggleSearch(); // expand + component.toggleSearch(); // start collapsing + component.onSearchAnimationEnd(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (component as any).searchControl.setValue(''); - component.onSearchBlur(); + expect((component as any).searchCollapsing()).toBe(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(true); + expect((component as any).searchExpanded()).toBe(false); }); - it('onSearchBlur() does not collapse when value is non-empty', () => { + it('onSearchAnimationEnd() does nothing when not collapsing', () => { const { component } = setup(); component.toggleSearch(); + // Not in collapsing state — should be a no-op + component.onSearchAnimationEnd(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (component as any).searchControl.setValue('abc'); - component.onSearchBlur(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(false); + expect((component as any).searchExpanded()).toBe(true); }); + }); + + // ------------------------------------------------------------------------- + // 6. searchConfig — rendering + // ------------------------------------------------------------------------- + + describe('searchConfig rendering', () => { + it('does not render ui5-search when searchConfig is absent', () => { + const { fixture } = setup(); + expect(root(fixture).querySelector('ui5-search')).toBeNull(); + }); + + it('does not render the search toggle button when searchConfig is absent', () => { + const { fixture } = setup(); + expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); + }); + + it('renders ui5-search inline when alwaysOnDisplay is true', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); + }); + + it('does not render the search toggle button when alwaysOnDisplay is true', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); + }); + + it('renders the search toggle button when alwaysOnDisplay is false', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelector('.card__search-btn')).not.toBeNull(); + }); + + it('does not render ui5-search before toggle is clicked when alwaysOnDisplay is false', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelector('ui5-search')).toBeNull(); + }); + + it('renders ui5-search after toggle button is clicked when alwaysOnDisplay is false', () => { + const { fixture, component } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + fixture.detectChanges(); + component.toggleSearch(); + fixture.detectChanges(); + expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); + }); + + it('binds placeholder from searchConfig to ui5-search', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + const search = root(fixture).querySelector('ui5-search'); + // Angular binds [placeholder] as a property; read via the property + expect((search as HTMLElement & { placeholder?: string })?.placeholder).toBe('Search pods…'); + }); + + it('binds accessibleName from searchConfig', () => { + const { fixture } = setup({ + searchConfig: { + accessibleName: 'Pod search', + alwaysOnDisplay: true, + }, + }); + fixture.detectChanges(); + const search = root(fixture).querySelector('ui5-search'); + expect( + (search as HTMLElement & { accessibleName?: string })?.accessibleName, + ).toBe('Pod search'); + }); + + it('defaults showClearIcon to true when not specified', () => { + const { fixture } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + const search = root(fixture).querySelector('ui5-search'); + expect( + (search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon, + ).toBe(true); + }); + + it('forwards showClearIcon: false when configured', () => { + const { fixture } = setup({ + searchConfig: { alwaysOnDisplay: true, showClearIcon: false }, + }); + fixture.detectChanges(); + const search = root(fixture).querySelector('ui5-search'); + expect( + (search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon, + ).toBe(false); + }); + + it('renders one ui5-search-scope per scopes entry', () => { + const { fixture } = setup({ + searchConfig: { + alwaysOnDisplay: true, + scopes: [ + { label: 'All', value: 'all' }, + { label: 'My Contributions', value: 'mine' }, + ], + }, + }); + fixture.detectChanges(); + const scopes = root(fixture).querySelectorAll('ui5-search-scope'); + expect(scopes).toHaveLength(2); + }); + + it('renders zero ui5-search-scope elements when scopes array is empty', () => { + const { fixture } = setup({ + searchConfig: { alwaysOnDisplay: true, scopes: [] }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); + }); + + it('sets text and value on each ui5-search-scope', () => { + const { fixture } = setup({ + searchConfig: { + alwaysOnDisplay: true, + scopes: [{ label: 'All', value: 'all' }], + }, + }); + fixture.detectChanges(); + const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { + text?: string; + value?: string; + }; + expect(scope?.text).toBe('All'); + expect(scope?.value).toBe('all'); + }); + }); + + // ------------------------------------------------------------------------- + // 7. searchConfig — outputs: searchChanged (debounced) + // ------------------------------------------------------------------------- + + describe('searchConfig: searchChanged output', () => { + it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { + const { fixture, component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); + + expect(emitted).toHaveLength(0); + vi.advanceTimersByTime(300); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: 'alpha', scope: undefined }); + }); + + it('does not emit searchChanged before the 300ms debounce elapses', () => { + const { fixture, component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'beta' })); + vi.advanceTimersByTime(299); + expect(emitted).toHaveLength(0); + }); + + it('includes active scope in searchChanged payload', () => { + const { fixture, component } = setup({ + searchConfig: { + alwaysOnDisplay: true, + scopes: [ + { label: 'All', value: 'all' }, + { label: 'Mine', value: 'mine' }, + ], + }, + }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + // Change scope first + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + + // Then type + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]).toEqual({ value: 'pod', scope: 'mine' }); + }); + + it('emits searchChanged with empty value after simulated clear icon (ui5Input with empty value)', () => { + const { fixture, component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + + // Type something first + component.onSearchInput(fakeSearchEvent({ value: 'foo' })); + vi.advanceTimersByTime(300); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + // Simulate clear icon click (fires ui5Input with empty value) + component.onSearchInput(fakeSearchEvent({ value: '' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]).toEqual({ value: '', scope: undefined }); + }); + }); + + // ------------------------------------------------------------------------- + // 8. searchConfig — outputs: searchSubmit (synchronous) + // ------------------------------------------------------------------------- + + describe('searchConfig: searchSubmit output', () => { + it('emits searchSubmit synchronously on ui5Search event', () => { + const { fixture, component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + component.onSearchSubmit(fakeSearchEvent({ value: 'my-pod' })); + + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: 'my-pod', scope: undefined }); + }); + + it('includes scope in searchSubmit when a scope is active', () => { + const { component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + + const emitted: { value: string; scope?: string }[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + component.onSearchSubmit(fakeSearchEvent({ value: 'redis', scopeValue: 'all' })); + + expect(emitted[0]).toEqual({ value: 'redis', scope: 'all' }); + }); + }); + + // ------------------------------------------------------------------------- + // 9. searchConfig — outputs: scopeChanged (synchronous) + // ------------------------------------------------------------------------- + + describe('searchConfig: scopeChanged output', () => { + it('emits scopeChanged synchronously on ui5ScopeChange event', () => { + const { component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + + const emitted: { value: string; scope?: string }[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: '', scope: 'mine' }); + }); + + it('includes in-flight search text in scopeChanged payload', () => { + const { component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + + // Type something + component.onSearchInput(fakeSearchEvent({ value: 'cache' })); + + const emitted: { value: string; scope?: string }[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: 'cache', scopeValue: 'all' })); + + expect(emitted[0]).toEqual({ value: 'cache', scope: 'all' }); + }); + + it('updates activeScope after scopeChanged so subsequent searchChanged carries new scope', () => { + const { component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + + // Change scope + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]?.scope).toBe('mine'); + }); + }); + + // ------------------------------------------------------------------------- + // 10. searchConfig — collapse preserves state + // ------------------------------------------------------------------------- + + describe('collapse preserves search state', () => { + it('collapsing does not emit searchChanged synchronously', () => { + const { component } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); - it('onSearchAnimationEnd() resets search state after collapse animation', () => { - const { component } = setup(); component.toggleSearch(); // expand - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (component as any).searchControl.setValue('query'); + + component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); + vi.advanceTimersByTime(300); // flush debounce + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.toggleSearch(); // collapse + expect(emitted).toHaveLength(0); + }); + + it('searchControl.value is preserved after collapse animation ends', () => { + const { component } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + + component.toggleSearch(); // expand + component.onSearchInput(fakeSearchEvent({ value: 'preserved-query' })); + component.toggleSearch(); // start collapsing - component.onSearchAnimationEnd(); + component.onSearchAnimationEnd(); // animation done → collapsed + // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(false); + expect((component as any).searchControl.value).toBe('preserved-query'); + }); + + it('re-expanding after collapse shows the same searchControl value', () => { + const { component } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + + component.toggleSearch(); // expand + component.onSearchInput(fakeSearchEvent({ value: 'in-flight' })); + component.toggleSearch(); // start collapsing + component.onSearchAnimationEnd(); // collapsed + + component.toggleSearch(); // re-expand // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(false); + expect((component as any).searchControl.value).toBe('in-flight'); + }); + + it('active scope is preserved after collapse', () => { + const { component } = setup({ + searchConfig: { + placeholder: 'Search pods…', + scopes: [{ label: 'Mine', value: 'mine' }], + }, + }); + + component.toggleSearch(); // expand + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + component.toggleSearch(); // collapse + component.onSearchAnimationEnd(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe(''); + expect((component as any).activeScope()).toBe('mine'); }); + }); - it('onSearchAnimationEnd() does nothing when not collapsing', () => { - const { component } = setup(); + // ------------------------------------------------------------------------- + // 11. searchConfig — alwaysOnDisplay: toggleSearch is a no-op + // ------------------------------------------------------------------------- + + describe('toggleSearch() is a no-op when alwaysOnDisplay is true', () => { + it('does not change searchState when alwaysOnDisplay is true', () => { + const { component } = setup({ + searchConfig: { alwaysOnDisplay: true }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const before = (component as any).searchState(); component.toggleSearch(); - // Not in collapsing state — should be a no-op - component.onSearchAnimationEnd(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(true); + expect((component as any).searchState()).toBe(before); + }); + }); + + // ------------------------------------------------------------------------- + // 12. searchConfig — buttonSettings.searchButton overrides + // ------------------------------------------------------------------------- + + describe('buttonSettings.searchButton overrides', () => { + it('applies custom icon to the search toggle button', () => { + const fixture: Fixture = TestBed.createComponent( + DeclarativeTableCard as unknown as typeof DeclarativeTableCard, + ); + + fixture.componentRef.setInput('config', { + tableConfig: READ_CONFIG, + searchConfig: { placeholder: 'Search pods…' }, + buttonSettings: { + searchButton: { icon: 'filter', tooltip: 'Open filter' }, + }, + } satisfies TableCardConfig); + fixture.componentRef.setInput('resources', RESOURCES); + fixture.componentRef.setInput('createFormState', {}); + fixture.componentRef.setInput('editFormState', {}); + fixture.detectChanges(); + + const btn = root(fixture).querySelector('.card__search-btn') as HTMLElement & { + icon?: string; + tooltip?: string; + }; + expect(btn?.icon).toBe('filter'); + expect(btn?.tooltip).toBe('Open filter'); }); }); // ------------------------------------------------------------------------- - // 6. Create button visibility + // 13. Create button visibility // ------------------------------------------------------------------------- describe('create button', () => { it('create button is absent when createConfig is not provided', () => { const { fixture } = setup(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - expect(root.querySelector('.card__create-btn')).toBeNull(); + expect(root(fixture).querySelector('.card__create-btn')).toBeNull(); }); it('create button is present when createConfig is provided', () => { const { fixture } = setup({ createConfig: CREATE_CONFIG }); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - expect(root.querySelector('.card__create-btn')).not.toBeNull(); + expect(root(fixture).querySelector('.card__create-btn')).not.toBeNull(); }); }); // ------------------------------------------------------------------------- - // 7. effectiveColumns() computed + // 14. effectiveColumns() computed // ------------------------------------------------------------------------- describe('effectiveColumns()', () => { @@ -402,7 +817,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 8. editInitialValue() computed + // 15. editInitialValue() computed // ------------------------------------------------------------------------- describe('editInitialValue()', () => { @@ -432,7 +847,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 9. onButtonClick() + // 16. onButtonClick() // ------------------------------------------------------------------------- describe('onButtonClick()', () => { @@ -504,7 +919,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 10. form state and submit flow + // 17. form state and submit flow // ------------------------------------------------------------------------- describe('form state and submit flow', () => { @@ -614,7 +1029,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 11. close methods + // 18. close methods // ------------------------------------------------------------------------- describe('close methods', () => { @@ -653,7 +1068,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 12. runtime form state + // 19. runtime form state // ------------------------------------------------------------------------- describe('runtime form state', () => { @@ -668,9 +1083,7 @@ describe('DeclarativeTableCard', () => { (component as any).createDialogOpen.set(true); fixture.detectChanges(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const submitButton = root.querySelector( + const submitButton = root(fixture).querySelector( '.dialog__footer ui5-button[design="Emphasized"]', ) as HTMLElement & { disabled: boolean }; @@ -686,9 +1099,7 @@ describe('DeclarativeTableCard', () => { (component as any).createDialogOpen.set(true); fixture.detectChanges(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const submitButton = root.querySelector( + const submitButton = root(fixture).querySelector( '.dialog__footer ui5-button[design="Emphasized"]', ) as HTMLElement & { disabled: boolean }; @@ -706,9 +1117,7 @@ describe('DeclarativeTableCard', () => { (component as any).editDialogOpen.set(true); fixture.detectChanges(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const submitButton = root.querySelector( + const submitButton = root(fixture).querySelector( '.dialog__footer ui5-button[design="Emphasized"]', ) as HTMLElement & { disabled: boolean }; @@ -724,9 +1133,7 @@ describe('DeclarativeTableCard', () => { (component as any).editDialogOpen.set(true); fixture.detectChanges(); - const root: ShadowRoot | HTMLElement = - fixture.nativeElement.shadowRoot ?? fixture.nativeElement; - const submitButton = root.querySelector( + const submitButton = root(fixture).querySelector( '.dialog__footer ui5-button[design="Emphasized"]', ) as HTMLElement & { disabled: boolean }; @@ -735,7 +1142,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 14. Pass-through outputs + // 20. Pass-through outputs // ------------------------------------------------------------------------- describe('pass-through outputs', () => { @@ -759,10 +1166,20 @@ describe('DeclarativeTableCard', () => { const { component } = setup(); expect(typeof component.searchChanged.emit).toBe('function'); }); + + it('exposes searchSubmit output', () => { + const { component } = setup(); + expect(typeof component.searchSubmit.emit).toBe('function'); + }); + + it('exposes scopeChanged output', () => { + const { component } = setup(); + expect(typeof component.scopeChanged.emit).toBe('function'); + }); }); // ------------------------------------------------------------------------- - // 15. readConfig pagination pass-through + // 21. readConfig pagination pass-through // ------------------------------------------------------------------------- describe('readConfig pagination', () => { @@ -785,7 +1202,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 16. TableConfig: growMode / height / loadMoreButtonText pass-through + // 22. TableConfig: growMode / height / loadMoreButtonText pass-through // ------------------------------------------------------------------------- describe('tableConfig passthrough: growMode, height, loadMoreButtonText', () => { diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts index 791aa34..eaee235 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts @@ -3,8 +3,8 @@ import { DeclarativeForm } from '../form/declarative-form/declarative-form.compo import { GenericResource } from '../models'; import { DeclarativeTable } from '../table/declarative-table/declarative-table.component'; import { - TableFieldDefinition, ResourceFieldButtonClickEvent, + TableFieldDefinition, } from '../table/models'; import { getResourceValueByJsonPath } from '../table/utils/resource-field-by-path'; import { TableCardConfig, TableCardFormState } from './models/configs'; @@ -16,6 +16,7 @@ import { ViewEncapsulation, afterNextRender, computed, + effect, inject, input, output, @@ -23,11 +24,12 @@ import { viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { FormControl } from '@angular/forms'; +import { Search } from '@fundamental-ngx/ui5-webcomponents-fiori/search'; +import { SearchScope } from '@fundamental-ngx/ui5-webcomponents-fiori/search-scope'; import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; import { Dialog } from '@fundamental-ngx/ui5-webcomponents/dialog'; import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; -import { Input } from '@fundamental-ngx/ui5-webcomponents/input'; import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; import '@ui5/webcomponents-icons/dist/add.js'; import '@ui5/webcomponents-icons/dist/search.js'; @@ -35,17 +37,22 @@ import { debounceTime } from 'rxjs'; type SearchState = 'collapsed' | 'expanded' | 'collapsing'; +interface Ui5SearchEventTarget { + value?: string; + scopeValue?: string; +} + @Component({ selector: 'mfp-declarative-table-card', imports: [ DeclarativeTable, DeclarativeForm, - ReactiveFormsModule, Dialog, Title, Button, Icon, - Input, + Search, + SearchScope, ], templateUrl: './declarative-table-card.component.html', styleUrl: './declarative-table-card.component.scss', @@ -65,7 +72,21 @@ export class DeclarativeTableCard { readonly loadMoreResources = output(); readonly paginationLimitChanged = output(); - readonly searchChanged = output(); + /** + * Emitted after the user types into the search input, debounced by 300 ms. + * Carries the current input value and the active scope (if any). + */ + readonly searchChanged = output<{ value: string; scope?: string }>(); + /** + * Emitted synchronously when the user submits the search (presses Enter or + * clicks the search icon). Carries the submitted value and the active scope. + */ + readonly searchSubmit = output<{ value: string; scope?: string }>(); + /** + * Emitted synchronously when the user selects a different scope in the + * scopes dropdown. Carries the current in-flight search text and the new scope. + */ + readonly scopeChanged = output<{ value: string; scope?: string }>(); readonly createFieldChange = output(); readonly editFieldChange = output<{ resource: T; @@ -84,7 +105,8 @@ export class DeclarativeTableCard { () => this.searchState() === 'collapsing', ); protected searchControl = new FormControl(''); - protected searchInputRef = viewChild('searchInput'); + protected searchInputRef = viewChild('searchInput'); + protected activeScope = signal(undefined); protected createDialogOpen = signal(false); protected editDialogOpen = signal(false); @@ -95,6 +117,10 @@ export class DeclarativeTableCard { protected tableConfig = computed(() => this.config().tableConfig); protected header = computed(() => this.config().header); protected headerTooltip = computed(() => this.config().headerTooltip); + protected searchConfig = computed(() => this.config().searchConfig); + protected alwaysOnDisplay = computed( + () => this.searchConfig()?.alwaysOnDisplay === true, + ); protected createFormConfig = computed( () => this.config().createResourceFormConfig, ); @@ -127,11 +153,21 @@ export class DeclarativeTableCard { this.searchControl.valueChanges .pipe(debounceTime(300), takeUntilDestroyed()) .subscribe((value) => { - this.searchChanged.emit(value ?? ''); + this.searchChanged.emit({ + value: value ?? '', + scope: this.activeScope(), + }); }); + + effect(() => { + this.activeScope.set(this.searchConfig()?.scopeValue); + }); } toggleSearch(): void { + if (this.alwaysOnDisplay()) { + return; + } if (this.searchState() === 'expanded') { this.collapseSearch(); } else if (this.searchState() === 'collapsed') { @@ -145,19 +181,35 @@ export class DeclarativeTableCard { } } - onSearchBlur(): void { - if (!this.searchControl.value) { - this.collapseSearch(); - } - } - onSearchAnimationEnd(): void { if (this.searchCollapsing()) { this.searchState.set('collapsed'); - this.searchControl.setValue('', { emitEvent: false }); } } + onSearchInput(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + this.searchControl.setValue(target?.value ?? ''); + } + + onSearchSubmit(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + this.searchSubmit.emit({ + value: target?.value ?? '', + scope: target?.scopeValue || undefined, + }); + } + + onSearchScopeChange(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + const scope = target?.scopeValue || undefined; + this.activeScope.set(scope); + this.scopeChanged.emit({ + value: this.searchControl.value ?? '', + scope, + }); + } + private collapseSearch(): void { this.searchState.set('collapsing'); } diff --git a/projects/ngx/declarative-ui/table-card/index.ts b/projects/ngx/declarative-ui/table-card/index.ts index de187bf..7cad2d0 100644 --- a/projects/ngx/declarative-ui/table-card/index.ts +++ b/projects/ngx/declarative-ui/table-card/index.ts @@ -1,2 +1,3 @@ export * from './declarative-table-card.component'; export * from './models/configs'; +export * from './models/search-config'; diff --git a/projects/ngx/declarative-ui/table-card/models/configs.ts b/projects/ngx/declarative-ui/table-card/models/configs.ts index b3d4b16..59077cd 100644 --- a/projects/ngx/declarative-ui/table-card/models/configs.ts +++ b/projects/ngx/declarative-ui/table-card/models/configs.ts @@ -1,6 +1,10 @@ import { FormFieldDefinition, FormFieldErrors } from '../../form'; import { ButtonSettings } from '../../models'; -import { TableFieldDefinition } from '../../table'; +import { TableConfig } from '../../table/models'; +import { TableCardSearchConfig } from './search-config'; + +export type { TableConfig } from '../../table/models'; +export type { Scope, TableCardSearchConfig } from './search-config'; /** Configuration for the create/edit resource form rendered inside the table card dialogs. */ export interface ResourceFormConfig { @@ -32,21 +36,6 @@ export interface DeleteResourceConfirmationConfig { cancelLabel?: string; } -/** Configuration for the inner `mfp-declarative-table`. */ -export interface TableConfig { - /** Column definitions. */ - fields: TableFieldDefinition[]; - /** Total number of items on the server (used by pagination). */ - totalItemsCount?: number; - /** Number of rows per page. */ - paginationLimit?: number; - /** When `true`, the "Load More" control is shown. */ - hasMore?: boolean; - height?: number; - growMode?: 'Scroll' | 'Button'; - loadMoreButtonText?: string; -} - /** Overrides for the table card's built-in action buttons. */ export interface TableCardButtonSettings { /** Partial override for the "Create" button. */ @@ -69,8 +58,8 @@ export interface TableCardConfig { tableConfig: TableConfig; /** Overrides for built-in toolbar and row-action buttons. */ buttonSettings?: TableCardButtonSettings; - /** When `true`, shows the search input and button in the card toolbar. */ - resourcesSearchable?: boolean; + /** When set, renders a `` in the card toolbar. Replaces the previous `resourcesSearchable` flag. */ + searchConfig?: TableCardSearchConfig; /** When set, enables the "Create" button and create dialog. */ createResourceFormConfig?: ResourceFormConfig; /** When set, enables per-row "Edit" button and edit dialog. */ diff --git a/projects/ngx/declarative-ui/table-card/models/search-config.ts b/projects/ngx/declarative-ui/table-card/models/search-config.ts new file mode 100644 index 0000000..5601585 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -0,0 +1,26 @@ +/** One option in the `` scopes dropdown. */ +export interface Scope { + /** Visible label shown in the dropdown. */ + label: string; + /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ + value?: string; +} + +/** Configuration for the `` element rendered in the table-card header. */ +export interface TableCardSearchConfig { + /** ARIA name for the search input. */ + accessibleName?: string; + /** Placeholder text shown when the input is empty. */ + placeholder?: string; + /** When `true`, the clear icon is shown inside the input. Default: `true`. */ + showClearIcon?: boolean; + /** Initial / controlled scope `value` (matches one of `scopes[].value`). */ + scopeValue?: string; + /** Initial / controlled search text value. */ + value?: string; + /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ + scopes?: Scope[]; + /** When `true`, `` is always visible in the toolbar. + * When `false` (default), the search is hidden behind a search-toggle icon button; clicking it expands the search and clicking it again (or losing focus on an empty input) collapses the search. Collapse preserves the entered text and active scope — re-expanding restores the in-flight query. Use the built-in clear icon (`showClearIcon`) to clear the value. */ + alwaysOnDisplay?: boolean; +} diff --git a/projects/ngx/declarative-ui/table/models/index.ts b/projects/ngx/declarative-ui/table/models/index.ts index c6a1f23..dd2d01d 100644 --- a/projects/ngx/declarative-ui/table/models/index.ts +++ b/projects/ngx/declarative-ui/table/models/index.ts @@ -12,3 +12,4 @@ export type { PropertyField, TransformType, } from '../../models/ui-definition'; +export type { TableConfig } from './table-config'; diff --git a/projects/ngx/declarative-ui/table/models/table-config.ts b/projects/ngx/declarative-ui/table/models/table-config.ts new file mode 100644 index 0000000..9d51786 --- /dev/null +++ b/projects/ngx/declarative-ui/table/models/table-config.ts @@ -0,0 +1,16 @@ +import { TableFieldDefinition } from '../../models/ui-definition'; + +/** Configuration for the `mfp-declarative-table` component. */ +export interface TableConfig { + /** Column definitions. */ + fields: TableFieldDefinition[]; + /** Total number of items on the server (used by pagination). */ + totalItemsCount?: number; + /** Number of rows per page. */ + paginationLimit?: number; + /** When `true`, the "Load More" control is shown. */ + hasMore?: boolean; + height?: number; + growMode?: 'Scroll' | 'Button'; + loadMoreButtonText?: string; +} From 4c58e82853f0deb63159b7c1abe2db0ca84894b2 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 15:33:31 +0400 Subject: [PATCH 02/13] feat: move serach to separate component Signed-off-by: Sobyt483 --- .../declarative-table-card.component.html | 63 +-- .../declarative-table-card.component.scss | 44 -- .../declarative-table-card.component.spec.ts | 479 +----------------- .../declarative-table-card.component.ts | 101 +--- .../search/table-card-search.component.html | 48 ++ .../search/table-card-search.component.scss | 47 ++ .../table-card-search.component.spec.ts | 419 +++++++++++++++ .../search/table-card-search.component.ts | 128 +++++ 8 files changed, 657 insertions(+), 672 deletions(-) create mode 100644 projects/ngx/declarative-ui/table-card/search/table-card-search.component.html create mode 100644 projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss create mode 100644 projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts create mode 100644 projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html index 48a85ea..3c3e939 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html @@ -15,62 +15,13 @@
@if (searchConfig(); as sc) { - @if (alwaysOnDisplay()) { - - @for (s of sc.scopes ?? []; track s.value) { - - } - - } @else { - @if (searchExpanded()) { - - @for (s of sc.scopes ?? []; track s.value) { - - } - - } - - } + } @if (createFormConfig()) { ` events do. `Event.target` is read-only in - * the browser, so we build a plain object and cast it. - */ -function fakeSearchEvent(opts: { value?: string; scopeValue?: string } = {}): Event { - return { target: { value: opts.value ?? '', scopeValue: opts.scopeValue } } as unknown as Event; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -224,484 +215,26 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 5. Search behaviour — toggle UX and state machine - // ------------------------------------------------------------------------- - - describe('search toggle UX', () => { - it('searchExpanded starts as false', () => { - const { component } = setup(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(false); - }); - - it('toggleSearch() sets searchExpanded to true on first call', () => { - const { component } = setup(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(true); - }); - - it('toggleSearch() starts collapsing on second call when already expanded', () => { - const { component } = setup(); - component.toggleSearch(); // expand - component.toggleSearch(); // collapse - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(true); - }); - - it('onSearchAnimationEnd() transitions state to collapsed after collapse animation', () => { - const { component } = setup(); - component.toggleSearch(); // expand - component.toggleSearch(); // start collapsing - component.onSearchAnimationEnd(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(false); - }); - - it('onSearchAnimationEnd() does nothing when not collapsing', () => { - const { component } = setup(); - component.toggleSearch(); - // Not in collapsing state — should be a no-op - component.onSearchAnimationEnd(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // 6. searchConfig — rendering + // 5. searchConfig — renders mfp-table-card-search // ------------------------------------------------------------------------- describe('searchConfig rendering', () => { - it('does not render ui5-search when searchConfig is absent', () => { + it('does not render mfp-table-card-search when searchConfig is absent', () => { const { fixture } = setup(); - expect(root(fixture).querySelector('ui5-search')).toBeNull(); - }); - - it('does not render the search toggle button when searchConfig is absent', () => { - const { fixture } = setup(); - expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); - }); - - it('renders ui5-search inline when alwaysOnDisplay is true', () => { - const { fixture } = setup({ - searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); - }); - - it('does not render the search toggle button when alwaysOnDisplay is true', () => { - const { fixture } = setup({ - searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); - }); - - it('renders the search toggle button when alwaysOnDisplay is false', () => { - const { fixture } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - }); - fixture.detectChanges(); - expect(root(fixture).querySelector('.card__search-btn')).not.toBeNull(); + expect(root(fixture).querySelector('mfp-table-card-search')).toBeNull(); }); - it('does not render ui5-search before toggle is clicked when alwaysOnDisplay is false', () => { + it('renders mfp-table-card-search when searchConfig is provided', () => { const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' }, }); fixture.detectChanges(); - expect(root(fixture).querySelector('ui5-search')).toBeNull(); - }); - - it('renders ui5-search after toggle button is clicked when alwaysOnDisplay is false', () => { - const { fixture, component } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - }); - fixture.detectChanges(); - component.toggleSearch(); - fixture.detectChanges(); - expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); - }); - - it('binds placeholder from searchConfig to ui5-search', () => { - const { fixture } = setup({ - searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - const search = root(fixture).querySelector('ui5-search'); - // Angular binds [placeholder] as a property; read via the property - expect((search as HTMLElement & { placeholder?: string })?.placeholder).toBe('Search pods…'); - }); - - it('binds accessibleName from searchConfig', () => { - const { fixture } = setup({ - searchConfig: { - accessibleName: 'Pod search', - alwaysOnDisplay: true, - }, - }); - fixture.detectChanges(); - const search = root(fixture).querySelector('ui5-search'); - expect( - (search as HTMLElement & { accessibleName?: string })?.accessibleName, - ).toBe('Pod search'); - }); - - it('defaults showClearIcon to true when not specified', () => { - const { fixture } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - const search = root(fixture).querySelector('ui5-search'); - expect( - (search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon, - ).toBe(true); - }); - - it('forwards showClearIcon: false when configured', () => { - const { fixture } = setup({ - searchConfig: { alwaysOnDisplay: true, showClearIcon: false }, - }); - fixture.detectChanges(); - const search = root(fixture).querySelector('ui5-search'); - expect( - (search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon, - ).toBe(false); - }); - - it('renders one ui5-search-scope per scopes entry', () => { - const { fixture } = setup({ - searchConfig: { - alwaysOnDisplay: true, - scopes: [ - { label: 'All', value: 'all' }, - { label: 'My Contributions', value: 'mine' }, - ], - }, - }); - fixture.detectChanges(); - const scopes = root(fixture).querySelectorAll('ui5-search-scope'); - expect(scopes).toHaveLength(2); - }); - - it('renders zero ui5-search-scope elements when scopes array is empty', () => { - const { fixture } = setup({ - searchConfig: { alwaysOnDisplay: true, scopes: [] }, - }); - fixture.detectChanges(); - expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); - }); - - it('sets text and value on each ui5-search-scope', () => { - const { fixture } = setup({ - searchConfig: { - alwaysOnDisplay: true, - scopes: [{ label: 'All', value: 'all' }], - }, - }); - fixture.detectChanges(); - const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { - text?: string; - value?: string; - }; - expect(scope?.text).toBe('All'); - expect(scope?.value).toBe('all'); - }); - }); - - // ------------------------------------------------------------------------- - // 7. searchConfig — outputs: searchChanged (debounced) - // ------------------------------------------------------------------------- - - describe('searchConfig: searchChanged output', () => { - it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { - const { fixture, component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - - const emitted: { value: string; scope?: string }[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); - - expect(emitted).toHaveLength(0); - vi.advanceTimersByTime(300); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: 'alpha', scope: undefined }); - }); - - it('does not emit searchChanged before the 300ms debounce elapses', () => { - const { fixture, component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - - const emitted: unknown[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.onSearchInput(fakeSearchEvent({ value: 'beta' })); - vi.advanceTimersByTime(299); - expect(emitted).toHaveLength(0); - }); - - it('includes active scope in searchChanged payload', () => { - const { fixture, component } = setup({ - searchConfig: { - alwaysOnDisplay: true, - scopes: [ - { label: 'All', value: 'all' }, - { label: 'Mine', value: 'mine' }, - ], - }, - }); - fixture.detectChanges(); - - const emitted: { value: string; scope?: string }[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - // Change scope first - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - - // Then type - component.onSearchInput(fakeSearchEvent({ value: 'pod' })); - vi.advanceTimersByTime(300); - - expect(emitted[0]).toEqual({ value: 'pod', scope: 'mine' }); - }); - - it('emits searchChanged with empty value after simulated clear icon (ui5Input with empty value)', () => { - const { fixture, component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - - // Type something first - component.onSearchInput(fakeSearchEvent({ value: 'foo' })); - vi.advanceTimersByTime(300); - - const emitted: { value: string; scope?: string }[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - // Simulate clear icon click (fires ui5Input with empty value) - component.onSearchInput(fakeSearchEvent({ value: '' })); - vi.advanceTimersByTime(300); - - expect(emitted[0]).toEqual({ value: '', scope: undefined }); - }); - }); - - // ------------------------------------------------------------------------- - // 8. searchConfig — outputs: searchSubmit (synchronous) - // ------------------------------------------------------------------------- - - describe('searchConfig: searchSubmit output', () => { - it('emits searchSubmit synchronously on ui5Search event', () => { - const { fixture, component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - fixture.detectChanges(); - - const emitted: { value: string; scope?: string }[] = []; - component.searchSubmit.subscribe((e) => emitted.push(e)); - - component.onSearchSubmit(fakeSearchEvent({ value: 'my-pod' })); - - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: 'my-pod', scope: undefined }); - }); - - it('includes scope in searchSubmit when a scope is active', () => { - const { component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - - const emitted: { value: string; scope?: string }[] = []; - component.searchSubmit.subscribe((e) => emitted.push(e)); - - component.onSearchSubmit(fakeSearchEvent({ value: 'redis', scopeValue: 'all' })); - - expect(emitted[0]).toEqual({ value: 'redis', scope: 'all' }); - }); - }); - - // ------------------------------------------------------------------------- - // 9. searchConfig — outputs: scopeChanged (synchronous) - // ------------------------------------------------------------------------- - - describe('searchConfig: scopeChanged output', () => { - it('emits scopeChanged synchronously on ui5ScopeChange event', () => { - const { component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - - const emitted: { value: string; scope?: string }[] = []; - component.scopeChanged.subscribe((e) => emitted.push(e)); - - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: '', scope: 'mine' }); - }); - - it('includes in-flight search text in scopeChanged payload', () => { - const { component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - - // Type something - component.onSearchInput(fakeSearchEvent({ value: 'cache' })); - - const emitted: { value: string; scope?: string }[] = []; - component.scopeChanged.subscribe((e) => emitted.push(e)); - - component.onSearchScopeChange(fakeSearchEvent({ value: 'cache', scopeValue: 'all' })); - - expect(emitted[0]).toEqual({ value: 'cache', scope: 'all' }); - }); - - it('updates activeScope after scopeChanged so subsequent searchChanged carries new scope', () => { - const { component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - - // Change scope - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - - const emitted: { value: string; scope?: string }[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.onSearchInput(fakeSearchEvent({ value: 'pod' })); - vi.advanceTimersByTime(300); - - expect(emitted[0]?.scope).toBe('mine'); - }); - }); - - // ------------------------------------------------------------------------- - // 10. searchConfig — collapse preserves state - // ------------------------------------------------------------------------- - - describe('collapse preserves search state', () => { - it('collapsing does not emit searchChanged synchronously', () => { - const { component } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - }); - - component.toggleSearch(); // expand - - component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); - vi.advanceTimersByTime(300); // flush debounce - - const emitted: unknown[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.toggleSearch(); // collapse - expect(emitted).toHaveLength(0); - }); - - it('searchControl.value is preserved after collapse animation ends', () => { - const { component } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - }); - - component.toggleSearch(); // expand - component.onSearchInput(fakeSearchEvent({ value: 'preserved-query' })); - - component.toggleSearch(); // start collapsing - component.onSearchAnimationEnd(); // animation done → collapsed - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe('preserved-query'); - }); - - it('re-expanding after collapse shows the same searchControl value', () => { - const { component } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - }); - - component.toggleSearch(); // expand - component.onSearchInput(fakeSearchEvent({ value: 'in-flight' })); - component.toggleSearch(); // start collapsing - component.onSearchAnimationEnd(); // collapsed - - component.toggleSearch(); // re-expand - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe('in-flight'); - }); - - it('active scope is preserved after collapse', () => { - const { component } = setup({ - searchConfig: { - placeholder: 'Search pods…', - scopes: [{ label: 'Mine', value: 'mine' }], - }, - }); - - component.toggleSearch(); // expand - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - component.toggleSearch(); // collapse - component.onSearchAnimationEnd(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).activeScope()).toBe('mine'); - }); - }); - - // ------------------------------------------------------------------------- - // 11. searchConfig — alwaysOnDisplay: toggleSearch is a no-op - // ------------------------------------------------------------------------- - - describe('toggleSearch() is a no-op when alwaysOnDisplay is true', () => { - it('does not change searchState when alwaysOnDisplay is true', () => { - const { component } = setup({ - searchConfig: { alwaysOnDisplay: true }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const before = (component as any).searchState(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchState()).toBe(before); - }); - }); - - // ------------------------------------------------------------------------- - // 12. searchConfig — buttonSettings.searchButton overrides - // ------------------------------------------------------------------------- - - describe('buttonSettings.searchButton overrides', () => { - it('applies custom icon to the search toggle button', () => { - const fixture: Fixture = TestBed.createComponent( - DeclarativeTableCard as unknown as typeof DeclarativeTableCard, - ); - - fixture.componentRef.setInput('config', { - tableConfig: READ_CONFIG, - searchConfig: { placeholder: 'Search pods…' }, - buttonSettings: { - searchButton: { icon: 'filter', tooltip: 'Open filter' }, - }, - } satisfies TableCardConfig); - fixture.componentRef.setInput('resources', RESOURCES); - fixture.componentRef.setInput('createFormState', {}); - fixture.componentRef.setInput('editFormState', {}); - fixture.detectChanges(); - - const btn = root(fixture).querySelector('.card__search-btn') as HTMLElement & { - icon?: string; - tooltip?: string; - }; - expect(btn?.icon).toBe('filter'); - expect(btn?.tooltip).toBe('Open filter'); + expect(root(fixture).querySelector('mfp-table-card-search')).not.toBeNull(); }); }); // ------------------------------------------------------------------------- - // 13. Create button visibility + // 6. Create button visibility // ------------------------------------------------------------------------- describe('create button', () => { diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts index eaee235..776e90b 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts @@ -8,39 +8,22 @@ import { } from '../table/models'; import { getResourceValueByJsonPath } from '../table/utils/resource-field-by-path'; import { TableCardConfig, TableCardFormState } from './models/configs'; +import { TableCardSearch } from './search/table-card-search.component'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, - Injector, ViewEncapsulation, - afterNextRender, computed, - effect, - inject, input, output, signal, - viewChild, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl } from '@angular/forms'; -import { Search } from '@fundamental-ngx/ui5-webcomponents-fiori/search'; -import { SearchScope } from '@fundamental-ngx/ui5-webcomponents-fiori/search-scope'; import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; import { Dialog } from '@fundamental-ngx/ui5-webcomponents/dialog'; import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; import { Title } from '@fundamental-ngx/ui5-webcomponents/title'; import '@ui5/webcomponents-icons/dist/add.js'; -import '@ui5/webcomponents-icons/dist/search.js'; -import { debounceTime } from 'rxjs'; - -type SearchState = 'collapsed' | 'expanded' | 'collapsing'; - -interface Ui5SearchEventTarget { - value?: string; - scopeValue?: string; -} @Component({ selector: 'mfp-declarative-table-card', @@ -51,8 +34,7 @@ interface Ui5SearchEventTarget { Title, Button, Icon, - Search, - SearchScope, + TableCardSearch, ], templateUrl: './declarative-table-card.component.html', styleUrl: './declarative-table-card.component.scss', @@ -99,15 +81,6 @@ export class DeclarativeTableCard { }>(); readonly deleteSubmit = output(); - protected searchState = signal('collapsed'); - protected searchExpanded = computed(() => this.searchState() !== 'collapsed'); - protected searchCollapsing = computed( - () => this.searchState() === 'collapsing', - ); - protected searchControl = new FormControl(''); - protected searchInputRef = viewChild('searchInput'); - protected activeScope = signal(undefined); - protected createDialogOpen = signal(false); protected editDialogOpen = signal(false); protected deleteDialogOpen = signal(false); @@ -118,9 +91,6 @@ export class DeclarativeTableCard { protected header = computed(() => this.config().header); protected headerTooltip = computed(() => this.config().headerTooltip); protected searchConfig = computed(() => this.config().searchConfig); - protected alwaysOnDisplay = computed( - () => this.searchConfig()?.alwaysOnDisplay === true, - ); protected createFormConfig = computed( () => this.config().createResourceFormConfig, ); @@ -147,73 +117,6 @@ export class DeclarativeTableCard { return this.buildInitialValues(editConfig.fields, pendingResource); }); - private readonly injector = inject(Injector); - - constructor() { - this.searchControl.valueChanges - .pipe(debounceTime(300), takeUntilDestroyed()) - .subscribe((value) => { - this.searchChanged.emit({ - value: value ?? '', - scope: this.activeScope(), - }); - }); - - effect(() => { - this.activeScope.set(this.searchConfig()?.scopeValue); - }); - } - - toggleSearch(): void { - if (this.alwaysOnDisplay()) { - return; - } - if (this.searchState() === 'expanded') { - this.collapseSearch(); - } else if (this.searchState() === 'collapsed') { - this.searchState.set('expanded'); - afterNextRender( - () => { - this.searchInputRef()?.elementRef.nativeElement.focus(); - }, - { injector: this.injector }, - ); - } - } - - onSearchAnimationEnd(): void { - if (this.searchCollapsing()) { - this.searchState.set('collapsed'); - } - } - - onSearchInput(event: Event): void { - const target = event.target as Ui5SearchEventTarget | null; - this.searchControl.setValue(target?.value ?? ''); - } - - onSearchSubmit(event: Event): void { - const target = event.target as Ui5SearchEventTarget | null; - this.searchSubmit.emit({ - value: target?.value ?? '', - scope: target?.scopeValue || undefined, - }); - } - - onSearchScopeChange(event: Event): void { - const target = event.target as Ui5SearchEventTarget | null; - const scope = target?.scopeValue || undefined; - this.activeScope.set(scope); - this.scopeChanged.emit({ - value: this.searchControl.value ?? '', - scope, - }); - } - - private collapseSearch(): void { - this.searchState.set('collapsing'); - } - onButtonClick(event: ResourceFieldButtonClickEvent): void { const action = event.field.uiSettings?.buttonSettings?.action; if (action === 'edit' && event.resource) { diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html new file mode 100644 index 0000000..1099ab3 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -0,0 +1,48 @@ +@if (alwaysOnDisplay()) { + + @for (s of searchConfig().scopes ?? []; track s.value) { + + } + +} @else { + @if (searchExpanded()) { + + @for (s of searchConfig().scopes ?? []; track s.value) { + + } + + } + +} diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss new file mode 100644 index 0000000..21a7c65 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss @@ -0,0 +1,47 @@ +:host { + display: contents; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: scaleX(0); + } + to { + opacity: 1; + transform: scaleX(1); + } +} + +@keyframes slide-out { + from { + opacity: 1; + transform: scaleX(1); + } + to { + opacity: 0; + transform: scaleX(0); + } +} + +.card__search { + transform-origin: right center; + + &--enter { + animation: slide-in 0.2s ease-out both; + } + + &--leave { + animation: slide-out 0.2s ease-in both; + } + + &--inline { + flex: 1; + min-width: 0; + } +} + +.card__search-btn { + min-width: auto; + color: var(--sapButton_IconColor, #0070f2); +} diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts new file mode 100644 index 0000000..06916ce --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -0,0 +1,419 @@ +import { TableCardSearch } from './table-card-search.component'; +import { ButtonSettings } from '../../models'; +import { TableCardSearchConfig } from '../models/search-config'; +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +type Comp = TableCardSearch; +type Fixture = ComponentFixture; + +function root(fixture: Fixture): ShadowRoot | HTMLElement { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + +function fakeSearchEvent(opts: { value?: string; scopeValue?: string } = {}): Event { + return { target: { value: opts.value ?? '', scopeValue: opts.scopeValue } } as unknown as Event; +} + +function setup(opts: { + searchConfig?: TableCardSearchConfig; + searchButtonConfig?: Partial; +} = {}): { fixture: Fixture; component: Comp } { + const fixture = TestBed.createComponent(TableCardSearch); + const component = fixture.componentInstance; + + fixture.componentRef.setInput('searchConfig', opts.searchConfig ?? {}); + if (opts.searchButtonConfig !== undefined) { + fixture.componentRef.setInput('searchButtonConfig', opts.searchButtonConfig); + } + + fixture.detectChanges(); + return { fixture, component }; +} + +describe('TableCardSearch', () => { + beforeEach(async () => { + vi.useFakeTimers(); + await TestBed.configureTestingModule({ + imports: [TableCardSearch], + schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create', () => { + const { component } = setup(); + expect(component).toBeTruthy(); + }); + + // ------------------------------------------------------------------------- + // 1. Toggle UX — state machine + // ------------------------------------------------------------------------- + + describe('search toggle UX', () => { + it('searchExpanded starts as false', () => { + const { component } = setup(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchExpanded()).toBe(false); + }); + + it('toggleSearch() sets searchExpanded to true on first call', () => { + const { component } = setup(); + component.toggleSearch(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchExpanded()).toBe(true); + }); + + it('toggleSearch() starts collapsing on second call when already expanded', () => { + const { component } = setup(); + component.toggleSearch(); + component.toggleSearch(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchCollapsing()).toBe(true); + }); + + it('onSearchAnimationEnd() transitions state to collapsed after collapse animation', () => { + const { component } = setup(); + component.toggleSearch(); + component.toggleSearch(); + component.onSearchAnimationEnd(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchCollapsing()).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchExpanded()).toBe(false); + }); + + it('onSearchAnimationEnd() does nothing when not collapsing', () => { + const { component } = setup(); + component.toggleSearch(); + component.onSearchAnimationEnd(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchExpanded()).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Rendering + // ------------------------------------------------------------------------- + + describe('rendering', () => { + it('does not render ui5-search when alwaysOnDisplay is false and not expanded', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + expect(root(fixture).querySelector('ui5-search')).toBeNull(); + }); + + it('renders the search toggle button when alwaysOnDisplay is false', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + expect(root(fixture).querySelector('.card__search-btn')).not.toBeNull(); + }); + + it('renders ui5-search after toggle button is clicked', () => { + const { fixture, component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + component.toggleSearch(); + fixture.detectChanges(); + expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); + }); + + it('renders ui5-search inline when alwaysOnDisplay is true', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); + expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); + }); + + it('does not render the search toggle button when alwaysOnDisplay is true', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); + expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); + }); + + it('binds placeholder from searchConfig to ui5-search', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); + const search = root(fixture).querySelector('ui5-search'); + expect((search as HTMLElement & { placeholder?: string })?.placeholder).toBe('Search pods…'); + }); + + it('binds accessibleName from searchConfig', () => { + const { fixture } = setup({ searchConfig: { accessibleName: 'Pod search', alwaysOnDisplay: true } }); + const search = root(fixture).querySelector('ui5-search'); + expect((search as HTMLElement & { accessibleName?: string })?.accessibleName).toBe('Pod search'); + }); + + it('defaults showClearIcon to true when not specified', () => { + const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const search = root(fixture).querySelector('ui5-search'); + expect((search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon).toBe(true); + }); + + it('forwards showClearIcon: false when configured', () => { + const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true, showClearIcon: false } }); + const search = root(fixture).querySelector('ui5-search'); + expect((search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon).toBe(false); + }); + + it('renders one ui5-search-scope per scopes entry', () => { + const { fixture } = setup({ + searchConfig: { + alwaysOnDisplay: true, + scopes: [ + { label: 'All', value: 'all' }, + { label: 'My Contributions', value: 'mine' }, + ], + }, + }); + expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(2); + }); + + it('renders zero ui5-search-scope elements when scopes array is empty', () => { + const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true, scopes: [] } }); + expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); + }); + + it('sets text and value on each ui5-search-scope', () => { + const { fixture } = setup({ + searchConfig: { alwaysOnDisplay: true, scopes: [{ label: 'All', value: 'all' }] }, + }); + const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { + text?: string; + value?: string; + }; + expect(scope?.text).toBe('All'); + expect(scope?.value).toBe('all'); + }); + }); + + // ------------------------------------------------------------------------- + // 3. searchChanged output (debounced) + // ------------------------------------------------------------------------- + + describe('searchChanged output', () => { + it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { + const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); + expect(emitted).toHaveLength(0); + vi.advanceTimersByTime(300); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: 'alpha', scope: undefined }); + }); + + it('does not emit searchChanged before the 300ms debounce elapses', () => { + const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + fixture.detectChanges(); + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'beta' })); + vi.advanceTimersByTime(299); + expect(emitted).toHaveLength(0); + }); + + it('includes active scope in searchChanged payload', () => { + const { fixture, component } = setup({ + searchConfig: { + alwaysOnDisplay: true, + scopes: [ + { label: 'All', value: 'all' }, + { label: 'Mine', value: 'mine' }, + ], + }, + }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]).toEqual({ value: 'pod', scope: 'mine' }); + }); + + it('emits searchChanged with empty value after simulated clear', () => { + const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + fixture.detectChanges(); + + component.onSearchInput(fakeSearchEvent({ value: 'foo' })); + vi.advanceTimersByTime(300); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: '' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]).toEqual({ value: '', scope: undefined }); + }); + }); + + // ------------------------------------------------------------------------- + // 4. searchSubmit output (synchronous) + // ------------------------------------------------------------------------- + + describe('searchSubmit output', () => { + it('emits searchSubmit synchronously on ui5Search event', () => { + const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + component.onSearchSubmit(fakeSearchEvent({ value: 'my-pod' })); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: 'my-pod', scope: undefined }); + }); + + it('includes scope in searchSubmit when a scope is active', () => { + const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + + const emitted: { value: string; scope?: string }[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + component.onSearchSubmit(fakeSearchEvent({ value: 'redis', scopeValue: 'all' })); + expect(emitted[0]).toEqual({ value: 'redis', scope: 'all' }); + }); + }); + + // ------------------------------------------------------------------------- + // 5. scopeChanged output (synchronous) + // ------------------------------------------------------------------------- + + describe('scopeChanged output', () => { + it('emits scopeChanged synchronously on ui5ScopeChange event', () => { + const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + + const emitted: { value: string; scope?: string }[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ value: '', scope: 'mine' }); + }); + + it('includes in-flight search text in scopeChanged payload', () => { + const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + + component.onSearchInput(fakeSearchEvent({ value: 'cache' })); + + const emitted: { value: string; scope?: string }[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: 'cache', scopeValue: 'all' })); + expect(emitted[0]).toEqual({ value: 'cache', scope: 'all' }); + }); + + it('updates activeScope so subsequent searchChanged carries new scope', () => { + const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]?.scope).toBe('mine'); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Collapse preserves search state + // ------------------------------------------------------------------------- + + describe('collapse preserves search state', () => { + it('collapsing does not emit searchChanged synchronously', () => { + const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + + component.toggleSearch(); + component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); + vi.advanceTimersByTime(300); + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.toggleSearch(); + expect(emitted).toHaveLength(0); + }); + + it('searchControl.value is preserved after collapse animation ends', () => { + const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + + component.toggleSearch(); + component.onSearchInput(fakeSearchEvent({ value: 'preserved-query' })); + component.toggleSearch(); + component.onSearchAnimationEnd(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchControl.value).toBe('preserved-query'); + }); + + it('re-expanding after collapse shows the same searchControl value', () => { + const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + + component.toggleSearch(); + component.onSearchInput(fakeSearchEvent({ value: 'in-flight' })); + component.toggleSearch(); + component.onSearchAnimationEnd(); + component.toggleSearch(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchControl.value).toBe('in-flight'); + }); + + it('active scope is preserved after collapse', () => { + const { component } = setup({ + searchConfig: { placeholder: 'Search pods…', scopes: [{ label: 'Mine', value: 'mine' }] }, + }); + + component.toggleSearch(); + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + component.toggleSearch(); + component.onSearchAnimationEnd(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).activeScope()).toBe('mine'); + }); + }); + + // ------------------------------------------------------------------------- + // 7. toggleSearch() is a no-op when alwaysOnDisplay is true + // ------------------------------------------------------------------------- + + describe('toggleSearch() is a no-op when alwaysOnDisplay is true', () => { + it('does not change searchState when alwaysOnDisplay is true', () => { + const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const before = (component as any).searchState(); + component.toggleSearch(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchState()).toBe(before); + }); + }); + + // ------------------------------------------------------------------------- + // 8. searchButtonConfig input overrides + // ------------------------------------------------------------------------- + + describe('searchButtonConfig input', () => { + it('applies custom icon and tooltip to the search toggle button', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + searchButtonConfig: { icon: 'filter', tooltip: 'Open filter' }, + }); + + const btn = root(fixture).querySelector('.card__search-btn') as HTMLElement & { + icon?: string; + tooltip?: string; + }; + expect(btn?.icon).toBe('filter'); + expect(btn?.tooltip).toBe('Open filter'); + }); + }); +}); diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts new file mode 100644 index 0000000..ff846d9 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -0,0 +1,128 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Injector, + ViewEncapsulation, + afterNextRender, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { Search } from '@fundamental-ngx/ui5-webcomponents-fiori/search'; +import { SearchScope } from '@fundamental-ngx/ui5-webcomponents-fiori/search-scope'; +import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; +import '@ui5/webcomponents-icons/dist/search.js'; +import { debounceTime } from 'rxjs'; +import { ButtonSettings } from '../../models'; +import { TableCardSearchConfig } from '../models/search-config'; + +type SearchState = 'collapsed' | 'expanded' | 'collapsing'; + +interface Ui5SearchEventTarget { + value?: string; + scopeValue?: string; +} + +@Component({ + selector: 'mfp-table-card-search', + imports: [Button, Search, SearchScope], + templateUrl: './table-card-search.component.html', + styleUrl: './table-card-search.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.ShadowDom, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class TableCardSearch { + searchConfig = input.required(); + searchButtonConfig = input | undefined>(undefined); + + readonly searchChanged = output<{ value: string; scope?: string }>(); + readonly searchSubmit = output<{ value: string; scope?: string }>(); + readonly scopeChanged = output<{ value: string; scope?: string }>(); + + protected searchState = signal('collapsed'); + protected searchExpanded = computed(() => this.searchState() !== 'collapsed'); + protected searchCollapsing = computed( + () => this.searchState() === 'collapsing', + ); + protected searchControl = new FormControl(''); + protected searchInputRef = viewChild('searchInput'); + protected activeScope = signal(undefined); + protected alwaysOnDisplay = computed( + () => this.searchConfig().alwaysOnDisplay === true, + ); + + private readonly injector = inject(Injector); + + constructor() { + this.searchControl.valueChanges + .pipe(debounceTime(300), takeUntilDestroyed()) + .subscribe((value) => { + this.searchChanged.emit({ + value: value ?? '', + scope: this.activeScope(), + }); + }); + + effect(() => { + this.activeScope.set(this.searchConfig().scopeValue); + }); + } + + toggleSearch(): void { + if (this.alwaysOnDisplay()) { + return; + } + if (this.searchState() === 'expanded') { + this.collapseSearch(); + } else if (this.searchState() === 'collapsed') { + this.searchState.set('expanded'); + afterNextRender( + () => { + this.searchInputRef()?.elementRef.nativeElement.focus(); + }, + { injector: this.injector }, + ); + } + } + + onSearchAnimationEnd(): void { + if (this.searchCollapsing()) { + this.searchState.set('collapsed'); + } + } + + onSearchInput(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + this.searchControl.setValue(target?.value ?? ''); + } + + onSearchSubmit(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + this.searchSubmit.emit({ + value: target?.value ?? '', + scope: target?.scopeValue || undefined, + }); + } + + onSearchScopeChange(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + const scope = target?.scopeValue || undefined; + this.activeScope.set(scope); + this.scopeChanged.emit({ + value: this.searchControl.value ?? '', + scope, + }); + } + + private collapseSearch(): void { + this.searchState.set('collapsing'); + } +} From 4e6d89ddf445f21670b4e4ec9a9c5c866ee648af Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 16:29:29 +0400 Subject: [PATCH 03/13] feat: adress pr comments Signed-off-by: Sobyt483 --- .../search/table-card-search.component.html | 4 +- .../table-card-search.component.spec.ts | 40 ++++++++++++++++++- .../search/table-card-search.component.ts | 12 ++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index 1099ab3..927b5e3 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -3,7 +3,7 @@ class="card__search card__search--inline" [accessibleName]="searchConfig().accessibleName" [placeholder]="searchConfig().placeholder" - [scopeValue]="searchConfig().scopeValue" + [scopeValue]="activeScope()" [showClearIcon]="searchConfig().showClearIcon ?? true" [value]="searchControl.value ?? ''" (ui5Input)="onSearchInput($event)" @@ -24,7 +24,7 @@ (searchCollapsing() ? 'leave' : 'enter') " [placeholder]="searchConfig().placeholder" - [scopeValue]="searchConfig().scopeValue" + [scopeValue]="activeScope()" [showClearIcon]="searchConfig().showClearIcon ?? true" [value]="searchControl.value ?? ''" (animationend)="onSearchAnimationEnd()" diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts index 06916ce..70d0bc7 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -398,7 +398,45 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 8. searchButtonConfig input overrides + // 8. searchConfig.value binding + // ------------------------------------------------------------------------- + + describe('searchConfig.value binding', () => { + it('initialises searchControl with config.value on creation', () => { + const { component } = setup({ searchConfig: { value: 'initial' } }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchControl.value).toBe('initial'); + }); + + it('updates searchControl when searchConfig.value changes', () => { + const { fixture, component } = setup({ searchConfig: { value: 'first' } }); + fixture.componentRef.setInput('searchConfig', { value: 'second' }); + fixture.detectChanges(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((component as any).searchControl.value).toBe('second'); + }); + + it('does not emit searchChanged when config.value is set to the same value', () => { + const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + vi.advanceTimersByTime(300); // flush any pending init emission + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + fixture.componentRef.setInput('searchConfig', { alwaysOnDisplay: true, value: 'same' }); + fixture.detectChanges(); + vi.advanceTimersByTime(300); + emitted.length = 0; // clear first emission + + fixture.componentRef.setInput('searchConfig', { alwaysOnDisplay: true, value: 'same' }); + fixture.detectChanges(); + vi.advanceTimersByTime(300); + expect(emitted).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // 9. searchButtonConfig input overrides // ------------------------------------------------------------------------- describe('searchButtonConfig input', () => { diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index ff846d9..f83ff28 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -1,3 +1,5 @@ +import { ButtonSettings } from '../../models'; +import { TableCardSearchConfig } from '../models/search-config'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -20,8 +22,6 @@ import { SearchScope } from '@fundamental-ngx/ui5-webcomponents-fiori/search-sco import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; import '@ui5/webcomponents-icons/dist/search.js'; import { debounceTime } from 'rxjs'; -import { ButtonSettings } from '../../models'; -import { TableCardSearchConfig } from '../models/search-config'; type SearchState = 'collapsed' | 'expanded' | 'collapsing'; @@ -72,7 +72,13 @@ export class TableCardSearch { }); effect(() => { - this.activeScope.set(this.searchConfig().scopeValue); + const config = this.searchConfig(); + this.activeScope.set(config.scopeValue); + + const nextValue = config.value ?? ''; + if (this.searchControl.value !== nextValue) { + this.searchControl.setValue(nextValue); + } }); } From 84b05bb97d4d927d0a2825cc69eb8131d4e864e8 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 21:19:08 +0400 Subject: [PATCH 04/13] feat: make some adjustments Signed-off-by: Sobyt483 --- .../stories/declarative-table-card.stories.ts | 132 +++++------ .../declarative-table-card.component.html | 1 - .../declarative-table-card.component.scss | 13 +- .../declarative-table-card.component.ts | 3 - .../table-card/models/configs.ts | 2 - .../table-card/models/search-config.ts | 3 - .../search/table-card-search.component.html | 62 ++--- .../search/table-card-search.component.scss | 43 +--- .../table-card-search.component.spec.ts | 214 ++---------------- .../search/table-card-search.component.ts | 64 ++---- .../table/models/table-config.ts | 2 + 11 files changed, 139 insertions(+), 400 deletions(-) diff --git a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts index 3c3fa39..7541325 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -10,7 +10,7 @@ import type { TableConfig, } from '../table-card/models/configs'; import type { TableFieldDefinition } from '../table/models'; -import { Component, Input } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import type { Meta, StoryObj } from '@storybook/angular'; import '@ui5/webcomponents-icons/dist/detail-view.js'; @@ -156,17 +156,64 @@ const BASE_CONFIG: TableCardConfig = { #tableCard [config]="config" [createFormState]="createFormState" - [resources]="resources" + [resources]="filteredResources" (createFieldChange)="onCreateFieldChange($event)" (createSubmit)="onCreateSubmit($event, tableCard)" + (scopeChanged)="onScopeChanged($event)" + (searchChanged)="onSearchChanged($event)" + (searchSubmit)="onSearchSubmit($event)" /> `, }) -class DeclarativeTableCardCreateStory { +class DeclarativeTableCardCreateStory implements OnInit { @Input() config!: TableCardConfig; @Input() resources: GenericResource[] = []; createFormState: TableCardFormState = {}; + searchTerm = ''; + activeScope: string | undefined = undefined; + + ngOnInit(): void { + this.searchTerm = this.config?.searchConfig?.value ?? ''; + this.activeScope = this.config?.searchConfig?.scopeValue; + } + + get filteredResources(): GenericResource[] { + const sc = this.config?.searchConfig; + if (!sc) return this.resources; + + let result = this.resources as Pod[]; + + if (this.activeScope === 'default' || this.activeScope === 'kube-system') { + result = result.filter( + (pod) => pod.metadata.namespace === this.activeScope, + ); + } + + if (this.searchTerm) { + result = result.filter((pod) => + pod.metadata.name.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + } + + return result as GenericResource[]; + } + + onSearchChanged(event: { value: string; scope?: string }): void { + this.searchTerm = event.value; + this.activeScope = event.scope; + console.log('[searchChanged]', event); + } + + onSearchSubmit(event: { value: string; scope?: string }): void { + console.log('[searchSubmit]', event); + } + + onScopeChanged(event: { value: string; scope?: string }): void { + this.activeScope = event.scope; + console.log('[scopeChanged]', event); + } + onCreateFieldChange(event: FormFieldChangeEvent): void { const fieldErrors: Record = { ...this.createFormState.fieldErrors, @@ -473,57 +520,20 @@ export const WithPagination: Story = { }, }; -// --------------------------------------------------------------------------- -// Search wrapper component (for stories wiring outputs to console) -// --------------------------------------------------------------------------- - -@Component({ - selector: 'mfp-declarative-table-card-search-story', - imports: [DeclarativeTableCard], - template: ` - - `, -}) -class DeclarativeTableCardSearchStory { - @Input() config!: TableCardConfig; - @Input() resources: GenericResource[] = []; - - onSearchChanged(event: { value: string; scope?: string }): void { - console.log('[searchChanged]', event); - } - - onSearchSubmit(event: { value: string; scope?: string }): void { - console.log('[searchSubmit]', event); - } - - onScopeChanged(event: { value: string; scope?: string }): void { - console.log('[scopeChanged]', event); - } -} - -type SearchStory = StoryObj; +type SearchStory = StoryObj; /** - * Search toggle UX: a search icon button reveals `` on click. - * Collapsing preserves the entered text — re-expanding restores the in-flight query. - * Use the built-in clear icon (×) to clear the value. + * Search is always visible when `searchConfig` is provided. + * The input is pre-filled with `value` from `searchConfig` and the table is + * filtered on load. Typing updates the count in real-time (300 ms debounce). */ export const WithSearch: SearchStory = { - render: (args) => ({ - props: args, - component: DeclarativeTableCardSearchStory, - }), args: { config: { ...BASE_CONFIG, searchConfig: { placeholder: 'Search pods…', + value: 'server', } satisfies TableCardSearchConfig, }, resources: PODS, @@ -531,44 +541,22 @@ export const WithSearch: SearchStory = { }; /** - * `alwaysOnDisplay: true` — `` is rendered inline in the toolbar with no toggle button. - */ -export const WithSearchAlwaysOn: SearchStory = { - render: (args) => ({ - props: args, - component: DeclarativeTableCardSearchStory, - }), - args: { - config: { - ...BASE_CONFIG, - searchConfig: { - placeholder: 'Search pods…', - alwaysOnDisplay: true, - } satisfies TableCardSearchConfig, - }, - resources: PODS, - }, -}; - -/** - * Scopes dropdown lists "All" and "My Contributions" next to the search input. + * Scopes dropdown lists namespaces next to the search input. * Selecting a scope emits `scopeChanged`; submitting the form emits `searchSubmit`. * Both events carry `{ value, scope }`. `searchChanged` fires after 300 ms debounce. * Open the Actions tab to observe all three outputs. */ export const WithSearchAndScopes: SearchStory = { - render: (args) => ({ - props: args, - component: DeclarativeTableCardSearchStory, - }), args: { config: { ...BASE_CONFIG, searchConfig: { placeholder: 'Search pods…', + value: 'api', scopes: [ - { label: 'All', value: 'all' }, - { label: 'My Contributions', value: 'mine' }, + { label: 'All namespaces', value: 'all' }, + { label: 'default', value: 'default' }, + { label: 'kube-system', value: 'kube-system' }, ], scopeValue: 'all', } satisfies TableCardSearchConfig, diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html index 3c3e939..30f1ff6 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html @@ -16,7 +16,6 @@
@if (searchConfig(); as sc) { { protected createButtonConfig = computed( () => this.config().buttonSettings?.createButton, ); - protected searchButtonConfig = computed( - () => this.config().buttonSettings?.searchButton, - ); protected effectiveColumns = computed(() => this.addActionsColumn()); protected editInitialValue = computed(() => { const pendingResource = this.pendingResource(); diff --git a/projects/ngx/declarative-ui/table-card/models/configs.ts b/projects/ngx/declarative-ui/table-card/models/configs.ts index 59077cd..8d13ef9 100644 --- a/projects/ngx/declarative-ui/table-card/models/configs.ts +++ b/projects/ngx/declarative-ui/table-card/models/configs.ts @@ -40,8 +40,6 @@ export interface DeleteResourceConfirmationConfig { export interface TableCardButtonSettings { /** Partial override for the "Create" button. */ createButton?: Partial; - /** Partial override for the search toggle button. */ - searchButton?: Partial; /** Partial override for the per-row "Edit" button. */ editButton?: Partial; /** Partial override for the per-row "Delete" button. */ diff --git a/projects/ngx/declarative-ui/table-card/models/search-config.ts b/projects/ngx/declarative-ui/table-card/models/search-config.ts index 5601585..238a88d 100644 --- a/projects/ngx/declarative-ui/table-card/models/search-config.ts +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -20,7 +20,4 @@ export interface TableCardSearchConfig { value?: string; /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ scopes?: Scope[]; - /** When `true`, `` is always visible in the toolbar. - * When `false` (default), the search is hidden behind a search-toggle icon button; clicking it expands the search and clicking it again (or losing focus on an empty input) collapses the search. Collapse preserves the entered text and active scope — re-expanding restores the in-flight query. Use the built-in clear icon (`showClearIcon`) to clear the value. */ - alwaysOnDisplay?: boolean; } diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index 927b5e3..987851f 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -1,48 +1,16 @@ -@if (alwaysOnDisplay()) { - - @for (s of searchConfig().scopes ?? []; track s.value) { - - } - -} @else { - @if (searchExpanded()) { - - @for (s of searchConfig().scopes ?? []; track s.value) { - - } - + + @for (s of searchConfig().scopes ?? []; track s.value) { + } - -} + diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss index 21a7c65..98bb51d 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss @@ -2,46 +2,7 @@ display: contents; } -@keyframes slide-in { - from { - opacity: 0; - transform: scaleX(0); - } - to { - opacity: 1; - transform: scaleX(1); - } -} - -@keyframes slide-out { - from { - opacity: 1; - transform: scaleX(1); - } - to { - opacity: 0; - transform: scaleX(0); - } -} - .card__search { - transform-origin: right center; - - &--enter { - animation: slide-in 0.2s ease-out both; - } - - &--leave { - animation: slide-out 0.2s ease-in both; - } - - &--inline { - flex: 1; - min-width: 0; - } -} - -.card__search-btn { - min-width: auto; - color: var(--sapButton_IconColor, #0070f2); + flex: 1; + min-width: 300px; } diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts index 70d0bc7..ef02ee4 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -1,5 +1,4 @@ import { TableCardSearch } from './table-card-search.component'; -import { ButtonSettings } from '../../models'; import { TableCardSearchConfig } from '../models/search-config'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -17,15 +16,11 @@ function fakeSearchEvent(opts: { value?: string; scopeValue?: string } = {}): Ev function setup(opts: { searchConfig?: TableCardSearchConfig; - searchButtonConfig?: Partial; } = {}): { fixture: Fixture; component: Comp } { const fixture = TestBed.createComponent(TableCardSearch); const component = fixture.componentInstance; fixture.componentRef.setInput('searchConfig', opts.searchConfig ?? {}); - if (opts.searchButtonConfig !== undefined) { - fixture.componentRef.setInput('searchButtonConfig', opts.searchButtonConfig); - } fixture.detectChanges(); return { fixture, component }; @@ -50,103 +45,40 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 1. Toggle UX — state machine - // ------------------------------------------------------------------------- - - describe('search toggle UX', () => { - it('searchExpanded starts as false', () => { - const { component } = setup(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(false); - }); - - it('toggleSearch() sets searchExpanded to true on first call', () => { - const { component } = setup(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(true); - }); - - it('toggleSearch() starts collapsing on second call when already expanded', () => { - const { component } = setup(); - component.toggleSearch(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(true); - }); - - it('onSearchAnimationEnd() transitions state to collapsed after collapse animation', () => { - const { component } = setup(); - component.toggleSearch(); - component.toggleSearch(); - component.onSearchAnimationEnd(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(false); - }); - - it('onSearchAnimationEnd() does nothing when not collapsing', () => { - const { component } = setup(); - component.toggleSearch(); - component.onSearchAnimationEnd(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchExpanded()).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // 2. Rendering + // 1. Rendering // ------------------------------------------------------------------------- describe('rendering', () => { - it('does not render ui5-search when alwaysOnDisplay is false and not expanded', () => { + it('always renders ui5-search when searchConfig is provided', () => { const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - expect(root(fixture).querySelector('ui5-search')).toBeNull(); - }); - - it('renders the search toggle button when alwaysOnDisplay is false', () => { - const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - expect(root(fixture).querySelector('.card__search-btn')).not.toBeNull(); - }); - - it('renders ui5-search after toggle button is clicked', () => { - const { fixture, component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - component.toggleSearch(); - fixture.detectChanges(); expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); }); - it('renders ui5-search inline when alwaysOnDisplay is true', () => { - const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); - expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); - }); - - it('does not render the search toggle button when alwaysOnDisplay is true', () => { - const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); + it('does not render a search toggle button', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); expect(root(fixture).querySelector('.card__search-btn')).toBeNull(); }); it('binds placeholder from searchConfig to ui5-search', () => { - const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…', alwaysOnDisplay: true } }); + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); const search = root(fixture).querySelector('ui5-search'); expect((search as HTMLElement & { placeholder?: string })?.placeholder).toBe('Search pods…'); }); it('binds accessibleName from searchConfig', () => { - const { fixture } = setup({ searchConfig: { accessibleName: 'Pod search', alwaysOnDisplay: true } }); + const { fixture } = setup({ searchConfig: { accessibleName: 'Pod search' } }); const search = root(fixture).querySelector('ui5-search'); expect((search as HTMLElement & { accessibleName?: string })?.accessibleName).toBe('Pod search'); }); it('defaults showClearIcon to true when not specified', () => { - const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture } = setup({ searchConfig: {} }); const search = root(fixture).querySelector('ui5-search'); expect((search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon).toBe(true); }); it('forwards showClearIcon: false when configured', () => { - const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true, showClearIcon: false } }); + const { fixture } = setup({ searchConfig: { showClearIcon: false } }); const search = root(fixture).querySelector('ui5-search'); expect((search as HTMLElement & { showClearIcon?: boolean })?.showClearIcon).toBe(false); }); @@ -154,7 +86,6 @@ describe('TableCardSearch', () => { it('renders one ui5-search-scope per scopes entry', () => { const { fixture } = setup({ searchConfig: { - alwaysOnDisplay: true, scopes: [ { label: 'All', value: 'all' }, { label: 'My Contributions', value: 'mine' }, @@ -165,13 +96,13 @@ describe('TableCardSearch', () => { }); it('renders zero ui5-search-scope elements when scopes array is empty', () => { - const { fixture } = setup({ searchConfig: { alwaysOnDisplay: true, scopes: [] } }); + const { fixture } = setup({ searchConfig: { scopes: [] } }); expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); }); it('sets text and value on each ui5-search-scope', () => { const { fixture } = setup({ - searchConfig: { alwaysOnDisplay: true, scopes: [{ label: 'All', value: 'all' }] }, + searchConfig: { scopes: [{ label: 'All', value: 'all' }] }, }); const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { text?: string; @@ -183,12 +114,12 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 3. searchChanged output (debounced) + // 2. searchChanged output (debounced) // ------------------------------------------------------------------------- describe('searchChanged output', () => { it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { - const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); const emitted: { value: string; scope?: string }[] = []; @@ -202,7 +133,7 @@ describe('TableCardSearch', () => { }); it('does not emit searchChanged before the 300ms debounce elapses', () => { - const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); const emitted: unknown[] = []; @@ -216,7 +147,6 @@ describe('TableCardSearch', () => { it('includes active scope in searchChanged payload', () => { const { fixture, component } = setup({ searchConfig: { - alwaysOnDisplay: true, scopes: [ { label: 'All', value: 'all' }, { label: 'Mine', value: 'mine' }, @@ -236,7 +166,7 @@ describe('TableCardSearch', () => { }); it('emits searchChanged with empty value after simulated clear', () => { - const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); component.onSearchInput(fakeSearchEvent({ value: 'foo' })); @@ -253,12 +183,12 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 4. searchSubmit output (synchronous) + // 3. searchSubmit output (synchronous) // ------------------------------------------------------------------------- describe('searchSubmit output', () => { it('emits searchSubmit synchronously on ui5Search event', () => { - const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); const emitted: { value: string; scope?: string }[] = []; @@ -270,7 +200,7 @@ describe('TableCardSearch', () => { }); it('includes scope in searchSubmit when a scope is active', () => { - const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { component } = setup({ searchConfig: {} }); const emitted: { value: string; scope?: string }[] = []; component.searchSubmit.subscribe((e) => emitted.push(e)); @@ -281,12 +211,12 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 5. scopeChanged output (synchronous) + // 4. scopeChanged output (synchronous) // ------------------------------------------------------------------------- describe('scopeChanged output', () => { it('emits scopeChanged synchronously on ui5ScopeChange event', () => { - const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { component } = setup({ searchConfig: {} }); const emitted: { value: string; scope?: string }[] = []; component.scopeChanged.subscribe((e) => emitted.push(e)); @@ -297,7 +227,7 @@ describe('TableCardSearch', () => { }); it('includes in-flight search text in scopeChanged payload', () => { - const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { component } = setup({ searchConfig: {} }); component.onSearchInput(fakeSearchEvent({ value: 'cache' })); @@ -309,7 +239,7 @@ describe('TableCardSearch', () => { }); it('updates activeScope so subsequent searchChanged carries new scope', () => { - const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { component } = setup({ searchConfig: {} }); component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); @@ -324,81 +254,7 @@ describe('TableCardSearch', () => { }); // ------------------------------------------------------------------------- - // 6. Collapse preserves search state - // ------------------------------------------------------------------------- - - describe('collapse preserves search state', () => { - it('collapsing does not emit searchChanged synchronously', () => { - const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - - component.toggleSearch(); - component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); - vi.advanceTimersByTime(300); - - const emitted: unknown[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.toggleSearch(); - expect(emitted).toHaveLength(0); - }); - - it('searchControl.value is preserved after collapse animation ends', () => { - const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - - component.toggleSearch(); - component.onSearchInput(fakeSearchEvent({ value: 'preserved-query' })); - component.toggleSearch(); - component.onSearchAnimationEnd(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe('preserved-query'); - }); - - it('re-expanding after collapse shows the same searchControl value', () => { - const { component } = setup({ searchConfig: { placeholder: 'Search pods…' } }); - - component.toggleSearch(); - component.onSearchInput(fakeSearchEvent({ value: 'in-flight' })); - component.toggleSearch(); - component.onSearchAnimationEnd(); - component.toggleSearch(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe('in-flight'); - }); - - it('active scope is preserved after collapse', () => { - const { component } = setup({ - searchConfig: { placeholder: 'Search pods…', scopes: [{ label: 'Mine', value: 'mine' }] }, - }); - - component.toggleSearch(); - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - component.toggleSearch(); - component.onSearchAnimationEnd(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).activeScope()).toBe('mine'); - }); - }); - - // ------------------------------------------------------------------------- - // 7. toggleSearch() is a no-op when alwaysOnDisplay is true - // ------------------------------------------------------------------------- - - describe('toggleSearch() is a no-op when alwaysOnDisplay is true', () => { - it('does not change searchState when alwaysOnDisplay is true', () => { - const { component } = setup({ searchConfig: { alwaysOnDisplay: true } }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const before = (component as any).searchState(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchState()).toBe(before); - }); - }); - - // ------------------------------------------------------------------------- - // 8. searchConfig.value binding + // 5. searchConfig.value binding // ------------------------------------------------------------------------- describe('searchConfig.value binding', () => { @@ -417,41 +273,21 @@ describe('TableCardSearch', () => { }); it('does not emit searchChanged when config.value is set to the same value', () => { - const { fixture, component } = setup({ searchConfig: { alwaysOnDisplay: true } }); + const { fixture, component } = setup({ searchConfig: {} }); vi.advanceTimersByTime(300); // flush any pending init emission const emitted: unknown[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); - fixture.componentRef.setInput('searchConfig', { alwaysOnDisplay: true, value: 'same' }); + fixture.componentRef.setInput('searchConfig', { value: 'same' }); fixture.detectChanges(); vi.advanceTimersByTime(300); emitted.length = 0; // clear first emission - fixture.componentRef.setInput('searchConfig', { alwaysOnDisplay: true, value: 'same' }); + fixture.componentRef.setInput('searchConfig', { value: 'same' }); fixture.detectChanges(); vi.advanceTimersByTime(300); expect(emitted).toHaveLength(0); }); }); - - // ------------------------------------------------------------------------- - // 9. searchButtonConfig input overrides - // ------------------------------------------------------------------------- - - describe('searchButtonConfig input', () => { - it('applies custom icon and tooltip to the search toggle button', () => { - const { fixture } = setup({ - searchConfig: { placeholder: 'Search pods…' }, - searchButtonConfig: { icon: 'filter', tooltip: 'Open filter' }, - }); - - const btn = root(fixture).querySelector('.card__search-btn') as HTMLElement & { - icon?: string; - tooltip?: string; - }; - expect(btn?.icon).toBe('filter'); - expect(btn?.tooltip).toBe('Open filter'); - }); - }); }); diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index f83ff28..b1e6622 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -1,4 +1,3 @@ -import { ButtonSettings } from '../../models'; import { TableCardSearchConfig } from '../models/search-config'; import { CUSTOM_ELEMENTS_SCHEMA, @@ -6,8 +5,6 @@ import { Component, Injector, ViewEncapsulation, - afterNextRender, - computed, effect, inject, input, @@ -19,12 +16,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { Search } from '@fundamental-ngx/ui5-webcomponents-fiori/search'; import { SearchScope } from '@fundamental-ngx/ui5-webcomponents-fiori/search-scope'; -import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; import '@ui5/webcomponents-icons/dist/search.js'; import { debounceTime } from 'rxjs'; -type SearchState = 'collapsed' | 'expanded' | 'collapsing'; - interface Ui5SearchEventTarget { value?: string; scopeValue?: string; @@ -32,7 +26,7 @@ interface Ui5SearchEventTarget { @Component({ selector: 'mfp-table-card-search', - imports: [Button, Search, SearchScope], + imports: [Search, SearchScope], templateUrl: './table-card-search.component.html', styleUrl: './table-card-search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -41,23 +35,14 @@ interface Ui5SearchEventTarget { }) export class TableCardSearch { searchConfig = input.required(); - searchButtonConfig = input | undefined>(undefined); readonly searchChanged = output<{ value: string; scope?: string }>(); readonly searchSubmit = output<{ value: string; scope?: string }>(); readonly scopeChanged = output<{ value: string; scope?: string }>(); - protected searchState = signal('collapsed'); - protected searchExpanded = computed(() => this.searchState() !== 'collapsed'); - protected searchCollapsing = computed( - () => this.searchState() === 'collapsing', - ); protected searchControl = new FormControl(''); protected searchInputRef = viewChild('searchInput'); protected activeScope = signal(undefined); - protected alwaysOnDisplay = computed( - () => this.searchConfig().alwaysOnDisplay === true, - ); private readonly injector = inject(Injector); @@ -80,29 +65,11 @@ export class TableCardSearch { this.searchControl.setValue(nextValue); } }); - } - - toggleSearch(): void { - if (this.alwaysOnDisplay()) { - return; - } - if (this.searchState() === 'expanded') { - this.collapseSearch(); - } else if (this.searchState() === 'collapsed') { - this.searchState.set('expanded'); - afterNextRender( - () => { - this.searchInputRef()?.elementRef.nativeElement.focus(); - }, - { injector: this.injector }, - ); - } - } - onSearchAnimationEnd(): void { - if (this.searchCollapsing()) { - this.searchState.set('collapsed'); - } + // Workaround for ui5-select truncating long scope labels — see https://github.com/UI5/webcomponents/issues/13719 + setTimeout(() => { + this.fixSelectWidth(); + }, 0); } onSearchInput(event: Event): void { @@ -128,7 +95,24 @@ export class TableCardSearch { }); } - private collapseSearch(): void { - this.searchState.set('collapsing'); + private fixSelectWidth(): void { + if (!this.searchConfig().scopes?.length) return; + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | HTMLElement + | undefined; + const ui5Select = nativeEl?.shadowRoot?.querySelector( + 'ui5-select', + ) as HTMLElement | null; + if (!ui5Select) return; + ui5Select.style.maxWidth = 'none'; + ui5Select.style.minWidth = 'fit-content'; + const label = ui5Select.shadowRoot?.querySelector( + '.ui5-select-label-root', + ) as HTMLElement | null; + if (label) { + label.style.marginRight = '5px'; + label.style.overflow = 'visible'; + label.style.textOverflow = 'clip'; + } } } diff --git a/projects/ngx/declarative-ui/table/models/table-config.ts b/projects/ngx/declarative-ui/table/models/table-config.ts index 9d51786..a2b9997 100644 --- a/projects/ngx/declarative-ui/table/models/table-config.ts +++ b/projects/ngx/declarative-ui/table/models/table-config.ts @@ -1,5 +1,7 @@ import { TableFieldDefinition } from '../../models/ui-definition'; +export type { TableFieldDefinition } from '../../models/ui-definition'; + /** Configuration for the `mfp-declarative-table` component. */ export interface TableConfig { /** Column definitions. */ From 7f0b4eb8356adc59c78ccb62933bdadc032b11a4 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 21:26:41 +0400 Subject: [PATCH 05/13] feat: move interfaces Signed-off-by: Sobyt483 --- projects/ngx/declarative-ui/models/index.ts | 1 + .../declarative-ui/models/ui-definition.ts | 35 +++++-------------- .../ngx/declarative-ui/table/models/index.ts | 8 +++-- .../table/models/table-config.ts | 29 +++++++++++++-- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/projects/ngx/declarative-ui/models/index.ts b/projects/ngx/declarative-ui/models/index.ts index 6db0082..42852e9 100644 --- a/projects/ngx/declarative-ui/models/index.ts +++ b/projects/ngx/declarative-ui/models/index.ts @@ -1,2 +1,3 @@ export * from './resource'; export * from './ui-definition'; +export type { TableFieldDefinition, ResourceFieldButtonClickEvent } from '../table/models/table-config'; diff --git a/projects/ngx/declarative-ui/models/ui-definition.ts b/projects/ngx/declarative-ui/models/ui-definition.ts index f193377..57923a2 100644 --- a/projects/ngx/declarative-ui/models/ui-definition.ts +++ b/projects/ngx/declarative-ui/models/ui-definition.ts @@ -1,4 +1,3 @@ -import { GenericResource } from './resource'; /** Text transformation applied to a field value before display. */ export type TransformType = @@ -18,7 +17,14 @@ export interface PropertyField { /** Appearance settings for tag chip rendering. */ export interface TagSettings { - design?: 'Neutral' | 'Positive' | 'Critical' | 'Negative' | 'Information' | 'Set1' | 'Set2'; + design?: + | 'Neutral' + | 'Positive' + | 'Critical' + | 'Negative' + | 'Information' + | 'Set1' + | 'Set2'; colorScheme?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'; /** Delimiter used to split a plain-string value into individual tags. Default: `','`. */ valueSeparator?: string; @@ -120,16 +126,6 @@ export interface ValueRule { then: string; } -/** Event payload emitted when a button inside a table cell is clicked. */ -export interface ResourceFieldButtonClickEvent { - /** Original DOM click event. */ - event: MouseEvent; - /** The field definition of the button cell that was clicked. */ - field: TableFieldDefinition; - /** The data row associated with the clicked button. */ - resource: T | undefined; -} - /** Base field definition shared by table columns and form fields. */ export interface FieldDefinition { /** Column header / form label. */ @@ -145,18 +141,3 @@ export interface FieldDefinition { /** Display and interaction configuration for this cell. */ uiSettings?: UiSettings; } - -/** Table column definition — extends `FieldDefinition` with optional column grouping. */ -export interface TableFieldDefinition extends FieldDefinition { - /** Groups this column visually with adjacent columns that share the same `name`. */ - group?: { - /** Logical group identifier. */ - name: string; - /** Group header label shown above the grouped cells. */ - label?: string; - /** Separator placed between values in the same group cell. */ - delimiter?: string; - /** When `true`, each value is rendered on its own line. */ - multiline?: boolean; - }; -} diff --git a/projects/ngx/declarative-ui/table/models/index.ts b/projects/ngx/declarative-ui/table/models/index.ts index dd2d01d..8da895e 100644 --- a/projects/ngx/declarative-ui/table/models/index.ts +++ b/projects/ngx/declarative-ui/table/models/index.ts @@ -7,9 +7,11 @@ export type { ValueRule, RuleCondition, FieldDefinition, - TableFieldDefinition, - ResourceFieldButtonClickEvent, PropertyField, TransformType, } from '../../models/ui-definition'; -export type { TableConfig } from './table-config'; +export type { + TableConfig, + TableFieldDefinition, + ResourceFieldButtonClickEvent, +} from './table-config'; diff --git a/projects/ngx/declarative-ui/table/models/table-config.ts b/projects/ngx/declarative-ui/table/models/table-config.ts index a2b9997..72a58d5 100644 --- a/projects/ngx/declarative-ui/table/models/table-config.ts +++ b/projects/ngx/declarative-ui/table/models/table-config.ts @@ -1,6 +1,5 @@ -import { TableFieldDefinition } from '../../models/ui-definition'; - -export type { TableFieldDefinition } from '../../models/ui-definition'; +import { FieldDefinition } from '../../models/ui-definition'; +import { GenericResource } from '../../models/resource'; /** Configuration for the `mfp-declarative-table` component. */ export interface TableConfig { @@ -16,3 +15,27 @@ export interface TableConfig { growMode?: 'Scroll' | 'Button'; loadMoreButtonText?: string; } + +export interface TableFieldDefinition extends FieldDefinition { + /** Groups this column visually with adjacent columns that share the same `name`. */ + group?: { + /** Logical group identifier. */ + name: string; + /** Group header label shown above the grouped cells. */ + label?: string; + /** Separator placed between values in the same group cell. */ + delimiter?: string; + /** When `true`, each value is rendered on its own line. */ + multiline?: boolean; + }; +} + +/** Event payload emitted when a button inside a table cell is clicked. */ +export interface ResourceFieldButtonClickEvent { + /** Original DOM click event. */ + event: MouseEvent; + /** The field definition of the button cell that was clicked. */ + field: TableFieldDefinition; + /** The data row associated with the clicked button. */ + resource: T | undefined; +} From ad237aff7339eaa3188840e855f17a73d608d190 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 25 Jun 2026 17:07:27 +0200 Subject: [PATCH 06/13] Adjust the search scopes Signed-off-by: gkrajniak --- nodemon.json | 7 ++++ package.json | 5 ++- .../declarative-table-card.component.ts | 8 ++--- .../table-card/models/search-config.ts | 6 ++-- .../search/table-card-search.component.html | 4 +-- .../search/table-card-search.component.ts | 32 ++++++++----------- projects/ngx/declarative-ui/tsconfig.lib.json | 5 +++ projects/ngx/tsconfig.lib.json | 5 +++ 8 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 nodemon.json diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..961fcfe --- /dev/null +++ b/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["projects", "scripts"], + "ignore": ["dist", "public", ".angular", "node_modules", "documentation.json"], + "ext": "js,yml,yaml,ts,html,css,scss,json,md", + "delay": 1000, + "exec": "rimraf dist && npm run build:dev && npm run yalc:publish" +} diff --git a/package.json b/package.json index ab24c90..b72c651 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,12 @@ "type": "module", "scripts": { "build:ngx": "ng build ngx", + "build:ngx:dev": "ng build ngx --configuration=development", "build:wc": "ng build webcomponents && ng build webcomponents-dashboard && node scripts/bundle-wc.mjs", + "build:wc:dev": "ng build webcomponents --configuration=development && ng build webcomponents-dashboard --configuration=development && node scripts/bundle-wc.mjs", "build": "npm run build:ngx && npm run build:wc", - "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon --ignore dist --ignore public --ext js,yml,yaml,ts,html,css,scss,json,md --exec \"rimraf dist && npm run build && npm run yalc:publish\"", + "build:dev": "npm run build:ngx:dev && npm run build:wc:dev", + "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon", "yalc:publish": "yalc publish dist/ngx --push --sig && yalc publish dist/webcomponents --push --sig", "test": "ng test ngx --watch=false", "test:watch": "ng test ngx", diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts index c398602..e58414c 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts @@ -7,7 +7,7 @@ import { TableFieldDefinition, } from '../table/models'; import { getResourceValueByJsonPath } from '../table/utils/resource-field-by-path'; -import { TableCardConfig, TableCardFormState } from './models/configs'; +import { Scope, TableCardConfig, TableCardFormState } from './models/configs'; import { TableCardSearch } from './search/table-card-search.component'; import { CUSTOM_ELEMENTS_SCHEMA, @@ -58,17 +58,17 @@ export class DeclarativeTableCard { * Emitted after the user types into the search input, debounced by 300 ms. * Carries the current input value and the active scope (if any). */ - readonly searchChanged = output<{ value: string; scope?: string }>(); + readonly searchChanged = output(); /** * Emitted synchronously when the user submits the search (presses Enter or * clicks the search icon). Carries the submitted value and the active scope. */ - readonly searchSubmit = output<{ value: string; scope?: string }>(); + readonly searchSubmit = output(); /** * Emitted synchronously when the user selects a different scope in the * scopes dropdown. Carries the current in-flight search text and the new scope. */ - readonly scopeChanged = output<{ value: string; scope?: string }>(); + readonly scopeChanged = output(); readonly createFieldChange = output(); readonly editFieldChange = output<{ resource: T; diff --git a/projects/ngx/declarative-ui/table-card/models/search-config.ts b/projects/ngx/declarative-ui/table-card/models/search-config.ts index 238a88d..48a5019 100644 --- a/projects/ngx/declarative-ui/table-card/models/search-config.ts +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -3,7 +3,9 @@ export interface Scope { /** Visible label shown in the dropdown. */ label: string; /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ - value?: string; + value: string; + /** The name of the property the value refers to **/ + property: string; } /** Configuration for the `` element rendered in the table-card header. */ @@ -15,7 +17,7 @@ export interface TableCardSearchConfig { /** When `true`, the clear icon is shown inside the input. Default: `true`. */ showClearIcon?: boolean; /** Initial / controlled scope `value` (matches one of `scopes[].value`). */ - scopeValue?: string; + initialScopeValue?: Scope; /** Initial / controlled search text value. */ value?: string; /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index 987851f..deba48f 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -3,7 +3,7 @@ class="card__search" [accessibleName]="searchConfig().accessibleName" [placeholder]="searchConfig().placeholder" - [scopeValue]="activeScope()" + [scopeValue]="activeScope()?.property" [showClearIcon]="searchConfig().showClearIcon ?? true" [value]="searchControl.value ?? ''" (ui5Input)="onSearchInput($event)" @@ -11,6 +11,6 @@ (ui5Search)="onSearchSubmit($event)" > @for (s of searchConfig().scopes ?? []; track s.value) { - + } diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index b1e6622..6e606ed 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -1,4 +1,4 @@ -import { TableCardSearchConfig } from '../models/search-config'; +import { Scope, TableCardSearchConfig } from '../models/search-config'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -36,13 +36,13 @@ interface Ui5SearchEventTarget { export class TableCardSearch { searchConfig = input.required(); - readonly searchChanged = output<{ value: string; scope?: string }>(); - readonly searchSubmit = output<{ value: string; scope?: string }>(); - readonly scopeChanged = output<{ value: string; scope?: string }>(); + readonly searchChanged = output(); + readonly searchSubmit = output(); + readonly scopeChanged = output(); protected searchControl = new FormControl(''); protected searchInputRef = viewChild('searchInput'); - protected activeScope = signal(undefined); + protected activeScope = signal(undefined); private readonly injector = inject(Injector); @@ -50,15 +50,12 @@ export class TableCardSearch { this.searchControl.valueChanges .pipe(debounceTime(300), takeUntilDestroyed()) .subscribe((value) => { - this.searchChanged.emit({ - value: value ?? '', - scope: this.activeScope(), - }); + this.searchChanged.emit(value); }); effect(() => { const config = this.searchConfig(); - this.activeScope.set(config.scopeValue); + this.activeScope.set(config.initialScopeValue); const nextValue = config.value ?? ''; if (this.searchControl.value !== nextValue) { @@ -79,20 +76,17 @@ export class TableCardSearch { onSearchSubmit(event: Event): void { const target = event.target as Ui5SearchEventTarget | null; - this.searchSubmit.emit({ - value: target?.value ?? '', - scope: target?.scopeValue || undefined, - }); + this.searchSubmit.emit(target?.value ?? ''); } onSearchScopeChange(event: Event): void { const target = event.target as Ui5SearchEventTarget | null; - const scope = target?.scopeValue || undefined; + const scopeProperty = target?.scopeValue || undefined; + const scope = this.searchConfig().scopes?.find( + (e) => e.property === scopeProperty, + ); this.activeScope.set(scope); - this.scopeChanged.emit({ - value: this.searchControl.value ?? '', - scope, - }); + this.scopeChanged.emit(scope); } private fixSelectWidth(): void { diff --git a/projects/ngx/declarative-ui/tsconfig.lib.json b/projects/ngx/declarative-ui/tsconfig.lib.json index c973483..177d3d9 100644 --- a/projects/ngx/declarative-ui/tsconfig.lib.json +++ b/projects/ngx/declarative-ui/tsconfig.lib.json @@ -4,8 +4,13 @@ "outDir": "../../../out-tsc/lib", "declaration": true, "declarationMap": true, + "sourceMap": true, + "inlineSources": true, "types": [] }, + "angularCompilerOptions": { + "compilationMode": "partial" + }, "include": ["**/*.ts"], "exclude": ["**/*.spec.ts"] } diff --git a/projects/ngx/tsconfig.lib.json b/projects/ngx/tsconfig.lib.json index d6699b3..5be2ae8 100644 --- a/projects/ngx/tsconfig.lib.json +++ b/projects/ngx/tsconfig.lib.json @@ -4,8 +4,13 @@ "outDir": "../../out-tsc/lib", "declaration": true, "declarationMap": true, + "sourceMap": true, + "inlineSources": true, "types": [] }, + "angularCompilerOptions": { + "compilationMode": "partial" + }, "include": ["**/*.ts"], "exclude": ["**/*.spec.ts"] } From b935241764991792c184817aa31d1434adb010ad Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 25 Jun 2026 18:28:09 +0200 Subject: [PATCH 07/13] Add search filters Signed-off-by: gkrajniak --- .../search/table-card-search.component.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index 6e606ed..f62ab8f 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -66,6 +66,7 @@ export class TableCardSearch { // Workaround for ui5-select truncating long scope labels — see https://github.com/UI5/webcomponents/issues/13719 setTimeout(() => { this.fixSelectWidth(); + this.fixSearchIconSize(); }, 0); } @@ -109,4 +110,34 @@ export class TableCardSearch { label.style.textOverflow = 'clip'; } } + + /** + * Workaround for the ui5 SearchField icon collapsing in some host theme + * environments: the rule `.ui5-shell-search-field-icon::part(root){width:1rem;height:1rem}` + * lives inside ``'s own shadow root and is occasionally stripped + * by the consumer's theming layer, leaving the icon mispositioned. We append + * the missing width/height back into the same shadow root so the icon + * renders identically to Storybook regardless of the host environment. + */ + private fixSearchIconSize(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | HTMLElement + | undefined; + const shadow = nativeEl?.shadowRoot; + if (!shadow) return; + + // Idempotent — only inject once per component instance. + const STYLE_ID = 'mfp-search-icon-size-fix'; + if (shadow.getElementById(STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .ui5-shell-search-field-icon::part(root) { + width: 1rem; + height: 1rem; + } + `; + shadow.appendChild(style); + } } From 9e649fd9d8f0a760a09f955ea2593d7bb4e1862c Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 25 Jun 2026 18:48:16 +0200 Subject: [PATCH 08/13] Add search filters Signed-off-by: gkrajniak --- .../search/table-card-search.component.html | 1 - .../search/table-card-search.component.scss | 5 ----- .../search/table-card-search.component.ts | 19 +++++++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index deba48f..31e8881 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -1,6 +1,5 @@ { this.fixSelectWidth(); this.fixSearchIconSize(); + this.fixSearchWidth(); }, 0); } @@ -140,4 +141,22 @@ export class TableCardSearch { `; shadow.appendChild(style); } + + /** + * Workaround for the ui5 SearchField shadow CSS pinning its host to + * `min-width: 18rem; max-width: 36rem`. In dense toolbar layouts that + * lets the search render almost twice the width it should. We replace + * those rules with inline styles on the host so layout behavior lives + * in one place (this method). + */ + private fixSearchWidth(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | HTMLElement + | undefined; + if (!nativeEl) return; + nativeEl.style.flex = '1'; + nativeEl.style.minWidth = '150px'; + nativeEl.style.maxWidth = 'calc(20rem + 50px)'; + nativeEl.style.width = '100%'; + } } From bb8e6133de29a02c3f2ba6219e5581c3944a66b9 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 25 Jun 2026 18:57:47 +0200 Subject: [PATCH 09/13] Add search filters Signed-off-by: gkrajniak --- .../search/table-card-search.component.html | 2 +- .../search/table-card-search.component.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index 31e8881..c851b1c 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -4,7 +4,7 @@ [placeholder]="searchConfig().placeholder" [scopeValue]="activeScope()?.property" [showClearIcon]="searchConfig().showClearIcon ?? true" - [value]="searchControl.value ?? ''" + [value]="externalValue()" (ui5Input)="onSearchInput($event)" (ui5ScopeChange)="onSearchScopeChange($event)" (ui5Search)="onSearchSubmit($event)" diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index 0acd755..484d109 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -43,6 +43,15 @@ export class TableCardSearch { protected searchControl = new FormControl(''); protected searchInputRef = viewChild('searchInput'); protected activeScope = signal(undefined); + /** + * Signal mirror of `searchConfig().value` used as the source of truth for + * `[value]` on ``. Decoupling the template binding from + * `searchControl.value` prevents the bound property from snapping the + * input back to a stale value during user interactions like clicking the + * clear icon — ui5 owns the live displayed value, Angular only writes it + * when the parent's config changes. + */ + protected externalValue = signal(''); private readonly injector = inject(Injector); @@ -60,6 +69,7 @@ export class TableCardSearch { const nextValue = config.value ?? ''; if (this.searchControl.value !== nextValue) { this.searchControl.setValue(nextValue); + this.externalValue.set(nextValue); } }); @@ -68,6 +78,7 @@ export class TableCardSearch { this.fixSelectWidth(); this.fixSearchIconSize(); this.fixSearchWidth(); + this.bindNativeInputListener(); }, 0); } @@ -159,4 +170,36 @@ export class TableCardSearch { nativeEl.style.maxWidth = 'calc(20rem + 50px)'; nativeEl.style.width = '100%'; } + + /** + * Some ngx wrapper / shadow-DOM event-bubbling edge-cases cause the + * Angular `(ui5Input)` binding to miss the synthetic `input` event that + * `` fires when its clear icon is clicked. Bind a native + * listener directly on the host element as an immediate, unconditional + * path so the clear button always resets the search. + * + * The event fires for BOTH user typing and clear-icon clicks; reading + * `target.value` (which ui5 sets to `""` before firing for clear) tells + * us which case we're in. Typing is already handled by `(ui5Input)`'s + * debounced flow — we only need to act here when the value is empty so + * the clear is instant (no 300ms wait). + */ + private bindNativeInputListener(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | (HTMLElement & { value?: string }) + | undefined; + if (!nativeEl) return; + + nativeEl.addEventListener('input', () => { + const next = nativeEl.value ?? ''; + if (this.searchControl.value !== next) { + this.searchControl.setValue(next); + } + // Fire the parent notification immediately on clear (skipping the + // 300ms debounce that exists for typed input). + if (next === '') { + this.searchChanged.emit(''); + } + }); + } } From 8c6c34a8a945db54e8bb2fe69c6ca574b9e0e596 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 25 Jun 2026 19:07:02 +0200 Subject: [PATCH 10/13] Add search filters Signed-off-by: gkrajniak --- .../stories/declarative-table-card.stories.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts index 7541325..c42a1d8 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -175,7 +175,7 @@ class DeclarativeTableCardCreateStory implements OnInit { ngOnInit(): void { this.searchTerm = this.config?.searchConfig?.value ?? ''; - this.activeScope = this.config?.searchConfig?.scopeValue; + this.activeScope = this.config?.searchConfig?.initialScopeValue?.value; } get filteredResources(): GenericResource[] { @@ -554,11 +554,15 @@ export const WithSearchAndScopes: SearchStory = { placeholder: 'Search pods…', value: 'api', scopes: [ - { label: 'All namespaces', value: 'all' }, - { label: 'default', value: 'default' }, - { label: 'kube-system', value: 'kube-system' }, + { label: 'All namespaces', value: 'all', property: 'namespace' }, + { label: 'default', value: 'default', property: 'namespace' }, + { label: 'kube-system', value: 'kube-system', property: 'namespace' }, ], - scopeValue: 'all', + initialScopeValue: { + label: 'All namespaces', + value: 'all', + property: 'namespace', + }, } satisfies TableCardSearchConfig, }, resources: PODS, From 8a6baafe97aa426987167ebd2ff45771e0f1f9b9 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Fri, 26 Jun 2026 12:14:24 +0200 Subject: [PATCH 11/13] Add search filters Signed-off-by: gkrajniak --- .../stories/declarative-table-card.stories.ts | 22 ++++++++++++++++--- .../table-card/models/search-config.ts | 2 ++ .../search/table-card-search.component.html | 6 ++--- .../search/table-card-search.component.ts | 6 ++--- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts index c42a1d8..37ce424 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -554,11 +554,27 @@ export const WithSearchAndScopes: SearchStory = { placeholder: 'Search pods…', value: 'api', scopes: [ - { label: 'All namespaces', value: 'all', property: 'namespace' }, - { label: 'default', value: 'default', property: 'namespace' }, - { label: 'kube-system', value: 'kube-system', property: 'namespace' }, + { + id: 'all', + label: 'All namespaces', + value: 'all', + property: 'namespace', + }, + { + id: 'd', + label: 'default', + value: 'default', + property: 'namespace', + }, + { + id: 'ks', + label: 'kube-system', + value: 'kube-system', + property: 'namespace', + }, ], initialScopeValue: { + id: 'all', label: 'All namespaces', value: 'all', property: 'namespace', diff --git a/projects/ngx/declarative-ui/table-card/models/search-config.ts b/projects/ngx/declarative-ui/table-card/models/search-config.ts index 48a5019..1875445 100644 --- a/projects/ngx/declarative-ui/table-card/models/search-config.ts +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -1,5 +1,7 @@ /** One option in the `` scopes dropdown. */ export interface Scope { + /** Id of the scope. */ + id: string; /** Visible label shown in the dropdown. */ label: string; /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html index c851b1c..1085bf7 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -2,14 +2,14 @@ #searchInput [accessibleName]="searchConfig().accessibleName" [placeholder]="searchConfig().placeholder" - [scopeValue]="activeScope()?.property" + [scopeValue]="activeScope()?.id" [showClearIcon]="searchConfig().showClearIcon ?? true" [value]="externalValue()" (ui5Input)="onSearchInput($event)" (ui5ScopeChange)="onSearchScopeChange($event)" (ui5Search)="onSearchSubmit($event)" > - @for (s of searchConfig().scopes ?? []; track s.value) { - + @for (s of searchConfig().scopes ?? []; track s.id) { + } diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts index 484d109..20c3b1b 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -94,10 +94,8 @@ export class TableCardSearch { onSearchScopeChange(event: Event): void { const target = event.target as Ui5SearchEventTarget | null; - const scopeProperty = target?.scopeValue || undefined; - const scope = this.searchConfig().scopes?.find( - (e) => e.property === scopeProperty, - ); + const scopeId = target?.scopeValue || undefined; + const scope = this.searchConfig().scopes?.find((e) => e.id === scopeId); this.activeScope.set(scope); this.scopeChanged.emit(scope); } From e2674fdadde23b6c9309d65d883c409398449ebd Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Fri, 26 Jun 2026 12:28:29 +0200 Subject: [PATCH 12/13] Add search filters Signed-off-by: gkrajniak --- docs/declarative-table-card.md | 126 ++++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/docs/declarative-table-card.md b/docs/declarative-table-card.md index 1c2afdf..8c64616 100644 --- a/docs/declarative-table-card.md +++ b/docs/declarative-table-card.md @@ -90,9 +90,9 @@ import { (createSubmit)="onCreateSubmit($event, tableCard)" (editSubmit)="onEditSubmit($event, tableCard)" (deleteSubmit)="onDeleteSubmit($event, tableCard)" - (searchChanged)="onSearch($event)" - (searchSubmit)="onSearch($event)" - (scopeChanged)="onSearch($event)" + (searchChanged)="onSearchChanged($event)" + (searchSubmit)="onSearchSubmit($event)" + (scopeChanged)="onScopeChanged($event)" /> `, }) @@ -167,9 +167,21 @@ export class MyComponent { tableCard.closeDeleteDialog(); } - onSearch({ value, scope }: { value: string; scope?: string }): void { - // Re-fetch / filter `pods` based on the current search text and scope. - this.reloadPods({ query: value, scope }); + onSearchChanged(value: string | null): void { + // Debounced (~300 ms) — fires while the user types and on clear-icon click. + // The currently active scope is tracked separately via `scopeChanged`. + this.reloadPods({ query: value ?? '' }); + } + + onSearchSubmit(value: string | null): void { + // Synchronous — fired on Enter or the search icon. Useful for forcing + // an immediate refresh that bypasses the debounce. + this.reloadPods({ query: value ?? '' }); + } + + onScopeChanged(scope: Scope | undefined): void { + // Synchronous — re-fetch using the new scope. + this.reloadPods({ scope }); } } ``` @@ -196,9 +208,9 @@ export class MyComponent { | `createSubmit` | `Record` | Fires when the create dialog Save button is clicked | | `editSubmit` | `{ resource: T; value: Record }` | Fires when the edit dialog Save button is clicked | | `deleteSubmit` | `T` | Fires when the delete dialog Delete button is clicked | -| `searchChanged` | `{ value: string; scope?: string }` | Emits 300 ms after the search input changes; `scope` reflects the currently active scope (if any) | -| `searchSubmit` | `{ value: string; scope?: string }` | Emits synchronously when the user submits the search (Enter or search icon) | -| `scopeChanged` | `{ value: string; scope?: string }` | Emits synchronously when the user picks a different scope from the dropdown; `value` is the current in-flight search text | +| `searchChanged` | `string \| null` | Emits ~300 ms after the search input changes (typing or clear icon click). The empty string is emitted immediately on clear. | +| `searchSubmit` | `string \| null` | Emits synchronously when the user submits the search (Enter or search icon) | +| `scopeChanged` | `Scope \| undefined` | Emits synchronously when the user picks a different scope from the dropdown. The full scope object is forwarded; `undefined` means "no scope selected". | | `tableRowClicked` | `T` | Emits when a table row is clicked | | `loadMoreResources` | - | Emits when the user triggers load more | | `paginationLimitChanged` | `number` | Emits when the user changes page size | @@ -232,10 +244,14 @@ interface TableCardConfig { /** One option in the `` scopes dropdown. */ interface Scope { + /** Stable identifier used to match `initialScopeValue` and as ``. */ + id: string; /** Visible label shown in the dropdown. */ label: string; - /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ - value?: string; + /** Logical value forwarded to the host (e.g. for filtering, URL query string). */ + value: string; + /** Name of the property this scope filters by — used by the host to build the filter expression. */ + property: string; } /** Configuration for the `` element rendered in the table-card header. */ @@ -246,15 +262,12 @@ interface TableCardSearchConfig { placeholder?: string; /** When `true`, the clear icon is shown inside the input. Default: `true`. */ showClearIcon?: boolean; - /** Initial / controlled scope `value` (matches one of `scopes[].value`). */ - scopeValue?: string; + /** Initial / controlled scope — must be one of the entries in `scopes`. Matched against `scopes[].id`. */ + initialScopeValue?: Scope; /** Initial / controlled search text value. */ value?: string; /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ scopes?: Scope[]; - /** When `true`, `` is always visible in the toolbar. - * When `false` (default), the search is hidden behind a search-toggle icon button; clicking it expands the search and clicking it again (or losing focus on an empty input) collapses the search. Collapse preserves the entered text and active scope — re-expanding restores the in-flight query. Use the built-in clear icon (`showClearIcon`) to clear the value. */ - alwaysOnDisplay?: boolean; } interface TableConfig { @@ -287,18 +300,17 @@ interface TableCardFormState { ## Search & Scopes -When `searchConfig` is set on `TableCardConfig`, the card renders a [``](https://ui5.github.io/webcomponents/components/fiori/Search/) element in the toolbar. Omit `searchConfig` to hide the search entirely. The previous `resourcesSearchable` boolean has been removed. +When `searchConfig` is set on `TableCardConfig`, the card renders a [``](https://ui5.github.io/webcomponents/components/fiori/Search/) element inline in the toolbar. Omit `searchConfig` to hide the search entirely. -### Visibility (`alwaysOnDisplay`) +### Clearing the input -| `alwaysOnDisplay` | Toolbar UX | -| ----------------- | ---------- | -| `true` | `` is rendered inline at all times. No toggle button is shown. | -| `false` (default) | The search is hidden behind a search-toggle icon button. Clicking the button expands the input; clicking it again — or blurring an empty input — collapses it. `buttonSettings.searchButton` overrides the toggle button's icon, text, and design. | +`showClearIcon` defaults to `true` — the user can click the inline X icon to empty the input. Clearing emits `searchChanged` with an empty string **immediately** (bypassing the typing debounce), so the host can refetch / un-filter without waiting. -### Collapse preserves state +### Scopes -Collapsing the search (toggle button or blur-on-empty) does **not** clear the entered text or the active scope. Re-expanding the search restores the same in-flight query. To clear the value the user clicks the built-in clear icon inside `` (`showClearIcon` defaults to `true`), which fires `searchChanged` with an empty `value` through the normal 300 ms debounce. +`scopes` is an array of `Scope` objects shown in the dropdown next to the search input. Each scope is matched by its `id`; `initialScopeValue` (a full `Scope`) selects the initially-active scope. Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. + +The `value` and `property` fields on `Scope` are passed through verbatim in `scopeChanged` — the host decides how to use them (e.g. as `?=` in the URL, as an OpenSearch `filter==` parameter, etc.). ### Event contract @@ -306,15 +318,18 @@ The host owns data fetching and filtering. The card forwards user actions verbat | Event | When | Payload | | --------------- | ---- | ------- | -| `searchChanged` | 300 ms after the input value changes (typing or clear icon) | `{ value, scope }` where `scope` is the currently active scope | -| `searchSubmit` | User presses Enter or clicks the search icon (synchronous) | `{ value, scope }` | -| `scopeChanged` | User picks a different scope from the dropdown (synchronous) | `{ value, scope }` where `value` is the current in-flight search text | +| `searchChanged` | ~300 ms after the input value changes while typing; **immediately** when the clear icon is clicked | `string \| null` — the current input value | +| `searchSubmit` | User presses Enter or clicks the search icon (synchronous) | `string \| null` — the current input value | +| `scopeChanged` | User picks a different scope from the dropdown (synchronous) | `Scope \| undefined` — the full scope object, or `undefined` when no scope is active | + +The search text and the active scope are emitted on separate events. The host is responsible for keeping the most recent value of each and combining them when issuing the next request. ### Example — "My Contributions" / "All" scopes ```ts import { DeclarativeTableCard, + Scope, TableCardConfig, } from '@openmfp/webcomponents'; @@ -333,6 +348,22 @@ import { export class MyComponent { pods: Pod[] = []; + private currentQuery = ''; + private currentScope: Scope | undefined; + + private readonly ALL_SCOPE: Scope = { + id: 'all', + label: 'All', + value: '*', + property: 'owner', + }; + private readonly MINE_SCOPE: Scope = { + id: 'mine', + label: 'My Contributions', + value: 'me', + property: 'owner', + }; + config: TableCardConfig = { header: 'Pods', tableConfig: { @@ -344,33 +375,44 @@ export class MyComponent { searchConfig: { placeholder: 'Search pods…', accessibleName: 'Search pods', - scopeValue: 'all', - scopes: [ - { label: 'All', value: 'all' }, - { label: 'My Contributions', value: 'mine' }, - ], + scopes: [this.ALL_SCOPE, this.MINE_SCOPE], + initialScopeValue: this.ALL_SCOPE, }, }; - onSearchChanged({ value, scope }: { value: string; scope?: string }): void { - // Debounced — call your list/search endpoint here. - this.reloadPods({ query: value, scope }); + onSearchChanged(value: string | null): void { + // Debounced while typing; instant on clear. Combine with the current scope + // when reloading. + this.currentQuery = value ?? ''; + this.reload(); } - onSearchSubmit({ value, scope }: { value: string; scope?: string }): void { + onSearchSubmit(value: string | null): void { // Synchronous — fired on Enter or the search icon. Useful for forcing - // an immediate refresh that bypasses the 300 ms debounce. - this.reloadPods({ query: value, scope }); + // an immediate refresh that bypasses the typing debounce. + this.currentQuery = value ?? ''; + this.reload(); + } + + onScopeChanged(scope: Scope | undefined): void { + // Synchronous — re-fetch with the current in-flight search text and the + // newly-selected scope. + this.currentScope = scope; + this.reload(); } - onScopeChanged({ value, scope }: { value: string; scope?: string }): void { - // Synchronous — re-fetch using the new scope and the current in-flight text. - this.reloadPods({ query: value, scope }); + private reload(): void { + this.reloadPods({ + query: this.currentQuery, + filter: this.currentScope + ? `${this.currentScope.property}=${this.currentScope.value}` + : undefined, + }); } } ``` -Set `alwaysOnDisplay: true` on `searchConfig` to skip the toggle UX and render `` inline. Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. +Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. --- From c4d6f8890c9a5bcfc70ddb68a9b44c30d42df082 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Fri, 26 Jun 2026 12:49:15 +0200 Subject: [PATCH 13/13] fix tests Signed-off-by: gkrajniak --- .../table-card-search.component.spec.ts | 128 ++++++++++-------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts index ef02ee4..5a82361 100644 --- a/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -1,11 +1,24 @@ import { TableCardSearch } from './table-card-search.component'; -import { TableCardSearchConfig } from '../models/search-config'; +import { Scope, TableCardSearchConfig } from '../models/search-config'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; type Comp = TableCardSearch; type Fixture = ComponentFixture; +const ALL_SCOPE: Scope = { + id: 'all', + label: 'All', + value: '*', + property: 'owner', +}; +const MINE_SCOPE: Scope = { + id: 'mine', + label: 'My Contributions', + value: 'me', + property: 'owner', +}; + function root(fixture: Fixture): ShadowRoot | HTMLElement { return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; } @@ -86,10 +99,7 @@ describe('TableCardSearch', () => { it('renders one ui5-search-scope per scopes entry', () => { const { fixture } = setup({ searchConfig: { - scopes: [ - { label: 'All', value: 'all' }, - { label: 'My Contributions', value: 'mine' }, - ], + scopes: [ALL_SCOPE, MINE_SCOPE], }, }); expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(2); @@ -100,15 +110,17 @@ describe('TableCardSearch', () => { expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); }); - it('sets text and value on each ui5-search-scope', () => { + it('sets text from label and value from id on each ui5-search-scope', () => { const { fixture } = setup({ - searchConfig: { scopes: [{ label: 'All', value: 'all' }] }, + searchConfig: { scopes: [ALL_SCOPE] }, }); const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { text?: string; value?: string; }; expect(scope?.text).toBe('All'); + // ui5-search-scope's `value` is the scope `id`, used by ui5 to match the + // active scopeValue. The Scope.value field is forwarded separately on events. expect(scope?.value).toBe('all'); }); }); @@ -118,25 +130,24 @@ describe('TableCardSearch', () => { // ------------------------------------------------------------------------- describe('searchChanged output', () => { - it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { + it('emits searchChanged with the current value after 300ms debounce on ui5Input', () => { const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (string | null)[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); expect(emitted).toHaveLength(0); vi.advanceTimersByTime(300); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: 'alpha', scope: undefined }); + expect(emitted).toEqual(['alpha']); }); it('does not emit searchChanged before the 300ms debounce elapses', () => { const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); - const emitted: unknown[] = []; + const emitted: (string | null)[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); component.onSearchInput(fakeSearchEvent({ value: 'beta' })); @@ -144,27 +155,6 @@ describe('TableCardSearch', () => { expect(emitted).toHaveLength(0); }); - it('includes active scope in searchChanged payload', () => { - const { fixture, component } = setup({ - searchConfig: { - scopes: [ - { label: 'All', value: 'all' }, - { label: 'Mine', value: 'mine' }, - ], - }, - }); - fixture.detectChanges(); - - const emitted: { value: string; scope?: string }[] = []; - component.searchChanged.subscribe((e) => emitted.push(e)); - - component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - component.onSearchInput(fakeSearchEvent({ value: 'pod' })); - vi.advanceTimersByTime(300); - - expect(emitted[0]).toEqual({ value: 'pod', scope: 'mine' }); - }); - it('emits searchChanged with empty value after simulated clear', () => { const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); @@ -172,13 +162,13 @@ describe('TableCardSearch', () => { component.onSearchInput(fakeSearchEvent({ value: 'foo' })); vi.advanceTimersByTime(300); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (string | null)[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); component.onSearchInput(fakeSearchEvent({ value: '' })); vi.advanceTimersByTime(300); - expect(emitted[0]).toEqual({ value: '', scope: undefined }); + expect(emitted).toEqual(['']); }); }); @@ -191,22 +181,27 @@ describe('TableCardSearch', () => { const { fixture, component } = setup({ searchConfig: {} }); fixture.detectChanges(); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (string | null)[] = []; component.searchSubmit.subscribe((e) => emitted.push(e)); component.onSearchSubmit(fakeSearchEvent({ value: 'my-pod' })); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: 'my-pod', scope: undefined }); + expect(emitted).toEqual(['my-pod']); }); - it('includes scope in searchSubmit when a scope is active', () => { - const { component } = setup({ searchConfig: {} }); + it('forwards the current input value verbatim even when a scope is active', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE] }, + }); + + // Activate a scope first. + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'all' })); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (string | null)[] = []; component.searchSubmit.subscribe((e) => emitted.push(e)); + // The scope is NOT bundled in the searchSubmit payload (decoupled events). component.onSearchSubmit(fakeSearchEvent({ value: 'redis', scopeValue: 'all' })); - expect(emitted[0]).toEqual({ value: 'redis', scope: 'all' }); + expect(emitted).toEqual(['redis']); }); }); @@ -215,41 +210,58 @@ describe('TableCardSearch', () => { // ------------------------------------------------------------------------- describe('scopeChanged output', () => { - it('emits scopeChanged synchronously on ui5ScopeChange event', () => { - const { component } = setup({ searchConfig: {} }); + it('emits the matching Scope object synchronously on ui5ScopeChange event', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (Scope | undefined)[] = []; component.scopeChanged.subscribe((e) => emitted.push(e)); component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toEqual({ value: '', scope: 'mine' }); + expect(emitted).toEqual([MINE_SCOPE]); }); - it('includes in-flight search text in scopeChanged payload', () => { - const { component } = setup({ searchConfig: {} }); + it('emits undefined when the event carries no matching scope id', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); + + const emitted: (Scope | undefined)[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'nonexistent' })); + expect(emitted).toEqual([undefined]); + }); - component.onSearchInput(fakeSearchEvent({ value: 'cache' })); + it('emits undefined when the event omits a scopeValue', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (Scope | undefined)[] = []; component.scopeChanged.subscribe((e) => emitted.push(e)); - component.onSearchScopeChange(fakeSearchEvent({ value: 'cache', scopeValue: 'all' })); - expect(emitted[0]).toEqual({ value: 'cache', scope: 'all' }); + component.onSearchScopeChange(fakeSearchEvent({ value: '' })); + expect(emitted).toEqual([undefined]); }); - it('updates activeScope so subsequent searchChanged carries new scope', () => { - const { component } = setup({ searchConfig: {} }); + it('subsequent searchChanged is independent of scope (events are decoupled)', () => { + const { component } = setup({ + searchConfig: { scopes: [MINE_SCOPE] }, + }); component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); - const emitted: { value: string; scope?: string }[] = []; + const emitted: (string | null)[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); component.onSearchInput(fakeSearchEvent({ value: 'pod' })); vi.advanceTimersByTime(300); - expect(emitted[0]?.scope).toBe('mine'); + // searchChanged carries only the search text; the host correlates scope + // and search text from their separate streams. + expect(emitted).toEqual(['pod']); }); }); @@ -276,7 +288,7 @@ describe('TableCardSearch', () => { const { fixture, component } = setup({ searchConfig: {} }); vi.advanceTimersByTime(300); // flush any pending init emission - const emitted: unknown[] = []; + const emitted: (string | null)[] = []; component.searchChanged.subscribe((e) => emitted.push(e)); fixture.componentRef.setInput('searchConfig', { value: 'same' });