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') { +
+ + @for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) { + @let hasMultipleRows = currentRootElement.field.rows.length > 1; +
+

{{ 'FORM.FORM_RENDERER.ROW_LABEL' | translate: {number: rowIndex + 1} }}

+
+ @for (column of row.columns; track $index) { +
+ @for (field of column?.fields; track $index) { + @if (field.type === 'section') { + + } @else { +
+ +
+ } + } +
+ } + + @let shouldDisplayRemoveRowButton = !currentRootElement.field.rows[rowIndex].isInitial || (currentRootElement.field.rows[rowIndex].isInitial && currentRootElement.field.params.allowInitialRowsDelete); + @if (shouldDisplayRemoveRowButton) { + + } +
+
+ } +
+
+ } +
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",