AAE-39559 Update to handle 0 initial rows for repeatable sections (#12113)

* AAE-39559 Update to handle 0 initial rows for repeatable sections

* address code review comments
This commit is contained in:
Darren Thornton
2026-08-04 10:53:19 -05:00
committed by GitHub
parent 3d01611249
commit 72522c0f72
4 changed files with 141 additions and 12 deletions
+12
View File
@@ -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)
@@ -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<FormRulesEvent> => {
const formRulesEvent = new Subject<FormRulesEvent>();
@@ -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;
}
@@ -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;
}