From f7d26d904f0e4ffe9846bb1504639a24373fd18e Mon Sep 17 00:00:00 2001
From: Diogo Bastos <50139916+DiogoABastos@users.noreply.github.com>
Date: Tue, 4 Nov 2025 10:52:01 +0000
Subject: [PATCH] AAE-27107 Create repeatable section widget (#11290)
* AAE-27107 Create widget
* AAE-27107 Add tests
* AAE-27107 Add tests
* AAE-27107 Add tests
* AAE-27107 Fix random
* AAE-27107 Fix repeat widget tests
* AAE-27107 Fix tests
* AAE-27107 Add column id property and allow for initial value
---
.../components/form-renderer.component.html | 49 +++++
.../components/form-renderer.component.scss | 24 +++
.../form-renderer.component.spec.ts | 148 ++++++++++++-
.../components/form-renderer.component.ts | 37 +++-
.../mock/form-renderer.component.mock.ts | 141 +++++++++++++
.../widgets/core/container-column.model.ts | 5 +
.../widgets/core/container-row.model.ts | 33 +++
.../widgets/core/form-field-types.ts | 5 +
.../widgets/core/form-field.model.spec.ts | 145 ++++++++++++-
.../widgets/core/form-field.model.ts | 167 ++++++++++++++-
.../src/lib/form/components/widgets/index.ts | 1 +
.../widgets/repeat/repeat.widget.html | 29 +++
.../widgets/repeat/repeat.widget.scss | 25 +++
.../widgets/repeat/repeat.widget.spec.ts | 195 ++++++++++++++++++
.../widgets/repeat/repeat.widget.ts | 45 ++++
lib/core/src/lib/i18n/en.json | 15 +-
16 files changed, 1050 insertions(+), 14 deletions(-)
create mode 100644 lib/core/src/lib/form/components/widgets/core/container-row.model.ts
create mode 100644 lib/core/src/lib/form/components/widgets/repeat/repeat.widget.html
create mode 100644 lib/core/src/lib/form/components/widgets/repeat/repeat.widget.scss
create mode 100644 lib/core/src/lib/form/components/widgets/repeat/repeat.widget.spec.ts
create mode 100644 lib/core/src/lib/form/components/widgets/repeat/repeat.widget.ts
diff --git a/lib/core/src/lib/form/components/form-renderer.component.html b/lib/core/src/lib/form/components/form-renderer.component.html
index 16577f134d..40e8a1502b 100644
--- a/lib/core/src/lib/form/components/form-renderer.component.html
+++ b/lib/core/src/lib/form/components/form-renderer.component.html
@@ -69,6 +69,55 @@
+ @if (currentRootElement.type === 'repeatable-section') {
+
+ }
+
diff --git a/lib/core/src/lib/form/components/form-renderer.component.scss b/lib/core/src/lib/form/components/form-renderer.component.scss
index b283847a1a..5036a5d4ed 100644
--- a/lib/core/src/lib/form/components/form-renderer.component.scss
+++ b/lib/core/src/lib/form/components/form-renderer.component.scss
@@ -97,6 +97,30 @@
padding-left: 3px;
padding-right: 3px;
}
+
+ &-remove-row-button {
+ margin-top: 20px;
+
+ #{ms.$mat-icon} {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 20px;
+ }
+ }
+
+ &-container {
+ padding: 0 10px;
+
+ &-label {
+ margin: 5px 0 5px -10px;
+ }
+
+ &-multiple {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.54);
+ margin-bottom: 25px;
+ }
+ }
}
@include flex.layout-bp(lt-md) {
diff --git a/lib/core/src/lib/form/components/form-renderer.component.spec.ts b/lib/core/src/lib/form/components/form-renderer.component.spec.ts
index be3f436bec..929b5b9bfd 100644
--- a/lib/core/src/lib/form/components/form-renderer.component.spec.ts
+++ b/lib/core/src/lib/form/components/form-renderer.component.spec.ts
@@ -39,6 +39,8 @@ import {
formNumberWidgetVisibility,
formRequiredNumberWidget,
mockSectionVisibilityForm,
+ mockRepeatableSectionForm01,
+ mockRepeatableSectionForm02,
multilineWidgetFormVisibilityMock,
numberMinMaxForm,
numberNotRequiredForm,
@@ -46,7 +48,9 @@ import {
radioWidgetVisibilityForm,
textWidgetVisibility
} from './mock/form-renderer.component.mock';
-import { FormModel, TextWidgetComponent } from './widgets';
+import { FormFieldModel, FormModel, TextWidgetComponent } from './widgets';
+import { MatDialog } from '@angular/material/dialog';
+import { of } from 'rxjs';
const typeIntoInput = (testingUtils: UnitTestingUtils, selector: string, message: string) => {
testingUtils.fillInputByCSS(selector, message);
@@ -87,6 +91,7 @@ describe('Form Renderer Component', () => {
let formRenderingService: FormRenderingService;
let rulesManager: FormRulesManager;
let testingUtils: UnitTestingUtils;
+ let dialog: MatDialog;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -96,6 +101,7 @@ describe('Form Renderer Component', () => {
formRendererComponent = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
formService = TestBed.inject(FormService);
+ dialog = TestBed.inject(MatDialog);
formRenderingService = TestBed.inject(FormRenderingService);
rulesManager = fixture.debugElement.injector.get(FormRulesManager);
});
@@ -799,4 +805,144 @@ describe('Form Renderer Component', () => {
expectElementToBeVisible(testingUtils, mockSectionFieldId);
});
});
+
+ describe('Repeatable section', () => {
+ const repeatableSectionField = new FormFieldModel(new FormModel(), {
+ id: 'RepeatableSection0tbw2y',
+ name: 'Repeatable Section',
+ type: 'repeatable-section',
+ tab: null,
+ params: {
+ initialNumberOfRows: 2,
+ allowInitialRowsDelete: true,
+ newRowsLimit: 1
+ },
+ numberOfColumns: 2,
+ fields: {
+ '1': [
+ {
+ id: 'Text0wwp7n',
+ name: 'Text',
+ type: 'text',
+ readOnly: false,
+ required: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minLength: 0,
+ maxLength: 0,
+ regexPattern: null,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ],
+ '2': [
+ {
+ id: 'Integer0rzkwq',
+ name: 'Integer',
+ type: 'integer',
+ readOnly: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minValue: null,
+ maxValue: null,
+ required: false,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ]
+ }
+ });
+
+ beforeEach(() => {
+ const formDefinition = new FormModel(mockRepeatableSectionForm01.formRepresentation);
+ fixture.componentInstance.formDefinition = formDefinition;
+
+ fixture.detectChanges();
+ });
+
+ it('should display repeatable-section field', () => {
+ const container = testingUtils.getByCSS('#field-RepeatableSection0tbw2y-container');
+
+ expect(container).toBeTruthy();
+ });
+
+ it('should remove row if confimation dialog is true', () => {
+ spyOn(dialog, 'open').and.returnValue({
+ beforeClosed: () => of(true)
+ } as any);
+
+ spyOn(repeatableSectionField, 'removeRow').and.callThrough();
+
+ const rowIndex = 0;
+
+ fixture.detectChanges();
+
+ fixture.componentInstance.displayDialogToRemoveRow(repeatableSectionField, rowIndex);
+
+ expect(dialog.open).toHaveBeenCalled();
+ expect(repeatableSectionField.removeRow).toHaveBeenCalledWith(rowIndex);
+ });
+
+ it('should NOT remove row if confirmation dialog is false', () => {
+ spyOn(dialog, 'open').and.returnValue({
+ beforeClosed: () => of(false)
+ } as any);
+
+ spyOn(repeatableSectionField, 'removeRow').and.callThrough();
+
+ const rowIndex = 0;
+
+ fixture.detectChanges();
+
+ fixture.componentInstance.displayDialogToRemoveRow(repeatableSectionField, rowIndex);
+
+ expect(dialog.open).toHaveBeenCalled();
+ expect(repeatableSectionField.removeRow).not.toHaveBeenCalled();
+ });
+
+ it('should display the correct number of initial rows', () => {
+ const rows = testingUtils.getAllByCSS('#field-RepeatableSection0tbw2y-container .adf-grid-list-container');
+
+ expect(rows.length).toBeTruthy(2);
+ });
+
+ describe('remove row button', () => {
+ it('should display remove button if allowed', () => {
+ const row = testingUtils.getByCSS('#field-RepeatableSection0tbw2y-container .adf-grid-list-container');
+
+ expect(row).toBeTruthy();
+
+ const removeRowButton = testingUtils.getByCSS(
+ '#field-RepeatableSection0tbw2y-container .adf-grid-list-container .adf-grid-list-remove-row-button'
+ );
+
+ expect(removeRowButton).toBeTruthy();
+ });
+
+ it('should NOT display remove button if NOT allowed', () => {
+ const formDefinition = new FormModel(mockRepeatableSectionForm02.formRepresentation);
+ fixture.componentInstance.formDefinition = formDefinition;
+
+ fixture.detectChanges();
+
+ const row = testingUtils.getByCSS('#field-RepeatableSection0tbw2y-container .adf-grid-list-container');
+
+ expect(row).toBeTruthy();
+
+ const removeRowButton = testingUtils.getByCSS(
+ '#field-RepeatableSection0tbw2y-container .adf-grid-list-container .adf-grid-list-remove-row-button'
+ );
+
+ expect(removeRowButton).toBeFalsy();
+ });
+ });
+ });
});
diff --git a/lib/core/src/lib/form/components/form-renderer.component.ts b/lib/core/src/lib/form/components/form-renderer.component.ts
index 944ec39507..8781c1d3a2 100644
--- a/lib/core/src/lib/form/components/form-renderer.component.ts
+++ b/lib/core/src/lib/form/components/form-renderer.component.ts
@@ -16,7 +16,7 @@
*/
import { NgClass, NgForOf, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
-import { Component, Inject, Injector, Input, OnDestroy, OnInit, Optional, ViewEncapsulation } from '@angular/core';
+import { ChangeDetectorRef, Component, Inject, inject, Injector, Input, OnDestroy, OnInit, Optional, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -26,10 +26,12 @@ import { FormRulesManager, formRulesManagerFactory } from '../models/form-rules.
import { FormService } from '../services/form.service';
import { FormFieldComponent } from './form-field/form-field.component';
import { FORM_FIELD_MODEL_RENDER_MIDDLEWARE, FormFieldModelRenderMiddleware } from './middlewares/middleware';
-import { ContainerModel, FormFieldModel, FormModel, TabModel } from './widgets';
+import { ContainerModel, FormFieldModel, FormModel, TabModel, RepeatWidgetComponent } from './widgets';
import { HeaderWidgetComponent } from './widgets/header/header.widget';
import { FormSectionComponent } from './form-section/form-section.component';
import { DecimalRenderMiddlewareService } from './middlewares/decimal-middleware.service';
+import { MatDialog } from '@angular/material/dialog';
+import { ConfirmDialogComponent } from '../../../lib/dialogs/confirm-dialog/confirm.dialog';
@Component({
selector: 'adf-form-renderer',
@@ -60,11 +62,17 @@ import { DecimalRenderMiddlewareService } from './middlewares/decimal-middleware
FormsModule,
NgClass,
HeaderWidgetComponent,
- FormSectionComponent
+ FormSectionComponent,
+ RepeatWidgetComponent
],
encapsulation: ViewEncapsulation.None
})
export class FormRendererComponent implements OnInit, OnDestroy {
+ public readonly formService = inject(FormService);
+ private readonly formRulesManager = inject(FormRulesManager);
+ private readonly dialog = inject(MatDialog);
+ private cdr = inject(ChangeDetectorRef);
+
@Input({ required: true })
formDefinition: FormModel;
@@ -76,8 +84,6 @@ export class FormRendererComponent implements OnInit, OnDestroy {
fields: FormFieldModel[];
constructor(
- public formService: FormService,
- private formRulesManager: FormRulesManager,
@Optional()
@Inject(FORM_FIELD_MODEL_RENDER_MIDDLEWARE)
private middlewareServices?: FormFieldModelRenderMiddleware[]
@@ -145,6 +151,27 @@ export class FormRendererComponent implements OnInit, OnDestroy {
return maxFieldSize;
}
+ displayDialogToRemoveRow(field: FormFieldModel, rowIndex: number) {
+ this.dialog
+ .open(ConfirmDialogComponent, {
+ data: {
+ title: 'FORM.FORM_RENDERER.REMOVE_ROW_DIALOG.TITLE',
+ message: 'FORM.FORM_RENDERER.REMOVE_ROW_DIALOG.MESSAGE',
+ yesLabel: 'FORM.FORM_RENDERER.REMOVE_ROW_DIALOG.YES_LABEL',
+ noLabel: 'FORM.FORM_RENDERER.REMOVE_ROW_DIALOG.NO_LABEL'
+ },
+ minWidth: '500px',
+ closeOnNavigation: true
+ })
+ .beforeClosed()
+ .subscribe((shouldRemove) => {
+ if (shouldRemove) {
+ field.removeRow(rowIndex);
+ this.cdr.detectChanges();
+ }
+ });
+ }
+
/**
* Calculate the column width based on the numberOfColumns and current field's colspan property
*
diff --git a/lib/core/src/lib/form/components/mock/form-renderer.component.mock.ts b/lib/core/src/lib/form/components/mock/form-renderer.component.mock.ts
index 9e9f3bce41..58f5e60509 100644
--- a/lib/core/src/lib/form/components/mock/form-renderer.component.mock.ts
+++ b/lib/core/src/lib/form/components/mock/form-renderer.component.mock.ts
@@ -2314,6 +2314,147 @@ export const mockSectionVisibilityForm = {
variables: []
}
};
+export const mockRepeatableSectionForm01 = {
+ formRepresentation: {
+ id: 'form-c3ac7a6b-88c8-4111-9f04-e9cc28f352d9',
+ name: 'repeatable-section-mock',
+ key: 'repeatable-section-mock-rthbq',
+ description: '',
+ version: 0,
+ formDefinition: {
+ tabs: [],
+ fields: [
+ {
+ id: 'RepeatableSection0tbw2y',
+ name: 'Repeatable Section',
+ type: 'repeatable-section',
+ tab: null,
+ params: {
+ initialNumberOfRows: 2,
+ allowInitialRowsDelete: true,
+ newRowsLimit: 3
+ },
+ numberOfColumns: 2,
+ fields: {
+ '1': [
+ {
+ id: 'Text0wwp7n',
+ name: 'Text',
+ type: 'text',
+ readOnly: false,
+ required: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minLength: 0,
+ maxLength: 0,
+ regexPattern: null,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ],
+ '2': [
+ {
+ id: 'Integer0rzkwq',
+ name: 'Integer',
+ type: 'integer',
+ readOnly: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minValue: null,
+ maxValue: null,
+ required: false,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ]
+ }
+ }
+ ],
+ outcomes: [],
+ metadata: {},
+ variables: []
+ }
+ }
+};
+
+export const mockRepeatableSectionForm02 = {
+ formRepresentation: {
+ id: 'form-c3ac7a6b-88c8-4111-9f04-e9cc28f352d9',
+ name: 'repeatable-section-mock',
+ key: 'repeatable-section-mock-rthbq',
+ description: '',
+ version: 0,
+ formDefinition: {
+ tabs: [],
+ fields: [
+ {
+ id: 'RepeatableSection0tbw2y',
+ name: 'Repeatable Section',
+ type: 'repeatable-section',
+ tab: null,
+ params: {
+ initialNumberOfRows: 2,
+ allowInitialRowsDelete: false,
+ newRowsLimit: 3
+ },
+ numberOfColumns: 2,
+ fields: {
+ '1': [
+ {
+ id: 'Text0wwp7n',
+ name: 'Text',
+ type: 'text',
+ readOnly: false,
+ required: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minLength: 0,
+ maxLength: 0,
+ regexPattern: null,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ],
+ '2': [
+ {
+ id: 'Integer0rzkwq',
+ name: 'Integer',
+ type: 'integer',
+ readOnly: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minValue: null,
+ maxValue: null,
+ required: false,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ]
+ }
+ }
+ ],
+ outcomes: [],
+ metadata: {},
+ variables: []
+ }
+ }
+};
export const displayDynamicTableMock = {
id: 1,
diff --git a/lib/core/src/lib/form/components/widgets/core/container-column.model.ts b/lib/core/src/lib/form/components/widgets/core/container-column.model.ts
index a301041641..499a6bb664 100644
--- a/lib/core/src/lib/form/components/widgets/core/container-column.model.ts
+++ b/lib/core/src/lib/form/components/widgets/core/container-column.model.ts
@@ -18,11 +18,16 @@
import { FormFieldModel } from './form-field.model';
export class ContainerColumnModel {
+ id: string;
size: number = 12;
fields: FormFieldModel[] = [];
colspan: number = 1;
rowspan: number = 1;
+ constructor() {
+ this.id = window.crypto.getRandomValues(new Uint32Array(1))[0].toString();
+ }
+
hasFields(): boolean {
return !!this.fields?.length;
}
diff --git a/lib/core/src/lib/form/components/widgets/core/container-row.model.ts b/lib/core/src/lib/form/components/widgets/core/container-row.model.ts
new file mode 100644
index 0000000000..b01c6b9d77
--- /dev/null
+++ b/lib/core/src/lib/form/components/widgets/core/container-row.model.ts
@@ -0,0 +1,33 @@
+/*!
+ * @license
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ContainerColumnModel } from './container-column.model';
+
+export class ContainerRowModel {
+ id: string;
+ isInitial: boolean;
+ columns: ContainerColumnModel[] = [];
+
+ size: number = 12;
+ colspan: number = 1;
+ rowspan: number = 1;
+
+ constructor(isInitial: boolean = false) {
+ this.isInitial = isInitial;
+ this.id = window.crypto.getRandomValues(new Uint32Array(1))[0].toString();
+ }
+}
diff --git a/lib/core/src/lib/form/components/widgets/core/form-field-types.ts b/lib/core/src/lib/form/components/widgets/core/form-field-types.ts
index f6a814530b..8b5ae79305 100644
--- a/lib/core/src/lib/form/components/widgets/core/form-field-types.ts
+++ b/lib/core/src/lib/form/components/widgets/core/form-field-types.ts
@@ -22,6 +22,7 @@ import { MaybeReactiveFormWidget, ReactiveFormWidget } from '../reactive-widget.
export class FormFieldTypes {
static CONTAINER: string = 'container';
static GROUP: string = 'group';
+ static REPEATABLE_SECTION: string = 'repeatable-section';
static SECTION: string = 'section';
static DYNAMIC_TABLE: string = 'dynamic-table';
static TEXT: string = 'text';
@@ -88,4 +89,8 @@ export class FormFieldTypes {
static isSectionType(type: string): boolean {
return type === FormFieldTypes.SECTION;
}
+
+ static isRepeatableSectionType(type: string): boolean {
+ return type === FormFieldTypes.REPEATABLE_SECTION;
+ }
}
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 1293dc4a4a..d769168a99 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
@@ -852,7 +852,8 @@ describe('FormFieldModel', () => {
FormFieldTypes.READONLY_TYPES.forEach((typeName) => {
const field = new FormFieldModel(form, {
id: typeName,
- type: typeName
+ type: typeName,
+ params: {}
});
field.value = '';
@@ -1266,4 +1267,146 @@ describe('FormFieldModel', () => {
expect(field.tooltip).toBe('');
});
+
+ describe('repeatable section', () => {
+ let form: FormModel;
+ let field: FormFieldModel;
+
+ beforeEach(() => {
+ form = new FormModel();
+ field = new FormFieldModel(form, {
+ id: 'RepeatableSection0tbw2y',
+ name: 'Repeatable Section',
+ type: 'repeatable-section',
+ tab: null,
+ params: {
+ initialNumberOfRows: 2,
+ allowInitialRowsDelete: true,
+ newRowsLimit: 3
+ },
+ numberOfColumns: 2,
+ fields: {
+ '1': [
+ {
+ id: 'Text0wwp7n',
+ name: 'Text',
+ type: 'text',
+ readOnly: false,
+ required: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minLength: 0,
+ maxLength: 0,
+ regexPattern: null,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ],
+ '2': [
+ {
+ id: 'Integer0rzkwq',
+ name: 'Integer',
+ type: 'integer',
+ readOnly: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minValue: null,
+ maxValue: null,
+ required: false,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ]
+ }
+ });
+ });
+
+ describe('add row', () => {
+ it('should add row if allowed by limit param', () => {
+ expect(field.rows.length).toBe(2);
+
+ field.addRow(field.fields, form);
+ expect(field.rows.length).toBe(3);
+ });
+
+ it('should add row if no limit param is defined', () => {
+ field.params.newRowsLimit = null;
+ expect(field.rows.length).toBe(2);
+
+ field.addRow(field.fields, form);
+ expect(field.rows.length).toBe(3);
+ });
+
+ it('should NOT add row if NOT allowed by limit param', () => {
+ expect(field.rows.length).toBe(2);
+
+ field.addRow(field.fields, form);
+ field.addRow(field.fields, form);
+ field.addRow(field.fields, form);
+ expect(field.rows.length).toBe(5);
+
+ field.addRow(field.fields, form);
+ expect(field.rows.length).toBe(5);
+ });
+ });
+
+ describe('remove row', () => {
+ it('should remove row if target index exists', () => {
+ expect(field.rows.length).toBe(2);
+
+ field.removeRow(1);
+ expect(field.rows.length).toBe(1);
+ });
+
+ it('should NOT remove row if target index does NOT exist', () => {
+ expect(field.rows.length).toBe(2);
+
+ field.removeRow(2);
+ expect(field.rows.length).toBe(2);
+ });
+
+ it('should update children fields rowIndex', () => {
+ expect(field.rows[0].columns[0].fields[0].parent.rowIndex).toBe(0);
+ expect(field.rows[1].columns[0].fields[0].parent.rowIndex).toBe(1);
+
+ field.removeRow(0);
+ expect(field.rows[0].columns[0].fields[0].parent.rowIndex).toBe(0);
+ });
+
+ it('should update form value', () => {
+ const formValues = {
+ initialState: [
+ {
+ Text0wwp7n: 'mock-1',
+ Integer0rzkwq: 1
+ },
+ {
+ Text0wwp7n: 'mock-2',
+ Integer0rzkwq: 2
+ }
+ ],
+ removeState: [
+ {
+ Text0wwp7n: 'mock-2',
+ Integer0rzkwq: 2
+ }
+ ]
+ };
+
+ form.values[field.id] = formValues.initialState;
+ expect(form.values[field.id]).toEqual(formValues.initialState);
+
+ field.removeRow(0);
+ expect(form.values[field.id]).toEqual(formValues.removeState);
+ });
+ });
+ });
});
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 13ccf57911..efefa06177 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,11 +30,22 @@ import { VariableConfig } from './form-field-variable-options';
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';
export type FieldOptionType = 'rest' | 'manual' | 'variable';
export type FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal';
+interface RepeatableSectionModel {
+ id: string;
+ uid: string;
+ fields: FormFieldModel[];
+ rowIndex: number;
+ value?: any;
+}
+
+const ROW_ID_PREFIX = '-Row';
+
// Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel {
private _value: string;
@@ -94,11 +105,13 @@ export class FormFieldModel extends FormWidgetModel {
schemaDefinition: DataColumn[];
externalProperty?: string;
style?: string;
+ parent?: RepeatableSectionModel;
// container model members
numberOfColumns: number = 1;
fields: FormFieldModel[] = [];
columns: ContainerColumnModel[] = [];
+ rows: ContainerRowModel[] = [];
// util members
emptyOption: FormFieldOption;
@@ -172,11 +185,11 @@ export class FormFieldModel extends FormWidgetModel {
return !this.readOnly || FormFieldTypes.isValidatableType(this.type);
}
- constructor(form: any, json?: any) {
+ constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
super(form, json);
if (json) {
this.fieldType = json.fieldType;
- this.id = json.id;
+ this.id = parent ? parent.uid : json.id;
this.name = json.name;
this.type = json.type;
this.roles = json.roles;
@@ -221,8 +234,9 @@ export class FormFieldModel extends FormWidgetModel {
this.schemaDefinition = json.schemaDefinition;
this.precision = json.precision;
this.externalProperty = json.externalProperty;
- this._value = this.parseValue(json);
+ this._value = this.parseValue(json, parent?.value);
this.style = json.style;
+ this.parent = parent;
if (json.placeholder && json.placeholder !== '' && json.placeholder !== 'null') {
this.placeholder = json.placeholder;
@@ -237,6 +251,10 @@ export class FormFieldModel extends FormWidgetModel {
if (FormFieldTypes.isContainerType(this.type) || FormFieldTypes.isSectionType(this.type)) {
this.containerFactory(json, form);
}
+
+ if (FormFieldTypes.isRepeatableSectionType(this.type)) {
+ this.repeatableSectionFactory(json, form);
+ }
}
if (form?.json) {
@@ -306,14 +324,123 @@ export class FormFieldModel extends FormWidgetModel {
});
}
+ private repeatableSectionFactory(json: any, form: any): void {
+ const { numberOfColumns = 1, params, value, fields = {} } = json;
+
+ this.numberOfColumns = numberOfColumns;
+ this.fields = fields;
+ this.rowspan = 1;
+ this.colspan = 1;
+ this.rows = [];
+
+ for (let i = 0; i < this.getNumberOfRows(params.initialNumberOfRows, params.newRowsLimit, value); i++) {
+ this.rows.push(this.createRow(fields, form, i, value?.[i], i < params?.initialNumberOfRows));
+ }
+
+ this.columns = this.rows[0].columns;
+ }
+
+ private getNumberOfRows(initialNrRows: number = 1, rowsLimit?: number, value?: any) {
+ return value?.length && !!rowsLimit ? Math.min(value?.length, initialNrRows + rowsLimit) : (value?.length ?? initialNrRows);
+ }
+
+ private createRow(fields: any, form: any, index: number, value?: any, isInitial: boolean = false) {
+ const row = new ContainerRowModel(isInitial);
+
+ row.columns.push(...this.createColumns(fields, form, index, value));
+
+ return row;
+ }
+
+ private createColumns(fields: any, form: any, index?: number, value?: any) {
+ const columns: ContainerColumnModel[] = [];
+
+ Object.keys(fields).forEach((currentField) => {
+ if (!Object.prototype.hasOwnProperty.call(fields, currentField)) {
+ return;
+ }
+
+ const col = new ContainerColumnModel();
+ col.fields = (fields[currentField] || []).map(
+ (field: any) =>
+ new FormFieldModel(form, field, {
+ id: this.id,
+ uid: this.getUniqueId(field),
+ fields: this.fields,
+ rowIndex: index ?? 0,
+ value: value?.[field.id]
+ })
+ );
+ col.rowspan = fields[currentField].length;
+
+ if (!FormFieldTypes.isSectionType(this.type)) {
+ this.updateContainerColspan(col.fields);
+ }
+
+ this.rowspan = Math.max(this.rowspan, col.rowspan);
+ columns.push(col);
+ });
+
+ return columns;
+ }
+
+ private getUniqueId(field: FormFieldModel): string {
+ return field.id + ROW_ID_PREFIX + window.crypto.getRandomValues(new Uint32Array(1))[0].toString();
+ }
+
+ private updateChildrenFieldsRowIndex() {
+ this.rows.forEach((row: ContainerRowModel, index: number) => {
+ for (const column of row.columns) {
+ for (const field of column.fields) {
+ field.parent.rowIndex = index;
+ }
+ }
+ });
+ }
+
+ private createInitialValue(fields: any) {
+ return Object.keys(fields)
+ .map((currentField) => (fields[currentField] || []).map((field) => field.id))
+ .flat(1)
+ .reduce((acc, curr) => ((acc[curr] = null), acc), {});
+ }
+
private updateContainerColspan(fields: FormFieldModel[]): void {
fields.forEach((colField: FormFieldModel) => {
this.colspan = Math.max(this.colspan, colField.colspan);
});
}
- parseValue(json: any): any {
- const value = Object.prototype.hasOwnProperty.call(json, 'value') && json.value !== undefined ? json.value : null;
+ addRow(fields: any, form: any) {
+ if (!this.shouldAddRow()) {
+ return;
+ }
+
+ this.rows.push(this.createRow(fields, form, this.rows.length));
+ }
+
+ private shouldAddRow(): boolean {
+ return !this.params.newRowsLimit || this.rows.length < this.params.initialNumberOfRows + this.params.newRowsLimit;
+ }
+
+ removeRow(index: number) {
+ if (!this.shouldRemoveRow(index)) {
+ return;
+ }
+
+ this.rows.splice(index, 1);
+ this.updateChildrenFieldsRowIndex();
+
+ this.form.values[this.id].splice(index, 1);
+ this.form.onFormFieldChanged(this);
+ }
+
+ private shouldRemoveRow(index: number): boolean {
+ return this.rows.length > index;
+ }
+
+ parseValue(json: any, initialValue?: any): any {
+ const value = initialValue ?? (Object.prototype.hasOwnProperty.call(json, 'value') && json.value !== undefined ? json.value : null);
/*
This is needed due to Activiti issue related to reading dropdown values as value string
@@ -410,6 +537,11 @@ export class FormFieldModel extends FormWidgetModel {
return;
}
+ if (this.parent) {
+ this.updateRepeatableSectionValue();
+ return;
+ }
+
switch (this.type) {
case FormFieldTypes.DROPDOWN: {
if (!this.value) {
@@ -540,6 +672,17 @@ export class FormFieldModel extends FormWidgetModel {
this.form.values[this.id] = this.value ? this.value : null;
break;
}
+ case FormFieldTypes.REPEATABLE_SECTION: {
+ this.form.values[this.id] = this.value ? this.value : [];
+ this.repeatableSectionFactory(
+ {
+ ...this.json,
+ value: this.value
+ },
+ this.form
+ );
+ break;
+ }
default:
if (this.shouldUpdateFormValues(this.type)) {
this.form.values[this.id] = this.value;
@@ -549,6 +692,20 @@ export class FormFieldModel extends FormWidgetModel {
this.form.onFormFieldChanged(this);
}
+ private updateRepeatableSectionValue() {
+ if (!this.form.values[this.parent.id]) {
+ this.form.values[this.parent.id] = [];
+ }
+
+ if (!this.form.values[this.parent.id][this.parent.rowIndex]) {
+ this.form.values[this.parent.id][this.parent.rowIndex] = this.createInitialValue(this.parent.fields);
+ }
+
+ this.form.values[this.parent.id][this.parent.rowIndex][this.id.split(ROW_ID_PREFIX)[0]] = this.value;
+
+ this.form.onFormFieldChanged(this);
+ }
+
/**
* Check if the field type is invalid, requires a type to be a `container`
*
diff --git a/lib/core/src/lib/form/components/widgets/index.ts b/lib/core/src/lib/form/components/widgets/index.ts
index e4f40afe6b..78393c75c3 100644
--- a/lib/core/src/lib/form/components/widgets/index.ts
+++ b/lib/core/src/lib/form/components/widgets/index.ts
@@ -56,6 +56,7 @@ export * from './text/text-mask.component';
// widgets with schema
export * from './display-text';
export * from './header';
+export * from './repeat/repeat.widget';
export const WIDGET_DIRECTIVES = [
UnknownWidgetComponent,
diff --git a/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.html b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.html
new file mode 100644
index 0000000000..1366112f9a
--- /dev/null
+++ b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.html
@@ -0,0 +1,29 @@
+
+
+
+ {{ element.name | translate }}
+
+
+
+
+
+ @if (!isEditor) {
+ @let shouldDisplayAddRowButton = !element.field.params.newRowsLimit || (element.field.params.newRowsLimit > getAddedRowsCount());
+ @if (shouldDisplayAddRowButton) {
+
+ } @else {
+ @let rowLimit = element.field.params.initialNumberOfRows + (element.field.params.newRowsLimit ?? 0);
+ {{ 'FORM.FIELD.REPEATABLE_SECTION.ROW_LIMIT_REACHED' | translate: { limit: rowLimit } }}
+ }
+ }
+
diff --git a/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.scss b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.scss
new file mode 100644
index 0000000000..777d30e7c7
--- /dev/null
+++ b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.scss
@@ -0,0 +1,25 @@
+.adf-container-widget {
+ &-repeat__text {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.87);
+ padding-bottom: 10px;
+ cursor: default;
+ user-select: none;
+ font-size: var(--adf-header-font-size);
+ font-weight: var(--adf-header-font-weight);
+ color: var(--adf-header-color);
+ line-height: normal;
+
+ &.adf-collapsible {
+ cursor: pointer;
+ }
+ }
+
+ &-row-action {
+ margin-left: 10px;
+ }
+
+ &-row-limit {
+ color: rgba(0, 0, 0, 0.67);
+ font-size: 12px;
+ }
+}
diff --git a/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.spec.ts b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.spec.ts
new file mode 100644
index 0000000000..dcbccdfede
--- /dev/null
+++ b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.spec.ts
@@ -0,0 +1,195 @@
+/*!
+ * @license
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ContainerModel } from '../core/container.model';
+import { FormFieldModel } from '../core/form-field.model';
+import { RepeatWidgetComponent } from './repeat.widget';
+import { FormModel } from '../core';
+import { UnitTestingUtils } from '../../../../testing';
+
+describe('RepeatWidgetComponent', () => {
+ let component: RepeatWidgetComponent;
+ let fixture: ComponentFixture;
+ let testingUtils: UnitTestingUtils;
+
+ /**
+ *
+ * @param initialNumberOfRows initial number of rows
+ * @param newRowsLimit limit for additional rows
+ * @param allowInitialRowsDelete should allow deleting rows
+ * @returns repeatable section json based on params
+ */
+ function getFormFieldJson(initialNumberOfRows: number = 2, newRowsLimit?: number, allowInitialRowsDelete: boolean = true) {
+ return {
+ id: 'RepeatableSection0tbw2y',
+ name: 'Repeatable Section',
+ type: 'repeatable-section',
+ tab: null,
+ params: {
+ initialNumberOfRows,
+ allowInitialRowsDelete,
+ newRowsLimit
+ },
+ numberOfColumns: 2,
+ fields: {
+ '1': [
+ {
+ id: 'Text0wwp7n',
+ name: 'Text',
+ type: 'text',
+ readOnly: false,
+ required: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minLength: 0,
+ maxLength: 0,
+ regexPattern: null,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ],
+ '2': [
+ {
+ id: 'Integer0rzkwq',
+ name: 'Integer',
+ type: 'integer',
+ readOnly: false,
+ colspan: 1,
+ rowspan: 1,
+ placeholder: null,
+ minValue: null,
+ maxValue: null,
+ required: false,
+ visibilityCondition: null,
+ params: {
+ existingColspan: 1,
+ maxColspan: 2
+ }
+ }
+ ]
+ }
+ };
+ }
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [RepeatWidgetComponent]
+ });
+
+ fixture = TestBed.createComponent(RepeatWidgetComponent);
+ component = fixture.componentInstance;
+ testingUtils = new UnitTestingUtils(fixture.debugElement);
+ });
+
+ describe('is editor', () => {
+ it('should NOT display add row button or row limit', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson()));
+
+ fixture.detectChanges();
+
+ expect(testingUtils.getByCSS('button.adf-container-widget-row-action')).toBeFalsy();
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action')).toBeFalsy();
+ });
+ });
+
+ describe('is NOT editor', () => {
+ beforeEach(() => {
+ component.isEditor = false;
+ });
+
+ it('should display add row button if no limit is defined', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson()));
+
+ fixture.detectChanges();
+
+ expect(testingUtils.getByCSS('button.adf-container-widget-row-action')).toBeTruthy();
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action')).toBeFalsy();
+ });
+
+ it('should display add row button if limit is defined but not reached', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson(2, 1)));
+
+ fixture.detectChanges();
+
+ expect(testingUtils.getByCSS('button.adf-container-widget-row-action')).toBeTruthy();
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action')).toBeFalsy();
+ });
+
+ it('should NOT display add row button if limit is defined and reached', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson(2, 1)));
+ spyOn(component, 'getAddedRowsCount').and.returnValue(1);
+
+ fixture.detectChanges();
+
+ expect(testingUtils.getByCSS('button.adf-container-widget-row-action')).toBeFalsy();
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action')).toBeTruthy();
+ });
+
+ it('should display row limit if limit has been reached', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson(2, 1)));
+ spyOn(component, 'getAddedRowsCount').and.returnValue(1);
+
+ fixture.detectChanges();
+
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action')).toBeTruthy();
+ expect(testingUtils.getByCSS('span.adf-container-widget-row-action').nativeElement.textContent.trim()).toBe(
+ 'FORM.FIELD.REPEATABLE_SECTION.ROW_LIMIT_REACHED'
+ );
+ });
+
+ it('should add row when add row button is clicked', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson()));
+ spyOn(component, 'addRow').and.callThrough();
+
+ fixture.detectChanges();
+
+ testingUtils.clickByCSS('button.adf-container-widget-row-action');
+ expect(component.addRow).toHaveBeenCalled();
+ });
+
+ describe('getAddedRowsCount', () => {
+ it('should get correct rows count if initial rows are allowed to be deleted', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson(2, 1)));
+
+ fixture.detectChanges();
+
+ expect(component.getAddedRowsCount()).toBe(0);
+
+ component.addRow();
+
+ fixture.detectChanges();
+
+ expect(component.getAddedRowsCount()).toBe(1);
+ });
+
+ it('should get correct rows count if initial rows are NOT allowed to be deleted', () => {
+ component.element = new ContainerModel(new FormFieldModel(new FormModel(), getFormFieldJson(2, 1, false)));
+
+ expect(component.getAddedRowsCount()).toBe(0);
+
+ component.addRow();
+
+ expect(component.getAddedRowsCount()).toBe(1);
+ });
+ });
+ });
+});
diff --git a/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.ts b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.ts
new file mode 100644
index 0000000000..c30159ce19
--- /dev/null
+++ b/lib/core/src/lib/form/components/widgets/repeat/repeat.widget.ts
@@ -0,0 +1,45 @@
+/*!
+ * @license
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, Input, ViewEncapsulation } from '@angular/core';
+import { FieldStylePipe } from '../../../pipes/field-style.pipe';
+import { MatIconModule } from '@angular/material/icon';
+import { TranslatePipe } from '@ngx-translate/core';
+import { MatButtonModule } from '@angular/material/button';
+import { ContainerModel } from '../core/container.model';
+
+@Component({
+ selector: 'adf-repeat-widget',
+ templateUrl: './repeat.widget.html',
+ styleUrls: ['./repeat.widget.scss'],
+ encapsulation: ViewEncapsulation.None,
+ imports: [FieldStylePipe, MatIconModule, MatButtonModule, TranslatePipe]
+})
+export class RepeatWidgetComponent {
+ @Input() element: ContainerModel;
+ @Input() isEditor: boolean = true;
+
+ addRow() {
+ this.element.field.addRow(this.element.json.fields, this.element.form);
+ }
+
+ getAddedRowsCount(): number {
+ return this.element.json.params.allowInitialRowsDelete
+ ? this.element.field.rows.length - this.element.json.params.initialNumberOfRows
+ : this.element.field.rows.filter((row) => !row.isInitial).length;
+ }
+}
diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json
index 5bc5f441e8..6b2a221f2f 100644
--- a/lib/core/src/lib/i18n/en.json
+++ b/lib/core/src/lib/i18n/en.json
@@ -69,10 +69,21 @@
"NO_LONGER_THAN": "Enter no more than {{ maxLength }} characters"
},
"FILE_ALREADY_UPLOADED": "A file with the same name is already uploaded.",
- "ATTACH": "Attach"
+ "ATTACH": "Attach",
+ "REPEATABLE_SECTION": {
+ "ADD_ROW": "add",
+ "ROW_LIMIT_REACHED": "Rows limit ({{ limit }}) reached."
+ }
},
"FORM_RENDERER": {
- "NAMELESS_TASK": "Nameless task"
+ "NAMELESS_TASK": "Nameless task",
+ "ROW_LABEL": "Row {{ number }}",
+ "REMOVE_ROW_DIALOG": {
+ "TITLE": "Delete the row",
+ "MESSAGE": "Are you sure you want to delete this row?",
+ "YES_LABEL": "Delete row",
+ "NO_LABEL": "Cancel"
+ }
},
"FIELD_STYLE": {
"FONT_SIZE": "Font size",