diff --git a/docs/declarative-table-card.md b/docs/declarative-table-card.md index 0323f47..8c64616 100644 --- a/docs/declarative-table-card.md +++ b/docs/declarative-table-card.md @@ -90,7 +90,9 @@ import { (createSubmit)="onCreateSubmit($event, tableCard)" (editSubmit)="onEditSubmit($event, tableCard)" (deleteSubmit)="onDeleteSubmit($event, tableCard)" - (searchChanged)="onSearch($event)" + (searchChanged)="onSearchChanged($event)" + (searchSubmit)="onSearchSubmit($event)" + (scopeChanged)="onScopeChanged($event)" /> `, }) @@ -164,6 +166,23 @@ export class MyComponent { await this.deletePod(pod); tableCard.closeDeleteDialog(); } + + onSearchChanged(value: string | null): void { + // Debounced (~300 ms) — fires while the user types and on clear-icon click. + // The currently active scope is tracked separately via `scopeChanged`. + this.reloadPods({ query: value ?? '' }); + } + + onSearchSubmit(value: string | null): void { + // Synchronous — fired on Enter or the search icon. Useful for forcing + // an immediate refresh that bypasses the debounce. + this.reloadPods({ query: value ?? '' }); + } + + onScopeChanged(scope: Scope | undefined): void { + // Synchronous — re-fetch using the new scope. + this.reloadPods({ scope }); + } } ``` @@ -189,7 +208,9 @@ export class MyComponent { | `createSubmit` | `Record` | Fires when the create dialog Save button is clicked | | `editSubmit` | `{ resource: T; value: Record }` | Fires when the edit dialog Save button is clicked | | `deleteSubmit` | `T` | Fires when the delete dialog Delete button is clicked | -| `searchChanged` | `string` | Emits 300 ms after the search input changes | +| `searchChanged` | `string \| null` | Emits ~300 ms after the search input changes (typing or clear icon click). The empty string is emitted immediately on clear. | +| `searchSubmit` | `string \| null` | Emits synchronously when the user submits the search (Enter or search icon) | +| `scopeChanged` | `Scope \| undefined` | Emits synchronously when the user picks a different scope from the dropdown. The full scope object is forwarded; `undefined` means "no scope selected". | | `tableRowClicked` | `T` | Emits when a table row is clicked | | `loadMoreResources` | - | Emits when the user triggers load more | | `paginationLimitChanged` | `number` | Emits when the user changes page size | @@ -211,15 +232,44 @@ 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 { + /** Stable identifier used to match `initialScopeValue` and as ``. */ + id: string; + /** Visible label shown in the dropdown. */ + label: string; + /** Logical value forwarded to the host (e.g. for filtering, URL query string). */ + value: string; + /** Name of the property this scope filters by — used by the host to build the filter expression. */ + property: string; +} + +/** Configuration for the `` element rendered in the table-card header. */ +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 — must be one of the entries in `scopes`. Matched against `scopes[].id`. */ + initialScopeValue?: Scope; + /** Initial / controlled search text value. */ + value?: string; + /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ + scopes?: Scope[]; +} + interface TableConfig { fields: TableFieldDefinition[]; totalItemsCount?: number; @@ -244,6 +294,126 @@ 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 inline in the toolbar. Omit `searchConfig` to hide the search entirely. + +### Clearing the input + +`showClearIcon` defaults to `true` — the user can click the inline X icon to empty the input. Clearing emits `searchChanged` with an empty string **immediately** (bypassing the typing debounce), so the host can refetch / un-filter without waiting. + +### Scopes + +`scopes` is an array of `Scope` objects shown in the dropdown next to the search input. Each scope is matched by its `id`; `initialScopeValue` (a full `Scope`) selects the initially-active scope. Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. + +The `value` and `property` fields on `Scope` are passed through verbatim in `scopeChanged` — the host decides how to use them (e.g. as `?=` in the URL, as an OpenSearch `filter==` parameter, etc.). + +### Event contract + +The host owns data fetching and filtering. The card forwards user actions verbatim: + +| Event | When | Payload | +| --------------- | ---- | ------- | +| `searchChanged` | ~300 ms after the input value changes while typing; **immediately** when the clear icon is clicked | `string \| null` — the current input value | +| `searchSubmit` | User presses Enter or clicks the search icon (synchronous) | `string \| null` — the current input value | +| `scopeChanged` | User picks a different scope from the dropdown (synchronous) | `Scope \| undefined` — the full scope object, or `undefined` when no scope is active | + +The search text and the active scope are emitted on separate events. The host is responsible for keeping the most recent value of each and combining them when issuing the next request. + +### Example — "My Contributions" / "All" scopes + +```ts +import { + DeclarativeTableCard, + Scope, + TableCardConfig, +} from '@openmfp/webcomponents'; + +@Component({ + imports: [DeclarativeTableCard], + template: ` + + `, +}) +export class MyComponent { + pods: Pod[] = []; + + private currentQuery = ''; + private currentScope: Scope | undefined; + + private readonly ALL_SCOPE: Scope = { + id: 'all', + label: 'All', + value: '*', + property: 'owner', + }; + private readonly MINE_SCOPE: Scope = { + id: 'mine', + label: 'My Contributions', + value: 'me', + property: 'owner', + }; + + config: TableCardConfig = { + header: 'Pods', + tableConfig: { + fields: [ + { label: 'Name', property: 'metadata.name' }, + { label: 'Namespace', property: 'metadata.namespace' }, + ], + }, + searchConfig: { + placeholder: 'Search pods…', + accessibleName: 'Search pods', + scopes: [this.ALL_SCOPE, this.MINE_SCOPE], + initialScopeValue: this.ALL_SCOPE, + }, + }; + + onSearchChanged(value: string | null): void { + // Debounced while typing; instant on clear. Combine with the current scope + // when reloading. + this.currentQuery = value ?? ''; + this.reload(); + } + + onSearchSubmit(value: string | null): void { + // Synchronous — fired on Enter or the search icon. Useful for forcing + // an immediate refresh that bypasses the typing debounce. + this.currentQuery = value ?? ''; + this.reload(); + } + + onScopeChanged(scope: Scope | undefined): void { + // Synchronous — re-fetch with the current in-flight search text and the + // newly-selected scope. + this.currentScope = scope; + this.reload(); + } + + private reload(): void { + this.reloadPods({ + query: this.currentQuery, + filter: this.currentScope + ? `${this.currentScope.property}=${this.currentScope.value}` + : undefined, + }); + } +} +``` + +Omit `scopes` (or pass an empty array) to render the input without a scope dropdown. + --- ## Actions column diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..961fcfe --- /dev/null +++ b/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["projects", "scripts"], + "ignore": ["dist", "public", ".angular", "node_modules", "documentation.json"], + "ext": "js,yml,yaml,ts,html,css,scss,json,md", + "delay": 1000, + "exec": "rimraf dist && npm run build:dev && npm run yalc:publish" +} diff --git a/package.json b/package.json index ab24c90..b72c651 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,12 @@ "type": "module", "scripts": { "build:ngx": "ng build ngx", + "build:ngx:dev": "ng build ngx --configuration=development", "build:wc": "ng build webcomponents && ng build webcomponents-dashboard && node scripts/bundle-wc.mjs", + "build:wc:dev": "ng build webcomponents --configuration=development && ng build webcomponents-dashboard --configuration=development && node scripts/bundle-wc.mjs", "build": "npm run build:ngx && npm run build:wc", - "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon --ignore dist --ignore public --ext js,yml,yaml,ts,html,css,scss,json,md --exec \"rimraf dist && npm run build && npm run yalc:publish\"", + "build:dev": "npm run build:ngx:dev && npm run build:wc:dev", + "build:watch": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && nodemon", "yalc:publish": "yalc publish dist/ngx --push --sig && yalc publish dist/webcomponents --push --sig", "test": "ng test ngx --watch=false", "test:watch": "ng test ngx", diff --git a/projects/ngx/declarative-ui/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..37ce424 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?.initialScopeValue?.value; + } + + 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,68 @@ 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: [ + { + id: 'all', + label: 'All namespaces', + value: 'all', + property: 'namespace', + }, + { + id: 'd', + label: 'default', + value: 'default', + property: 'namespace', + }, + { + id: 'ks', + label: 'kube-system', + value: 'kube-system', + property: 'namespace', + }, + ], + initialScopeValue: { + id: 'all', + label: 'All namespaces', + value: 'all', + property: 'namespace', + }, + } 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..e58414c 100644 --- a/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts +++ b/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts @@ -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 { Scope, 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(); + /** + * 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(); + /** + * 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(); 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..1875445 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/models/search-config.ts @@ -0,0 +1,27 @@ +/** One option in the `` scopes dropdown. */ +export interface Scope { + /** Id of the scope. */ + id: string; + /** Visible label shown in the dropdown. */ + label: string; + /** Logical value forwarded in `scopeChanged` / `searchSubmit` events. Used by `` to match `scopeValue`. */ + value: string; + /** The name of the property the value refers to **/ + property: 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`). */ + initialScopeValue?: Scope; + /** Initial / controlled search text value. */ + value?: string; + /** Scope options shown in the scopes dropdown. Omit or leave empty to render the input without a scope dropdown. */ + scopes?: Scope[]; +} 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..1085bf7 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.html @@ -0,0 +1,15 @@ + + @for (s of searchConfig().scopes ?? []; track s.id) { + + } + 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..92d692c --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.scss @@ -0,0 +1,3 @@ +:host { + display: contents; +} 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..5a82361 --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.spec.ts @@ -0,0 +1,305 @@ +import { TableCardSearch } from './table-card-search.component'; +import { Scope, TableCardSearchConfig } from '../models/search-config'; +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +type Comp = TableCardSearch; +type Fixture = ComponentFixture; + +const ALL_SCOPE: Scope = { + id: 'all', + label: 'All', + value: '*', + property: 'owner', +}; +const MINE_SCOPE: Scope = { + id: 'mine', + label: 'My Contributions', + value: 'me', + property: 'owner', +}; + +function root(fixture: Fixture): ShadowRoot | HTMLElement { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + +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: [ALL_SCOPE, MINE_SCOPE], + }, + }); + 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 from label and value from id on each ui5-search-scope', () => { + const { fixture } = setup({ + searchConfig: { scopes: [ALL_SCOPE] }, + }); + const scope = root(fixture).querySelector('ui5-search-scope') as HTMLElement & { + text?: string; + value?: string; + }; + expect(scope?.text).toBe('All'); + // ui5-search-scope's `value` is the scope `id`, used by ui5 to match the + // active scopeValue. The Scope.value field is forwarded separately on events. + expect(scope?.value).toBe('all'); + }); + }); + + // ------------------------------------------------------------------------- + // 2. searchChanged output (debounced) + // ------------------------------------------------------------------------- + + describe('searchChanged output', () => { + it('emits searchChanged with the current value after 300ms debounce on ui5Input', () => { + const { fixture, component } = setup({ searchConfig: {} }); + fixture.detectChanges(); + + const emitted: (string | null)[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'alpha' })); + expect(emitted).toHaveLength(0); + vi.advanceTimersByTime(300); + expect(emitted).toEqual(['alpha']); + }); + + it('does not emit searchChanged before the 300ms debounce elapses', () => { + const { fixture, component } = setup({ searchConfig: {} }); + fixture.detectChanges(); + + const emitted: (string | null)[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'beta' })); + vi.advanceTimersByTime(299); + expect(emitted).toHaveLength(0); + }); + + 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: (string | null)[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: '' })); + vi.advanceTimersByTime(300); + + expect(emitted).toEqual(['']); + }); + }); + + // ------------------------------------------------------------------------- + // 3. searchSubmit output (synchronous) + // ------------------------------------------------------------------------- + + describe('searchSubmit output', () => { + it('emits searchSubmit synchronously on ui5Search event', () => { + const { fixture, component } = setup({ searchConfig: {} }); + fixture.detectChanges(); + + const emitted: (string | null)[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + component.onSearchSubmit(fakeSearchEvent({ value: 'my-pod' })); + expect(emitted).toEqual(['my-pod']); + }); + + it('forwards the current input value verbatim even when a scope is active', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE] }, + }); + + // Activate a scope first. + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'all' })); + + const emitted: (string | null)[] = []; + component.searchSubmit.subscribe((e) => emitted.push(e)); + + // The scope is NOT bundled in the searchSubmit payload (decoupled events). + component.onSearchSubmit(fakeSearchEvent({ value: 'redis', scopeValue: 'all' })); + expect(emitted).toEqual(['redis']); + }); + }); + + // ------------------------------------------------------------------------- + // 4. scopeChanged output (synchronous) + // ------------------------------------------------------------------------- + + describe('scopeChanged output', () => { + it('emits the matching Scope object synchronously on ui5ScopeChange event', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); + + const emitted: (Scope | undefined)[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + expect(emitted).toEqual([MINE_SCOPE]); + }); + + it('emits undefined when the event carries no matching scope id', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); + + const emitted: (Scope | undefined)[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'nonexistent' })); + expect(emitted).toEqual([undefined]); + }); + + it('emits undefined when the event omits a scopeValue', () => { + const { component } = setup({ + searchConfig: { scopes: [ALL_SCOPE, MINE_SCOPE] }, + }); + + const emitted: (Scope | undefined)[] = []; + component.scopeChanged.subscribe((e) => emitted.push(e)); + + component.onSearchScopeChange(fakeSearchEvent({ value: '' })); + expect(emitted).toEqual([undefined]); + }); + + it('subsequent searchChanged is independent of scope (events are decoupled)', () => { + const { component } = setup({ + searchConfig: { scopes: [MINE_SCOPE] }, + }); + + component.onSearchScopeChange(fakeSearchEvent({ value: '', scopeValue: 'mine' })); + + const emitted: (string | null)[] = []; + component.searchChanged.subscribe((e) => emitted.push(e)); + + component.onSearchInput(fakeSearchEvent({ value: 'pod' })); + vi.advanceTimersByTime(300); + + // searchChanged carries only the search text; the host correlates scope + // and search text from their separate streams. + expect(emitted).toEqual(['pod']); + }); + }); + + // ------------------------------------------------------------------------- + // 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: (string | null)[] = []; + 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..20c3b1b --- /dev/null +++ b/projects/ngx/declarative-ui/table-card/search/table-card-search.component.ts @@ -0,0 +1,203 @@ +import { Scope, 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(); + readonly searchSubmit = output(); + readonly scopeChanged = output(); + + protected searchControl = new FormControl(''); + protected searchInputRef = viewChild('searchInput'); + protected activeScope = signal(undefined); + /** + * Signal mirror of `searchConfig().value` used as the source of truth for + * `[value]` on ``. Decoupling the template binding from + * `searchControl.value` prevents the bound property from snapping the + * input back to a stale value during user interactions like clicking the + * clear icon — ui5 owns the live displayed value, Angular only writes it + * when the parent's config changes. + */ + protected externalValue = signal(''); + + private readonly injector = inject(Injector); + + constructor() { + this.searchControl.valueChanges + .pipe(debounceTime(300), takeUntilDestroyed()) + .subscribe((value) => { + this.searchChanged.emit(value); + }); + + effect(() => { + const config = this.searchConfig(); + this.activeScope.set(config.initialScopeValue); + + const nextValue = config.value ?? ''; + if (this.searchControl.value !== nextValue) { + this.searchControl.setValue(nextValue); + this.externalValue.set(nextValue); + } + }); + + // Workaround for ui5-select truncating long scope labels — see https://github.com/UI5/webcomponents/issues/13719 + setTimeout(() => { + this.fixSelectWidth(); + this.fixSearchIconSize(); + this.fixSearchWidth(); + this.bindNativeInputListener(); + }, 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(target?.value ?? ''); + } + + onSearchScopeChange(event: Event): void { + const target = event.target as Ui5SearchEventTarget | null; + const scopeId = target?.scopeValue || undefined; + const scope = this.searchConfig().scopes?.find((e) => e.id === scopeId); + this.activeScope.set(scope); + this.scopeChanged.emit(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'; + } + } + + /** + * Workaround for the ui5 SearchField icon collapsing in some host theme + * environments: the rule `.ui5-shell-search-field-icon::part(root){width:1rem;height:1rem}` + * lives inside ``'s own shadow root and is occasionally stripped + * by the consumer's theming layer, leaving the icon mispositioned. We append + * the missing width/height back into the same shadow root so the icon + * renders identically to Storybook regardless of the host environment. + */ + private fixSearchIconSize(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | HTMLElement + | undefined; + const shadow = nativeEl?.shadowRoot; + if (!shadow) return; + + // Idempotent — only inject once per component instance. + const STYLE_ID = 'mfp-search-icon-size-fix'; + if (shadow.getElementById(STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .ui5-shell-search-field-icon::part(root) { + width: 1rem; + height: 1rem; + } + `; + shadow.appendChild(style); + } + + /** + * Workaround for the ui5 SearchField shadow CSS pinning its host to + * `min-width: 18rem; max-width: 36rem`. In dense toolbar layouts that + * lets the search render almost twice the width it should. We replace + * those rules with inline styles on the host so layout behavior lives + * in one place (this method). + */ + private fixSearchWidth(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | HTMLElement + | undefined; + if (!nativeEl) return; + nativeEl.style.flex = '1'; + nativeEl.style.minWidth = '150px'; + nativeEl.style.maxWidth = 'calc(20rem + 50px)'; + nativeEl.style.width = '100%'; + } + + /** + * Some ngx wrapper / shadow-DOM event-bubbling edge-cases cause the + * Angular `(ui5Input)` binding to miss the synthetic `input` event that + * `` fires when its clear icon is clicked. Bind a native + * listener directly on the host element as an immediate, unconditional + * path so the clear button always resets the search. + * + * The event fires for BOTH user typing and clear-icon clicks; reading + * `target.value` (which ui5 sets to `""` before firing for clear) tells + * us which case we're in. Typing is already handled by `(ui5Input)`'s + * debounced flow — we only need to act here when the value is empty so + * the clear is instant (no 300ms wait). + */ + private bindNativeInputListener(): void { + const nativeEl = this.searchInputRef()?.elementRef.nativeElement as + | (HTMLElement & { value?: string }) + | undefined; + if (!nativeEl) return; + + nativeEl.addEventListener('input', () => { + const next = nativeEl.value ?? ''; + if (this.searchControl.value !== next) { + this.searchControl.setValue(next); + } + // Fire the parent notification immediately on clear (skipping the + // 300ms debounce that exists for typed input). + if (next === '') { + this.searchChanged.emit(''); + } + }); + } +} 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; +} diff --git a/projects/ngx/declarative-ui/tsconfig.lib.json b/projects/ngx/declarative-ui/tsconfig.lib.json index c973483..177d3d9 100644 --- a/projects/ngx/declarative-ui/tsconfig.lib.json +++ b/projects/ngx/declarative-ui/tsconfig.lib.json @@ -4,8 +4,13 @@ "outDir": "../../../out-tsc/lib", "declaration": true, "declarationMap": true, + "sourceMap": true, + "inlineSources": true, "types": [] }, + "angularCompilerOptions": { + "compilationMode": "partial" + }, "include": ["**/*.ts"], "exclude": ["**/*.spec.ts"] } diff --git a/projects/ngx/tsconfig.lib.json b/projects/ngx/tsconfig.lib.json index d6699b3..5be2ae8 100644 --- a/projects/ngx/tsconfig.lib.json +++ b/projects/ngx/tsconfig.lib.json @@ -4,8 +4,13 @@ "outDir": "../../out-tsc/lib", "declaration": true, "declarationMap": true, + "sourceMap": true, + "inlineSources": true, "types": [] }, + "angularCompilerOptions": { + "compilationMode": "partial" + }, "include": ["**/*.ts"], "exclude": ["**/*.spec.ts"] }