diff --git a/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.html b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.html new file mode 100644 index 0000000..2b0613f --- /dev/null +++ b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.html @@ -0,0 +1,38 @@ +
+ @for (entry of entries(); track $index; let index = $index) { + @let expanded = expandedIndex() === index; +
+ + + @if (expanded) { +
+ @for (row of rows(); track row.field.property) { +
+ {{ row.label }}: + +
+ } +
+ } +
+ } +
diff --git a/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.scss b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.scss new file mode 100644 index 0000000..2c3245a --- /dev/null +++ b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.scss @@ -0,0 +1,75 @@ +:host { + display: block; + width: 100%; +} + +.collection { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + padding-top: 0.25rem; +} + +.card { + border: 1px solid var(--sapGroup_ContentBorderColor, #d9d9d9); + border-radius: 0.5rem; + background: var(--sapGroup_TitleBackground, #fff); + overflow: hidden; + + &--expanded { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + + &__header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0 0.75rem; + background: transparent; + border: 0; + text-align: left; + cursor: pointer; + font: inherit; + min-height: 1.5rem; + color: var(--sapTextColor, #1d2d3e); + + &:hover { + background: var(--sapList_Hover_Background, rgba(0, 0, 0, 0.03)); + } + } + + &__chevron { + flex: 0 0 auto; + color: var(--sapContent_IconColor, #6a6d70); + } + + &__preview { + font-weight: 500; + color: var(--sapTextColor, #1d2d3e); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--sapGroup_ContentBorderColor, #d9d9d9); + } +} + +.row { + display: flex; + gap: 0.25rem; + align-items: baseline; + + &__label { + font-weight: 500; + color: var(--sapContent_LabelColor, #556b82); + white-space: nowrap; + } +} diff --git a/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.spec.ts b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.spec.ts new file mode 100644 index 0000000..b5ce2fa --- /dev/null +++ b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.spec.ts @@ -0,0 +1,126 @@ +import { FieldDefinition, GenericResource } from '../../models'; +import { ResourceCollectionField } from './resource-collection-field.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +type Fixture = ComponentFixture< + ResourceCollectionField +>; +type Comp = ResourceCollectionField; + +const CONDITIONS_FIELD: FieldDefinition = { + label: 'Conditions', + property: 'status.conditions', + propertyCollection: [ + { label: 'Type', property: 'status.conditions.type' }, + { label: 'Status', property: 'status.conditions.status' }, + { label: 'Reason', property: 'status.conditions.reason' }, + ], +}; + +function setup( + field: FieldDefinition, + resource?: Partial, +): { fixture: Fixture; component: Comp } { + const fixture: Fixture = TestBed.createComponent( + ResourceCollectionField as unknown as typeof ResourceCollectionField< + GenericResource, + FieldDefinition + >, + ); + const component = fixture.componentInstance; + fixture.componentRef.setInput('fieldDefinition', field); + if (resource !== undefined) + fixture.componentRef.setInput('resource', resource as GenericResource); + fixture.detectChanges(); + return { fixture, component }; +} + +function root(fixture: Fixture): ParentNode { + return fixture.nativeElement.shadowRoot ?? fixture.nativeElement; +} + +function all(fixture: Fixture, selector: string): Element[] { + return Array.from(root(fixture).querySelectorAll(selector)); +} + +function el(fixture: Fixture, testId: string): Element | null { + return root(fixture).querySelector(`[data-testid="${testId}"]`); +} + +describe('ResourceCollectionField', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceCollectionField], + }).compileComponents(); + }); + + it('renders one collapsed card per array entry', () => { + const { fixture } = setup(CONDITIONS_FIELD, { + status: { + conditions: [ + { type: 'Ready', status: 'True', reason: 'OK' }, + { type: 'Progressing', status: 'False', reason: 'Retry' }, + ], + }, + } as unknown as GenericResource); + + expect(all(fixture, '.card').length).toBe(2); + expect(all(fixture, '.card__body').length).toBe(0); + }); + + it('shows the first non-empty sub-field as the header preview', () => { + const { fixture } = setup(CONDITIONS_FIELD, { + status: { conditions: [{ type: 'Ready', status: 'True' }] }, + } as unknown as GenericResource); + + expect( + el(fixture, 'resource-collection-item-0-toggle')?.textContent, + ).toContain('Type: Ready'); + }); + + it('falls back to label and index when the entry is empty', () => { + const { fixture } = setup(CONDITIONS_FIELD, { + status: { conditions: [{}] }, + } as unknown as GenericResource); + + expect( + el(fixture, 'resource-collection-item-0-toggle')?.textContent, + ).toContain('Conditions 1'); + }); + + it('expands a single card and renders a resource-field per sub-field', () => { + const { fixture, component } = setup(CONDITIONS_FIELD, { + status: { conditions: [{ type: 'Ready', status: 'True', reason: 'OK' }] }, + } as unknown as GenericResource); + + component.toggle(0); + fixture.detectChanges(); + + const bodies = all(fixture, '.card__body'); + expect(bodies.length).toBe(1); + expect(bodies[0].querySelectorAll('mfp-resource-field').length).toBe(3); + expect(bodies[0].textContent).toContain('Type:'); + }); + + it('collapses an expanded card when toggled again', () => { + const { fixture, component } = setup(CONDITIONS_FIELD, { + status: { conditions: [{ type: 'Ready' }] }, + } as unknown as GenericResource); + + component.toggle(0); + fixture.detectChanges(); + expect(all(fixture, '.card__body').length).toBe(1); + + component.toggle(0); + fixture.detectChanges(); + expect(all(fixture, '.card__body').length).toBe(0); + }); + + it('renders no cards when the collection path is missing or not an array', () => { + const { fixture } = setup(CONDITIONS_FIELD, { + status: {}, + } as unknown as GenericResource); + + expect(all(fixture, '.card').length).toBe(0); + }); +}); diff --git a/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.ts b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.ts new file mode 100644 index 0000000..3719216 --- /dev/null +++ b/projects/ngx/declarative-ui/resource-field/resource-collection-field/resource-collection-field.component.ts @@ -0,0 +1,108 @@ +import { FieldDefinition, GenericResource } from '../../models'; +import { getResourceValueByJsonPath } from '../../table/utils/resource-field-by-path'; +import { ResourceField } from '../resource-field.component'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + computed, + forwardRef, + input, + signal, +} from '@angular/core'; +import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; +import '@ui5/webcomponents-icons/dist/navigation-down-arrow.js'; +import '@ui5/webcomponents-icons/dist/navigation-right-arrow.js'; + +/** One `label: value` row of an expanded collection card. */ +interface CollectionRow { + label: string; + field: FieldDefinition; +} + +/** + * Read-only display for a `propertyCollection` field. Renders the resolved + * array as a column of collapsed cards; expanding a card lists each sub-field + * as a `label: value` row rendered through `mfp-resource-field`, so per- + * sub-field `uiSettings` still apply. Mirrors the styling of the editable + * `mfp-form-collection-field`. + */ +@Component({ + selector: 'mfp-resource-collection-field', + // `ResourceField` imports this component back, so the two form a module-init + // cycle. `forwardRef` defers resolution until the template is instantiated. + imports: [Icon, forwardRef(() => ResourceField)], + templateUrl: './resource-collection-field.component.html', + styleUrl: './resource-collection-field.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.ShadowDom, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class ResourceCollectionField< + T extends GenericResource, + F extends FieldDefinition, +> { + readonly fieldDefinition = input.required(); + readonly resource = input(); + + protected readonly expandedIndex = signal(null); + + private readonly collectionPath = computed(() => { + const property = this.fieldDefinition().property; + return typeof property === 'string' ? property : undefined; + }); + + protected readonly entries = computed[]>(() => { + const value = getResourceValueByJsonPath(this.resource() ?? {}, { + property: this.collectionPath(), + }); + return Array.isArray(value) ? (value as Record[]) : []; + }); + + protected readonly rows = computed(() => { + const parent = this.collectionPath(); + return (this.fieldDefinition().propertyCollection ?? []).map((sub) => { + const property = + typeof sub.property === 'string' + ? stripParentPath(sub.property, parent) + : sub.property; + return { + label: sub.label ?? leafOf(property), + field: { ...sub, property } as FieldDefinition, + }; + }); + }); + + toggle(index: number, event?: Event): void { + event?.stopPropagation(); + this.expandedIndex.set(this.expandedIndex() === index ? null : index); + } + + previewFor(entry: Record, index: number): string { + for (const row of this.rows()) { + const raw = getResourceValueByJsonPath(entry, { + property: row.field.property, + }); + if (raw !== undefined && raw !== null && String(raw).trim() !== '') { + return `${row.label}: ${String(raw)}`; + } + } + const prefix = (this.fieldDefinition().label ?? '').trim() || 'Item'; + return `${prefix} ${index + 1}`; + } +} + +function stripParentPath(name: string, parent: string | undefined): string { + if (!parent) return name; + if (name.startsWith(parent + '.')) { + return name.slice(parent.length + 1); + } + return name; +} + +function leafOf(property: string | string[] | undefined): string { + if (typeof property !== 'string') return ''; + const segments = property.split('.'); + return segments[segments.length - 1]; +} diff --git a/projects/ngx/declarative-ui/resource-field/resource-field.component.html b/projects/ngx/declarative-ui/resource-field/resource-field.component.html index 2ce9d7f..4d5ccae 100644 --- a/projects/ngx/declarative-ui/resource-field/resource-field.component.html +++ b/projects/ngx/declarative-ui/resource-field/resource-field.component.html @@ -1,72 +1,79 @@ -@let displayType = displayAs(); - - @if (displayType === 'secret') { - - - - - } @else if (displayType === 'boolIcon' && isBoolLike()) { - - } @else if (displayType === 'link' && isUrlValue()) { - - } @else if (displayType === 'tooltip') { - - } @else if (displayType === 'alert') { - @if (!value()) { +@if (isCollection()) { + +} @else { + @let displayType = displayAs(); + + @if (displayType === 'secret') { + + + + + } @else if (displayType === 'boolIcon' && isBoolLike()) { + + } @else if (displayType === 'link' && isUrlValue()) { + + } @else if (displayType === 'tooltip') { + } @else if (displayType === 'alert') { + @if (!value()) { + + } + } @else if (displayType === 'img' && value()) { + Resource image + } @else if (displayType === 'button') { + @let buttonSettings = uiSettings()?.buttonSettings; + {{ buttonSettings?.text }}  + } @else if (displayType === 'tag') { + + } @else { + {{ displayValue() }} } - } @else if (displayType === 'img' && value()) { - Resource image - } @else if (displayType === 'button') { - @let buttonSettings = uiSettings()?.buttonSettings; - {{ buttonSettings?.text }}  - } @else if (displayType === 'tag') { - + + @if (withCopyButton()) { + - } @else { - {{ displayValue() }} } - - -@if (withCopyButton()) { - } diff --git a/projects/ngx/declarative-ui/resource-field/resource-field.component.scss b/projects/ngx/declarative-ui/resource-field/resource-field.component.scss index 12dba3c..5aa8bf1 100644 --- a/projects/ngx/declarative-ui/resource-field/resource-field.component.scss +++ b/projects/ngx/declarative-ui/resource-field/resource-field.component.scss @@ -3,6 +3,11 @@ align-items: center; } +:host(.resource-field--collection) { + display: block; + width: 100%; +} + .secret-value-container { display: flex; align-items: center; diff --git a/projects/ngx/declarative-ui/resource-field/resource-field.component.spec.ts b/projects/ngx/declarative-ui/resource-field/resource-field.component.spec.ts index 8b60992..63d338e 100644 --- a/projects/ngx/declarative-ui/resource-field/resource-field.component.spec.ts +++ b/projects/ngx/declarative-ui/resource-field/resource-field.component.spec.ts @@ -427,4 +427,30 @@ describe('ResourceField', () => { expect(span?.textContent?.trim()).toBe('Pending'); }); }); + + describe('collection display', () => { + it('delegates to mfp-resource-collection-field when the field has propertyCollection', () => { + const { fixture } = setup( + { + label: 'Conditions', + property: 'status.conditions', + propertyCollection: [ + { label: 'Type', property: 'status.conditions.type' }, + ], + }, + { + status: { conditions: [{ type: 'Ready' }] }, + } as unknown as GenericResource, + ); + + const scope = fixture.nativeElement.shadowRoot ?? fixture.nativeElement; + expect(scope.querySelector('mfp-resource-collection-field')).toBeTruthy(); + }); + + it('renders a scalar value (no collection) for a plain field', () => { + const { fixture } = setup({ property: 'status' }, { status: 'Active' }); + const scope = fixture.nativeElement.shadowRoot ?? fixture.nativeElement; + expect(scope.querySelector('mfp-resource-collection-field')).toBeNull(); + }); + }); }); diff --git a/projects/ngx/declarative-ui/resource-field/resource-field.component.ts b/projects/ngx/declarative-ui/resource-field/resource-field.component.ts index 3d78a09..017a6df 100644 --- a/projects/ngx/declarative-ui/resource-field/resource-field.component.ts +++ b/projects/ngx/declarative-ui/resource-field/resource-field.component.ts @@ -3,10 +3,14 @@ import { GenericResource, ResourceFieldButtonClickEvent, } from '../models'; -import { evaluateCssRules, evaluateValueRules } from '../table/utils/rules.engine'; import { getFieldValue } from '../table/utils/field-definition.utils'; +import { + evaluateCssRules, + evaluateValueRules, +} from '../table/utils/rules.engine'; import { BooleanValue } from './boolean-value/boolean-value.component'; import { LinkValue } from './link-value/link-value.component'; +import { ResourceCollectionField } from './resource-collection-field/resource-collection-field.component'; import { SecretValue } from './secret-value/secret-value.component'; import { TagListValue } from './tag-list-value/tag-list-value.component'; import { @@ -15,6 +19,7 @@ import { Component, ViewEncapsulation, computed, + forwardRef, input, output, signal, @@ -25,14 +30,32 @@ import '@ui5/webcomponents-icons/dist/AllIcons.js'; @Component({ selector: 'mfp-resource-field', - imports: [Icon, BooleanValue, LinkValue, SecretValue, Button, TagListValue], + imports: [ + Icon, + BooleanValue, + LinkValue, + SecretValue, + Button, + TagListValue, + // `ResourceCollectionField` imports this component back (each expanded + // card renders its sub-fields via `mfp-resource-field`), so the two form + // a module-init cycle. `forwardRef` defers resolution until the template + // is instantiated, by which point both classes are defined. + forwardRef(() => ResourceCollectionField), + ], templateUrl: './resource-field.component.html', styleUrl: './resource-field.component.scss', + host: { + '[class.resource-field--collection]': 'isCollection()', + }, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.ShadowDom, schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class ResourceField { +export class ResourceField< + T extends GenericResource, + F extends FieldDefinition, +> { fieldDefinition = input.required(); resource = input(); readonly buttonClick = output>(); @@ -53,8 +76,8 @@ export class ResourceField ...this.cssCustomization(), ...this.cssRules(), })); - displayValue = computed( - () => evaluateValueRules(this.value() ?? '', this.uiSettings()?.valueRules), + displayValue = computed(() => + evaluateValueRules(this.value() ?? '', this.uiSettings()?.valueRules), ); isBoolLike = computed(() => this.boolValue() !== undefined); @@ -67,6 +90,10 @@ export class ResourceField isVisible = signal(false); copySuccess = signal(false); + isCollection = computed( + () => !!this.fieldDefinition().propertyCollection?.length, + ); + toggleVisibility(e: Event): void { e.stopPropagation(); this.isVisible.set(!this.isVisible()); @@ -93,11 +120,14 @@ export class ResourceField private normalizeTagsArray(value: unknown): string[] { if (Array.isArray(value)) { - return value.map(v => String(v).trim()).filter(v => v.length > 0); + return value.map((v) => String(v).trim()).filter((v) => v.length > 0); } if (typeof value === 'string') { const separator = this.uiSettings()?.tagSettings?.valueSeparator ?? ','; - return value.split(separator).map(v => v.trim()).filter(v => v.length > 0); + return value + .split(separator) + .map((v) => v.trim()) + .filter((v) => v.length > 0); } return []; } 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 2fcc51f..367de06 100644 --- a/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts @@ -13,6 +13,9 @@ import type { TableFieldDefinition } from '../table/models'; import { Component, Input } from '@angular/core'; import type { Meta, StoryObj } from '@storybook/angular'; import '@ui5/webcomponents-icons/dist/detail-view.js'; +import '@ui5/webcomponents-icons/dist/inspect.js'; +import '@ui5/webcomponents-icons/dist/restart.js'; +import '@ui5/webcomponents-icons/dist/stop.js'; // --------------------------------------------------------------------------- // Sample data @@ -613,3 +616,199 @@ export const WithFilterTabsOverflow: Story = { }, }, }; + +// --------------------------------------------------------------------------- +// Rich collection cells: a collection column whose sub-fields exercise tags, +// a secret, a css-coloured phrase, and icon-only row actions +// --------------------------------------------------------------------------- + +interface RichPod extends GenericResource { + id: string; + metadata: { name: string; namespace: string; uid: string }; + spec: { description: string }; + status: { + conditions: { + type: string; + status: string; + phase: string; + token: string; + }[]; + }; +} + +const RICH_PODS: RichPod[] = [ + { + id: 'rp-001', + metadata: { name: 'api-server-7d9f', namespace: 'default', uid: 'rp-001' }, + spec: { description: 'Front-facing API server pod' }, + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + phase: 'Running', + token: 'sk-live-7f3a9c21b48e5d0f', + }, + { + type: 'Initialized', + status: 'True', + phase: 'Pending', + token: 'sk-live-0d1e2f3a4b5c6d7e', + }, + ], + }, + isAvailable: true, + }, + { + id: 'rp-002', + metadata: { name: 'worker-5bc8', namespace: 'default', uid: 'rp-002' }, + spec: { description: 'Background job worker' }, + status: { + conditions: [ + { + type: 'Ready', + status: 'False', + phase: 'Failed', + token: 'sk-live-1a2b3c4d5e6f7a8b', + }, + ], + }, + isAvailable: true, + }, + { + id: 'rp-003', + metadata: { + name: 'db-postgres-0', + namespace: 'production', + uid: 'rp-003', + }, + spec: { description: 'Primary Postgres database' }, + status: { + conditions: [ + { + type: 'Ready', + status: 'True', + phase: 'Running', + token: 'sk-live-9z8y7x6w5v4u3t2s', + }, + ], + }, + isAvailable: false, + }, +]; + +const PHASE_CSS_RULES = [ + { + if: { condition: 'equals' as const, value: 'Running' }, + styles: { color: 'var(--sapPositiveColor, #107e3e)', fontWeight: '600' }, + }, + { + if: { condition: 'equals' as const, value: 'Pending' }, + styles: { color: 'var(--sapCriticalColor, #e9730c)', fontWeight: '600' }, + }, + { + if: { condition: 'equals' as const, value: 'Failed' }, + styles: { color: 'var(--sapNegativeColor, #bb0000)', fontWeight: '600' }, + }, +]; + +const RICH_COLUMNS: TableFieldDefinition[] = [ + { label: 'Name', property: 'metadata.name' }, + { label: 'Description', property: 'spec.description' }, + { + label: 'Conditions', + property: 'status.conditions', + propertyCollection: [ + { + label: 'Type', + property: 'status.conditions.type', + uiSettings: { + displayAs: 'tag', + tagSettings: { design: 'Set2', colorScheme: '2' }, + }, + }, + { + label: 'Status', + property: 'status.conditions.status', + uiSettings: { + displayAs: 'tag', + tagSettings: { design: 'Set2', colorScheme: '6' }, + }, + }, + { + label: 'Phase', + property: 'status.conditions.phase', + uiSettings: { cssRules: PHASE_CSS_RULES }, + }, + { + label: 'Token', + property: 'status.conditions.token', + uiSettings: { displayAs: 'secret', withCopyButton: true }, + }, + { + label: 'Restart', + uiSettings: { + displayAs: 'button', + buttonSettings: { + icon: 'restart', + design: 'Transparent', + action: 'restart', + tooltip: 'Restart', + }, + }, + }, + { + label: 'Stop', + uiSettings: { + displayAs: 'button', + buttonSettings: { + icon: 'stop', + design: 'Transparent', + action: 'stop', + tooltip: 'Stop', + }, + }, + }, + ], + }, + { + uiSettings: { + displayAs: 'button', + align: 'end', + buttonSettings: { + icon: 'inspect', + design: 'Transparent', + action: 'inspect', + tooltip: 'Inspect', + }, + }, + group: { name: 'actions', label: '', multiline: false }, + }, +]; + +/** + * A collection column whose sub-fields exercise every rich cell renderer. + * + * Top-level columns are just **Name**, **Description**, and the trailing + * **Actions** column. The **Conditions** column is a `propertyCollection` — + * each row renders as a stack of collapsed cards, and expanding one shows the + * sub-fields: + * + * - **Type** / **Status** as coloured **tags**. + * - **Phase** as a phrase coloured by `cssRules` (green / orange / red). + * - **Token** as a `secret` with a **copy button** and an **eye** toggle. + * - **Restart** and **Stop** as icon-only buttons (no text). + */ +export const WithRichCells: Story = { + args: { + config: { + ...BASE_CONFIG, + header: 'Pods (rich collection cells)', + tableConfig: { + ...BASE_TABLE_CONFIG, + fields: RICH_COLUMNS, + }, + }, + resources: RICH_PODS, + }, +};