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/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/stories/declarative-table-card.stories.ts b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts index 658f1d4..7541325 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -6,10 +6,11 @@ import type { ResourceFormConfig, TableCardConfig, TableCardFormState, + TableCardSearchConfig, 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'; @@ -140,7 +141,6 @@ const BASE_TABLE_CONFIG: TableConfig = { const BASE_CONFIG: TableCardConfig = { header: 'Pods', - resourcesSearchable: true, tableConfig: BASE_TABLE_CONFIG, }; @@ -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, @@ -472,3 +519,48 @@ export const WithPagination: Story = { }, }, }; + +type SearchStory = StoryObj; + +/** + * 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 = { + args: { + config: { + ...BASE_CONFIG, + searchConfig: { + placeholder: 'Search pods…', + value: 'server', + } satisfies TableCardSearchConfig, + }, + resources: PODS, + }, +}; + +/** + * 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 = { + args: { + config: { + ...BASE_CONFIG, + searchConfig: { + placeholder: 'Search pods…', + value: 'api', + scopes: [ + { label: 'All namespaces', value: 'all' }, + { label: 'default', value: 'default' }, + { label: 'kube-system', value: 'kube-system' }, + ], + 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..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 @@ -14,26 +14,12 @@ }
- @if (config().resourcesSearchable) { - @if (searchExpanded()) { - - } - } @if (createFormConfig()) { diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.scss b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.scss index 9c9427b..9833d93 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.scss +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.scss @@ -2,28 +2,6 @@ display: block; } -@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 { display: flex; flex-direction: column; @@ -35,9 +13,12 @@ display: flex; align-items: center; justify-content: space-between; - min-height: 3rem; - padding: 0 1rem; + flex-wrap: wrap; + min-height: 2rem; + padding: 0.5rem 1rem; border-bottom: 1px solid var(--sapGroup_ContentBorderColor, #d9d9d9); + gap: 0.5rem; + overflow: hidden; } &__title { @@ -50,12 +31,18 @@ line-height: normal; display: flex; align-items: center; + flex: 1 0 auto; + min-width: 0; + overflow: hidden; } &__actions { display: flex; align-items: center; gap: 0.5rem; + flex: 1 1 300px; + min-width: 0; + justify-content: flex-end; } &__info-icon { @@ -63,28 +50,11 @@ margin-left: 0.5rem; } - &__search-input { - transform-origin: right center; - - &--enter { - animation: slide-in 0.2s ease-out both; - } - - &--leave { - animation: slide-out 0.2s ease-in both; - } - } - &__create-btn { min-width: auto; color: var(--sapButton_IconColor, #0070f2); } - &__search-btn { - min-width: auto; - color: var(--sapButton_IconColor, #0070f2); - } - &__body { flex: 1; overflow: auto; diff --git a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.spec.ts b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.spec.ts index 2255faa..2a0b627 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.spec.ts +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.spec.ts @@ -11,6 +11,7 @@ import { ResourceFormConfig, TableCardConfig, TableCardFormState, + TableCardSearchConfig, TableConfig, } from './models/configs'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; @@ -103,6 +104,7 @@ function setup( deleteConfig?: TableCardDeleteConfig; createFormState?: TableCardFormState; editFormState?: TableCardFormState; + searchConfig?: TableCardSearchConfig; } = {}, ): { fixture: Fixture; component: Comp } { const fixture: Fixture = TestBed.createComponent( @@ -121,6 +123,7 @@ function setup( editButton: opts.editConfig?.editButtonSettings, deleteButton: opts.deleteConfig?.deleteButtonSettings, }, + searchConfig: opts.searchConfig, }; fixture.componentRef.setInput('config', config); @@ -132,12 +135,18 @@ function setup( return { fixture, component }; } +/** Return the component's shadow root or host element for querying. */ +function root(fixture: Fixture): ShadowRoot | HTMLElement { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('DeclarativeTableCard', () => { beforeEach(async () => { + vi.useFakeTimers(); await TestBed.configureTestingModule({ imports: [ DeclarativeTableCard as unknown as typeof DeclarativeTableCard, @@ -146,6 +155,10 @@ describe('DeclarativeTableCard', () => { }).compileComponents(); }); + afterEach(() => { + vi.useRealTimers(); + }); + // ------------------------------------------------------------------------- // 1. Component creation // ------------------------------------------------------------------------- @@ -162,9 +175,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 +186,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,90 +203,33 @@ 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. searchConfig — renders mfp-table-card-search // ------------------------------------------------------------------------- - describe('search', () => { - 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 - // 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', () => { - const { component } = setup(); - component.toggleSearch(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (component as any).searchControl.setValue(''); - component.onSearchBlur(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchCollapsing()).toBe(true); - }); - - it('onSearchBlur() does not collapse when value is non-empty', () => { - const { component } = setup(); - component.toggleSearch(); - // 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); - }); - - 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.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); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((component as any).searchControl.value).toBe(''); + describe('searchConfig rendering', () => { + it('does not render mfp-table-card-search when searchConfig is absent', () => { + const { fixture } = setup(); + expect(root(fixture).querySelector('mfp-table-card-search')).toBeNull(); }); - 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); + it('renders mfp-table-card-search when searchConfig is provided', () => { + const { fixture } = setup({ + searchConfig: { placeholder: 'Search pods…' }, + }); + fixture.detectChanges(); + expect(root(fixture).querySelector('mfp-table-card-search')).not.toBeNull(); }); }); @@ -288,21 +240,17 @@ describe('DeclarativeTableCard', () => { 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 +350,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 8. editInitialValue() computed + // 15. editInitialValue() computed // ------------------------------------------------------------------------- describe('editInitialValue()', () => { @@ -432,7 +380,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 9. onButtonClick() + // 16. onButtonClick() // ------------------------------------------------------------------------- describe('onButtonClick()', () => { @@ -504,7 +452,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 10. form state and submit flow + // 17. form state and submit flow // ------------------------------------------------------------------------- describe('form state and submit flow', () => { @@ -614,7 +562,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 11. close methods + // 18. close methods // ------------------------------------------------------------------------- describe('close methods', () => { @@ -653,7 +601,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 12. runtime form state + // 19. runtime form state // ------------------------------------------------------------------------- describe('runtime form state', () => { @@ -668,9 +616,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 +632,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 +650,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 +666,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 +675,7 @@ describe('DeclarativeTableCard', () => { }); // ------------------------------------------------------------------------- - // 14. Pass-through outputs + // 20. Pass-through outputs // ------------------------------------------------------------------------- describe('pass-through outputs', () => { @@ -759,10 +699,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 +735,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..c398602 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,49 +3,38 @@ 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'; +import { TableCardSearch } from './search/table-card-search.component'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, - Injector, ViewEncapsulation, - afterNextRender, computed, - inject, input, output, signal, - viewChild, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; 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'; -import { debounceTime } from 'rxjs'; - -type SearchState = 'collapsed' | 'expanded' | 'collapsing'; @Component({ selector: 'mfp-declarative-table-card', imports: [ DeclarativeTable, DeclarativeForm, - ReactiveFormsModule, Dialog, Title, Button, Icon, - Input, + TableCardSearch, ], templateUrl: './declarative-table-card.component.html', styleUrl: './declarative-table-card.component.scss', @@ -65,7 +54,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; @@ -78,14 +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 createDialogOpen = signal(false); protected editDialogOpen = signal(false); protected deleteDialogOpen = signal(false); @@ -95,6 +90,7 @@ 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 createFormConfig = computed( () => this.config().createResourceFormConfig, ); @@ -107,9 +103,6 @@ export class DeclarativeTableCard { 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(); @@ -121,47 +114,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 ?? ''); - }); - } - - toggleSearch(): void { - if (this.searchState() === 'expanded') { - this.collapseSearch(); - } else if (this.searchState() === 'collapsed') { - this.searchState.set('expanded'); - afterNextRender( - () => { - this.searchInputRef()?.elementRef.nativeElement.focus(); - }, - { injector: this.injector }, - ); - } - } - - onSearchBlur(): void { - if (!this.searchControl.value) { - this.collapseSearch(); - } - } - - onSearchAnimationEnd(): void { - if (this.searchCollapsing()) { - this.searchState.set('collapsed'); - this.searchControl.setValue('', { emitEvent: false }); - } - } - - 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/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..8d13ef9 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,27 +36,10 @@ 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. */ 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. */ @@ -69,8 +56,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..238a88d --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -0,0 +1,23 @@ +/** 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[]; +} 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..987851f --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -0,0 +1,16 @@ + + @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..98bb51d --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss @@ -0,0 +1,8 @@ +:host { + display: contents; +} + +.card__search { + 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 new file mode 100644 index 0000000..ef02ee4 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -0,0 +1,293 @@ +import { TableCardSearch } from './table-card-search.component'; +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; +} = {}): { fixture: Fixture; component: Comp } { + const fixture = TestBed.createComponent(TableCardSearch); + const component = fixture.componentInstance; + + fixture.componentRef.setInput('searchConfig', opts.searchConfig ?? {}); + + 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. Rendering + // ------------------------------------------------------------------------- + + describe('rendering', () => { + it('always renders ui5-search when searchConfig is provided', () => { + const { fixture } = setup({ searchConfig: { placeholder: 'Search pods…' } }); + expect(root(fixture).querySelector('ui5-search')).not.toBeNull(); + }); + + 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…' } }); + 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' } }); + 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: {} }); + 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: { 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: { + 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: { scopes: [] } }); + expect(root(fixture).querySelectorAll('ui5-search-scope')).toHaveLength(0); + }); + + it('sets text and value on each ui5-search-scope', () => { + const { fixture } = setup({ + searchConfig: { 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'); + }); + }); + + // ------------------------------------------------------------------------- + // 2. searchChanged output (debounced) + // ------------------------------------------------------------------------- + + describe('searchChanged output', () => { + it('emits searchChanged with { value } after 300ms debounce on ui5Input', () => { + const { fixture, component } = setup({ searchConfig: {} }); + 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: {} }); + 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: { + scopes: [ + { label: 'All', value: 'all' }, + { label: 'Mine', value: 'mine' }, + ], + }, + }); + fixture.detectChanges(); + + const emitted: { value: string; scope?: string }[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + expect(emitted[0]).toEqual({ value: 'pod', scope: 'mine' }); + }); + + it('emits searchChanged with empty value after simulated clear', () => { + const { fixture, component } = setup({ searchConfig: {} }); + fixture.detectChanges(); + + 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 }); + }); + }); + + // ------------------------------------------------------------------------- + // 3. searchSubmit output (synchronous) + // ------------------------------------------------------------------------- + + describe('searchSubmit output', () => { + it('emits searchSubmit synchronously on ui5Search event', () => { + const { fixture, component } = setup({ searchConfig: {} }); + 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: {} }); + + 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' }); + }); + }); + + // ------------------------------------------------------------------------- + // 4. scopeChanged output (synchronous) + // ------------------------------------------------------------------------- + + describe('scopeChanged output', () => { + it('emits scopeChanged synchronously on ui5ScopeChange event', () => { + const { component } = setup({ searchConfig: {} }); + + 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: {} }); + + 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: {} }); + + 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'); + }); + }); + + // ------------------------------------------------------------------------- + // 5. 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: {} }); + vi.advanceTimersByTime(300); // flush any pending init emission + + const emitted: unknown[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + fixture.componentRef.setInput('searchConfig', { value: 'same' }); + fixture.detectChanges(); + vi.advanceTimersByTime(300); + emitted.length = 0; // clear first emission + + fixture.componentRef.setInput('searchConfig', { value: 'same' }); + fixture.detectChanges(); + vi.advanceTimersByTime(300); + expect(emitted).toHaveLength(0); + }); + }); +}); 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..b1e6622 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -0,0 +1,118 @@ +import { TableCardSearchConfig } from '../models/search-config'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Injector, + ViewEncapsulation, + 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 '@ui5/webcomponents-icons/dist/search.js'; +import { debounceTime } from 'rxjs'; + +interface Ui5SearchEventTarget { + value?: string; + scopeValue?: string; +} + +@Component({ + selector: 'mfp-table-card-search', + imports: [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(); + + readonly searchChanged = output<{ value: string; scope?: string }>(); + readonly searchSubmit = output<{ value: string; scope?: string }>(); + readonly scopeChanged = output<{ value: string; scope?: string }>(); + + protected searchControl = new FormControl(''); + protected searchInputRef = viewChild('searchInput'); + protected activeScope = signal(undefined); + + private readonly injector = inject(Injector); + + constructor() { + this.searchControl.valueChanges + .pipe(debounceTime(300), takeUntilDestroyed()) + .subscribe((value) => { + this.searchChanged.emit({ + value: value ?? '', + scope: this.activeScope(), + }); + }); + + effect(() => { + const config = this.searchConfig(); + this.activeScope.set(config.scopeValue); + + const nextValue = config.value ?? ''; + if (this.searchControl.value !== nextValue) { + this.searchControl.setValue(nextValue); + } + }); + + // Workaround for ui5-select truncating long scope labels — see https://github.com/UI5/webcomponents/issues/13719 + setTimeout(() => { + this.fixSelectWidth(); + }, 0); + } + + 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 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/index.ts b/projects/ngx/declarative-ui/table/models/index.ts index c6a1f23..8da895e 100644 --- a/projects/ngx/declarative-ui/table/models/index.ts +++ b/projects/ngx/declarative-ui/table/models/index.ts @@ -7,8 +7,11 @@ export type { ValueRule, RuleCondition, FieldDefinition, - TableFieldDefinition, - ResourceFieldButtonClickEvent, PropertyField, TransformType, } from '../../models/ui-definition'; +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 new file mode 100644 index 0000000..72a58d5 --- /dev/null +++ b/projects/ngx/declarative-ui/table/models/table-config.ts @@ -0,0 +1,41 @@ +import { FieldDefinition } from '../../models/ui-definition'; +import { GenericResource } from '../../models/resource'; + +/** 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; +} + +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; +}