diff --git a/docs/core/models/form-field.model.md b/docs/core/models/form-field.model.md index 92ad9b1cc5..ed98e18f8d 100644 --- a/docs/core/models/form-field.model.md +++ b/docs/core/models/form-field.model.md @@ -50,6 +50,7 @@ Contains the value and metadata for a field of a [`Form`](../../../lib/process-s | numberOfColumns | number | 1 | Number of columns defined by a container field | | fields | [`FormFieldModel`](../../core/models/form-field.model.md)\[] | \[] | Fields contained within a container field | | columns | [`ContainerColumnModel`](../../../lib/core/src/lib/form/components/widgets/core/container-column.model.ts)\[] | \[] | Column definitions for a container field | +| rows | [`ContainerRowModel`](../../../lib/core/src/lib/form/components/widgets/core/container-row.model.ts)\[] | \[] | Row definitions for a repeatable section field | | emptyOption | [`FormFieldOption`](../../../lib/core/src/lib/form/components/widgets/core/form-field-option.ts) | | Dropdown menu item to use when no option is chosen | | validationSummary | string | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) | @@ -95,6 +96,17 @@ The [REST Call Task 101](https://community.alfresco.com/community/bpm/blog/2016/ tutorial on the [APS community site](https://community.alfresco.com/community/bpm) contains full details about how the REST calls work, along with a worked example. +### Repeatable sections + +Repeatable section fields (`type: 'repeatable-section'`) support an `initialNumberOfRows` parameter in `params`. +Setting `initialNumberOfRows` to `0` is valid: the section starts with no runtime rows and users add rows via the **Add row** action. + +When no rows exist, `columns` holds a design-time column template (used by form editors and form-rules). +Template fields are bound to the form for rendering but are marked as templates and do not write to `form.values`. +No row data is written to `form.values` until the user adds a row or saved values are loaded from the server. + +If saved values are present on the field (`value` array), row count is derived from `value.length` (subject to `maxNumberOfRows`), regardless of `initialNumberOfRows`. + ## See also - [Extensibility](../../user-guide/extensibility.md) diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts index a1a7f523c8..0e4c65e9b4 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts @@ -1542,6 +1542,103 @@ describe('FormFieldModel', () => { field = new FormFieldModel(form, json); }); + describe('zero initial rows', () => { + const zeroInitialRowsJson = { + ...json, + params: { + ...json.params, + initialNumberOfRows: 0 + } + }; + + it('should create column template without rows when initialNumberOfRows is 0', () => { + const freshForm = new FormModel(); + const zeroInitialRowsField = new FormFieldModel(freshForm, zeroInitialRowsJson); + + expect(zeroInitialRowsField.rows.length).toBe(0); + expect(zeroInitialRowsField.columns.length).toBe(2); + + const templateFieldIds = zeroInitialRowsField.columns.flatMap((column) => column.fields.map((templateField) => templateField.id)); + expect(templateFieldIds.some((id) => id.startsWith('Text0wwp7n-Row'))).toBe(true); + expect(templateFieldIds.some((id) => id.startsWith('Integer0rzkwq-Row'))).toBe(true); + zeroInitialRowsField.columns + .flatMap((column) => column.fields) + .forEach((templateField) => { + expect(templateField.form).toBe(freshForm); + expect(templateField.parent?.isTemplate).toBe(true); + }); + + expect(freshForm.values[json.id]).toBeUndefined(); + }); + + it('should not create rows after reload when initialNumberOfRows is 0 and no values were saved', () => { + const freshForm = new FormModel(); + new FormFieldModel(freshForm, zeroInitialRowsJson); + + expect(freshForm.values[json.id]).toBeUndefined(); + + const reloadedField = new FormFieldModel(freshForm, { + ...zeroInitialRowsJson, + value: freshForm.values[json.id] + }); + + expect(reloadedField.rows.length).toBe(0); + expect(freshForm.values[json.id]).toBeUndefined(); + }); + + it('should create rows from saved values when initialNumberOfRows is 0', () => { + const savedValuesJson = { + ...zeroInitialRowsJson, + value: [ + { + Text0wwp7n: 'saved text', + Dropdown0e7tn4: null, + Integer0rzkwq: 42, + Dropdown0wgm63: null + } + ] + }; + + const freshForm = new FormModel(); + const fieldWithSavedValues = new FormFieldModel(freshForm, savedValuesJson); + + expect(fieldWithSavedValues.rows.length).toBe(1); + expect(fieldWithSavedValues.rows[0].columns[0].fields[0].value).toBe('saved text'); + }); + + it('should reuse column template on subsequent updateForm calls when initialNumberOfRows is 0', () => { + const freshForm = new FormModel(); + const zeroInitialRowsField = new FormFieldModel(freshForm, zeroInitialRowsJson); + const templateColumns = zeroInitialRowsField.columns; + + zeroInitialRowsField.updateForm(); + + expect(zeroInitialRowsField.columns).toBe(templateColumns); + }); + + it('should apply read-only state to column template when initialNumberOfRows is 0', () => { + const freshForm = new FormModel(); + const zeroInitialRowsField = new FormFieldModel(freshForm, zeroInitialRowsJson); + + zeroInitialRowsField.readOnly = true; + + zeroInitialRowsField.columns + .flatMap((column) => column.fields) + .forEach((templateField) => { + expect(templateField.readOnly).toBe(true); + }); + }); + + it('should add first row when initialNumberOfRows is 0', () => { + const zeroInitialRowsField = new FormFieldModel(form, zeroInitialRowsJson); + + zeroInitialRowsField.addRow(zeroInitialRowsField.fields, form); + + expect(zeroInitialRowsField.rows.length).toBe(1); + expect(zeroInitialRowsField.rows[0].columns[0].fields[0].id).toContain('Text0wwp7n-Row'); + }); + }); + describe('add row', () => { const assignFormRulesEventSubject = (): Subject => { const formRulesEvent = new Subject(); diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index 85b3d19757..0603a7c272 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -30,7 +30,7 @@ import { DataColumn } from '../../../../datatable/data/data-column.model'; import { DateFnsUtils } from '../../../../common'; import { isValid as isValidDate } from 'date-fns'; import { ContainerRowModel } from './container-row.model'; -import { RepeatableSectionModel, ROW_ID_PREFIX } from './repeatable-section.model'; +import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model'; import { formFieldRuleHandler } from './handlers/form-field-rule.handler'; import { formFieldVisibilityConditionHandler } from './handlers/form-field-visibility-condition.handler'; @@ -351,7 +351,12 @@ export class FormFieldModel extends FormWidgetModel { this.rows.push(this.createRow(fields, form, i, value?.[i], i < params?.initialNumberOfRows)); } - this.columns = this.rows[0].columns; + if (this.rows.length > 0) { + this.columns = this.rows[0].columns; + } else if (this.columns.length === 0) { + // Design-time column template for Studio and form-rules; isTemplate suppresses form.values writes. + this.columns = this.createColumns(fields, form, TEMPLATE_ROW_ID, 0, undefined, true); + } } private getNumberOfRows(initialNrRows: number = 1, maxNrRows: number | null = null, value?: any) { @@ -366,7 +371,7 @@ export class FormFieldModel extends FormWidgetModel { return row; } - private createColumns(fields: any, form: any, rowId: string, index?: number, value?: any) { + private createColumns(fields: any, form: any, rowId: string, index?: number, value?: any, isTemplate: boolean = false) { const columns: ContainerColumnModel[] = []; Object.keys(fields).forEach((currentField) => { @@ -382,7 +387,8 @@ export class FormFieldModel extends FormWidgetModel { uid: this.getUniqueId(field, rowId), fields: this.fields, rowIndex: index ?? 0, - value: field.type === FormFieldTypes.SECTION ? value : value?.[field.id] + value: field.type === FormFieldTypes.SECTION ? value : value?.[field.id], + isTemplate }) ); col.rowspan = fields[currentField].length; @@ -399,15 +405,24 @@ export class FormFieldModel extends FormWidgetModel { } private updateRepeatableSectionReadOnlyState(state: boolean) { - for (const row of this.rows) { - for (const column of row.columns) { - for (const field of column.fields) { - if (field.type === FormFieldTypes.SECTION) { - this.updateInnerSectionReadOnlyState(field, state); - } + if (this.rows.length > 0) { + for (const row of this.rows) { + this.updateRepeatableSectionColumnsReadOnlyState(row.columns, state); + } + return; + } - field.readOnly = this.getRepeatableSectionFieldReadOnlyState(field, state); + this.updateRepeatableSectionColumnsReadOnlyState(this.columns, state); + } + + private updateRepeatableSectionColumnsReadOnlyState(columns: ContainerColumnModel[], state: boolean) { + for (const column of columns) { + for (const field of column.fields) { + if (field.type === FormFieldTypes.SECTION) { + this.updateInnerSectionReadOnlyState(field, state); } + + field.readOnly = this.getRepeatableSectionFieldReadOnlyState(field, state); } } } @@ -594,7 +609,7 @@ export class FormFieldModel extends FormWidgetModel { } updateForm() { - if (!this.form) { + if (!this.form || this.parent?.isTemplate) { return; } diff --git a/lib/core/src/lib/form/components/widgets/core/repeatable-section.model.ts b/lib/core/src/lib/form/components/widgets/core/repeatable-section.model.ts index d80e8399fa..49a1efd89a 100644 --- a/lib/core/src/lib/form/components/widgets/core/repeatable-section.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/repeatable-section.model.ts @@ -17,10 +17,15 @@ export const ROW_ID_PREFIX = '-Row'; +/** Placeholder row id for design-time column templates when no rows exist (not used for runtime rows). */ +export const TEMPLATE_ROW_ID = '0'; + export interface RepeatableSectionModel { id: string; uid: string; fields: any; rowIndex: number; value?: any; + /** When true, field is a design-time column template and must not write to form.values. */ + isTemplate?: boolean; }