From 88310c4848cacea8d91fa55f133d54e79f67a414 Mon Sep 17 00:00:00 2001 From: gkrajniak Date: Thu, 9 Jul 2026 10:57:17 +0200 Subject: [PATCH 1/6] Add support vor generic form collection of object Signed-off-by: gkrajniak --- docs/declarative-form.md | 16 ++++----- .../declarative-form.component.html | 4 +-- .../declarative-form.component.ts | 4 +-- .../form-collection-field.component.ts | 35 ++++++++++--------- .../form/models/form-field-definition.ts | 9 +++-- .../declarative-ui/models/ui-definition.ts | 11 +++--- .../stories/declarative-form.stories.ts | 10 +++--- 7 files changed, 43 insertions(+), 46 deletions(-) diff --git a/docs/declarative-form.md b/docs/declarative-form.md index 5e4628a..d5e9598 100644 --- a/docs/declarative-form.md +++ b/docs/declarative-form.md @@ -136,12 +136,12 @@ interface FormFieldDefinition { values?: string[]; // Static select options disabled?: boolean; // Disables the field validation?: 'onBlur' | 'onChange'; // When to emit fieldChange for this field - propertyCollection?: FormFieldDefinition[]; // Sub-fields for an array-of-objects field; see "Collection fields" below + collection?: FormFieldDefinition[]; // Sub-fields for an array-of-objects field; see "Collection fields" below } interface FormFieldChangeEvent { fieldProperty: string; // The field property name (matches field.name) - value: unknown; // Current value of the control; for a `propertyCollection` field this is the full `Array>` + value: unknown; // Current value of the control; for a `collection` field this is the full `Array>` } type FormFieldErrors = Record; @@ -153,7 +153,7 @@ type FormFieldErrors = Record; { fieldProperty: 'metadata.name', value: 'my-app' } ``` -For a `propertyCollection` field the payload carries the whole array: +For a `collection` field the payload carries the whole array: ```ts { @@ -180,9 +180,9 @@ For a `propertyCollection` field the payload carries the whole array: --- -## Collection fields (`propertyCollection`) +## Collection fields (`collection`) -Set `propertyCollection` on a `FormFieldDefinition` to declare that the value at this field's `name` is an **array of objects**. The form renders it as a stack of expandable/collapsible cards, one per array entry. +Set `collection` on a `FormFieldDefinition` to declare that the value at this field's `name` is an **array of objects**. The form renders it as a stack of expandable/collapsible cards, one per array entry. ### UX @@ -193,7 +193,7 @@ Set `propertyCollection` on a `FormFieldDefinition` to declare that the value at ### Sub-field definitions -`propertyCollection` is a full `FormFieldDefinition[]`. Each sub-field carries the same options as a top-level field (`label`, `values`, `required`, `validation`, `disabled`, and even nested `propertyCollection` for array-of-arrays). Sub-field `name` values are used verbatim as keys on each entry object — whatever you write becomes the shape of the emitted payload. +`collection` is a full `FormFieldDefinition[]`. Each sub-field carries the same options as a top-level field (`label`, `values`, `required`, `validation`, `disabled`, and even nested `collection` for array-of-arrays). Sub-field `name` values are used verbatim as keys on each entry object — whatever you write becomes the shape of the emitted payload. ### Change events for collection fields @@ -208,7 +208,7 @@ const fields: FormFieldDefinition[] = [ { name: 'spec.artifacts', label: 'Artifacts', - propertyCollection: [ + collection: [ { name: 'name', label: 'Name', required: true }, { name: 'url', label: 'URL' }, { name: 'type', label: 'Type', values: ['image', 'chart', 'file'] }, @@ -253,7 +253,7 @@ All interactive elements carry `data-testid` attributes for reliable E2E targeti | Field label | `generic-form-field-label-{name}` | | | Input or select | `generic-form-field-{name}` | `` or `` | | Select option | `generic-form-field-{name}-option-{value}` | `value` = option string or `empty` for the blank placeholder | -| Collection container | `collection-field` | Rendered inside a `propertyCollection` field | +| Collection container | `collection-field` | Rendered inside a `collection` field | | Collection item | `collection-item-{index}` | Zero-based array index | | Collection item toggle | `collection-item-{index}-toggle` | Expand / collapse header | | Collection item remove | `collection-item-{index}-remove` | Trash icon | diff --git a/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.html b/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.html index 3b5ade5..abc8376 100644 --- a/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.html +++ b/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.html @@ -11,10 +11,10 @@ [required]="field.required" >{{ field.label }} - @if (field.propertyCollection?.length) { + @if (field.collection?.length) { { for (const field of fields) { const existingControl = existingControls[field.name]; - const wantsCollection = !!field.propertyCollection?.length; + const wantsCollection = !!field.collection?.length; const existingIsCollection = existingControl instanceof FormArray; if (existingControl && wantsCollection !== existingIsCollection) { this.form.removeControl(field.name); @@ -200,7 +200,7 @@ export class DeclarativeForm { const seeds: Record[]> = {}; for (const field of this.fields()) { - if (!field.propertyCollection?.length) continue; + if (!field.collection?.length) continue; const raw = (initialValues as Record)[field.name]; const entries = Array.isArray(raw) ? (raw as Record[]) diff --git a/projects/ngx/declarative-ui/form/declarative-form/form-collection-field/form-collection-field.component.ts b/projects/ngx/declarative-ui/form/declarative-form/form-collection-field/form-collection-field.component.ts index 8f9f828..67fb6e3 100644 --- a/projects/ngx/declarative-ui/form/declarative-form/form-collection-field/form-collection-field.component.ts +++ b/projects/ngx/declarative-ui/form/declarative-form/form-collection-field/form-collection-field.component.ts @@ -4,10 +4,11 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation, + effect, forwardRef, input, - linkedSignal, output, + signal, } from '@angular/core'; import { Button } from '@fundamental-ngx/ui5-webcomponents/button'; import { Icon } from '@fundamental-ngx/ui5-webcomponents/icon'; @@ -18,7 +19,7 @@ import '@ui5/webcomponents-icons/dist/navigation-right-arrow.js'; /** * Internal collection editor rendered by `mfp-declarative-form` when a field - * carries `propertyCollection` sub-fields. + * carries `collection` sub-fields. * * Each entry renders as a collapsible card whose expanded body is a nested * `` bound to that entry. Live changes flow up @@ -45,18 +46,16 @@ export class FormCollectionField { readonly initialEntries = input[]>([]); readonly valueChange = output[]>(); + protected readonly entries = signal[]>([]); + protected readonly expandedIndex = signal(null); - protected readonly entries = linkedSignal[]>(() => [ - ...(this.initialEntries() ?? []), - ]); - - protected readonly expandedIndex = linkedSignal< - Record[], - number | null - >({ - source: this.initialEntries, - computation: () => null, - }); + constructor() { + effect(() => { + const initial = this.initialEntries(); + this.entries.set([...(initial ?? [])]); + this.expandedIndex.set(null); + }); + } previewFor(entry: Record, index: number): string { for (const field of this.fields()) { @@ -83,12 +82,14 @@ export class FormCollectionField { remove(index: number): void { const next = this.entries().filter((_, i) => i !== index); this.entries.set(next); - const expandedIndex = this.expandedIndex() as number; - if (expandedIndex === index) { + if (this.expandedIndex() === index) { this.expandedIndex.set(null); - } else if (expandedIndex !== null && expandedIndex > index) { + } else if ( + this.expandedIndex() !== null && + (this.expandedIndex() as number) > index + ) { // Preserve the same expanded row after removing an earlier one. - this.expandedIndex.set(expandedIndex - 1); + this.expandedIndex.set((this.expandedIndex() as number) - 1); } this.valueChange.emit(next); } diff --git a/projects/ngx/declarative-ui/form/models/form-field-definition.ts b/projects/ngx/declarative-ui/form/models/form-field-definition.ts index ff5c901..31f4a44 100644 --- a/projects/ngx/declarative-ui/form/models/form-field-definition.ts +++ b/projects/ngx/declarative-ui/form/models/form-field-definition.ts @@ -16,16 +16,15 @@ export interface FormFieldDefinition { * Nested field definitions describing one item of an object collection. * * When set, this field represents an array of objects; the form renders it - * as a stack of expandable/collapsible cards. Each card is a nested - * `mfp-declarative-form` whose `fields` are the entries in - * `propertyCollection`; an inline Add button appends entries and a trash - * button removes them. Editing is live — no per-card Save/Cancel. + * as a stack of expandable/collapsible cards with Add / Edit / Remove + * controls. Each Add / Edit opens a nested `mfp-declarative-form` inline + * whose `fields` are the entries in `collection`. * * The submitted value at this field's path is `Array>`, * with each entry keyed by the sub-fields' `name`s. When `required` is * `true`, the host is expected to require at least one entry in the array. */ - propertyCollection?: FormFieldDefinition[]; + collection?: FormFieldDefinition[]; } /** Event payload emitted each time a single form field value changes. */ diff --git a/projects/ngx/declarative-ui/models/ui-definition.ts b/projects/ngx/declarative-ui/models/ui-definition.ts index 88d410f..41f4014 100644 --- a/projects/ngx/declarative-ui/models/ui-definition.ts +++ b/projects/ngx/declarative-ui/models/ui-definition.ts @@ -141,7 +141,7 @@ export interface ResourceFieldButtonClickEvent { export interface FieldDefinition { /** Column header / form label. */ label?: string; - /** Dot-separated path to the resource property (e.g. `metadata.name`). For a collection field this is the path to the array itself (e.g. `status.conditions`). */ + /** Dot-separated path to the resource property (e.g. `metadata.name`). */ property?: string | string[]; /** Alternative path resolver with optional transforms. */ propertyField?: PropertyField; @@ -151,10 +151,7 @@ export interface FieldDefinition { value?: string; /** Display and interaction configuration for this cell. */ uiSettings?: UiSettings; - /** - * Sub-field definitions describing one entry of an array-of-objects field. - * When set, `property` points at the array; each sub-field describes one - * column/input of an entry. Mirrors `propertyField` naming for consistency. - */ - propertyCollection?: FieldDefinition[]; + /** Dot-separated path to the resource collection property (e.g. `status.conditions`). */ + collectionProperty?: string; + collection?: FieldDefinition[]; } diff --git a/projects/ngx/declarative-ui/stories/declarative-form.stories.ts b/projects/ngx/declarative-ui/stories/declarative-form.stories.ts index 9479ee6..2ff8346 100644 --- a/projects/ngx/declarative-ui/stories/declarative-form.stories.ts +++ b/projects/ngx/declarative-ui/stories/declarative-form.stories.ts @@ -14,7 +14,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; // pre-built bundle loaded via `