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
This commit is contained in:
Diogo Bastos
2025-11-04 10:52:01 +00:00
committed by GitHub
parent e85a27e05b
commit f7d26d904f
16 changed files with 1050 additions and 14 deletions
@@ -69,6 +69,55 @@
</ng-template>
</div>
@if (currentRootElement.type === 'repeatable-section') {
<div
[id]="'field-' + currentRootElement?.id + '-container'"
class="adf-container-widget"
[hidden]="!currentRootElement?.isVisible"
>
<adf-repeat-widget [element]="currentRootElement" [isEditor]="false">
@for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1;
<div
class="adf-grid-list-container"
[class.adf-grid-list-container-multiple]="hasMultipleRows"
>
<h4 class="adf-grid-list-container-label">{{ 'FORM.FORM_RENDERER.ROW_LABEL' | translate: {number: rowIndex + 1} }}</h4>
<section class="adf-grid-list-column-view">
@for (column of row.columns; track $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement)"
>
@for (field of column?.fields; track $index) {
@if (field.type === 'section') {
<adf-form-section [field]="field"/>
} @else {
<div class="adf-grid-list-column-view-item">
<adf-form-field [field]="field"/>
</div>
}
}
</div>
}
@let shouldDisplayRemoveRowButton = !currentRootElement.field.rows[rowIndex].isInitial || (currentRootElement.field.rows[rowIndex].isInitial && currentRootElement.field.params.allowInitialRowsDelete);
@if (shouldDisplayRemoveRowButton) {
<button
mat-icon-button
class="adf-grid-list-remove-row-button"
(click)="displayDialogToRemoveRow(currentRootElement.field, rowIndex)"
>
<mat-icon>close</mat-icon>
</button>
}
</section>
</div>
}
</adf-repeat-widget>
</div>
}
<div *ngIf="currentRootElement.type === 'dynamic-table'" class="adf-container-widget">
<adf-form-field [field]="currentRootElement" />
</div>
@@ -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) {
@@ -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<any>;
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();
});
});
});
});
@@ -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<T> implements OnInit, OnDestroy {
public readonly formService = inject(FormService);
private readonly formRulesManager = inject(FormRulesManager<T>);
private readonly dialog = inject(MatDialog);
private cdr = inject(ChangeDetectorRef);
@Input({ required: true })
formDefinition: FormModel;
@@ -76,8 +84,6 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
fields: FormFieldModel[];
constructor(
public formService: FormService,
private formRulesManager: FormRulesManager<T>,
@Optional()
@Inject(FORM_FIELD_MODEL_RENDER_MIDDLEWARE)
private middlewareServices?: FormFieldModelRenderMiddleware[]
@@ -145,6 +151,27 @@ export class FormRendererComponent<T> 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
*
@@ -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,
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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 = '<some 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);
});
});
});
});
@@ -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`
*
@@ -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,
@@ -0,0 +1,29 @@
<div [style]="element | adfFieldStyle" class="adf-container-widget-repeat">
<h4
id="container-repeat"
class="adf-container-widget-repeat__text"
>
<span [id]="'container-repeat-label-' + element?.id" role="button" tabindex="0">
{{ element.name | translate }}
</span>
</h4>
<ng-content />
@if (!isEditor) {
@let shouldDisplayAddRowButton = !element.field.params.newRowsLimit || (element.field.params.newRowsLimit > getAddedRowsCount());
@if (shouldDisplayAddRowButton) {
<button
mat-button
color="primary"
class="adf-container-widget-row-action"
(click)="addRow()"
>
<mat-icon>add</mat-icon> {{ 'FORM.FIELD.REPEATABLE_SECTION.ADD_ROW' | translate }}
</button>
} @else {
@let rowLimit = element.field.params.initialNumberOfRows + (element.field.params.newRowsLimit ?? 0);
<span class="adf-container-widget-row-action adf-container-widget-row-limit">{{ 'FORM.FIELD.REPEATABLE_SECTION.ROW_LIMIT_REACHED' | translate: { limit: rowLimit } }}</span>
}
}
</div>
@@ -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;
}
}
@@ -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<RepeatWidgetComponent>;
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);
});
});
});
});
@@ -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;
}
}
+13 -2
View File
@@ -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",