From efe7e3119e61a756369e3403ea3fc17fd33a3022 Mon Sep 17 00:00:00 2001 From: Sobyt483 Date: Tue, 23 Jun 2026 15:16:55 +0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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; +}