AAE-44184 Colspan setting on form fields is ignored or not properly applied (#11800)

* AAE-44184 Colspan setting on form fields is ignored or not properly applied

* AAE-44184 Better handling of empty columns

* AAE-44184 Fixing stale adf-form-field instances

* AAE-44184 Consolidating duplicate code
This commit is contained in:
David Olson
2026-05-20 14:19:17 +05:30
committed by Anamika Dey
parent 68010297a6
commit 1c2b91dadc
9 changed files with 475 additions and 129 deletions
@@ -1,94 +1,116 @@
<div id="adf-form-renderer" class="{{ formDefinition.className }} adf-form-renderer"
[ngClass]="{ 'adf-readonly-form': formDefinition.readOnly }">
<div *ngIf="formDefinition.hasTabs()">
<div *ngIf="hasTabs()" class="alfresco-tabs-widget">
@if (formDefinition.hasTabs()) {
@if (hasTabs()) {
<div class="alfresco-tabs-widget">
<mat-tab-group [preserveContent]="true">
<mat-tab *ngFor="let tab of visibleTabs()" [label]="tab.title | translate ">
@for (tab of visibleTabs(); track tab) {
<mat-tab [label]="tab.title | translate ">
<ng-template matTabContent>
<div class="adf-form-tab-content">
<ng-template *ngTemplateOutlet="render; context: { fieldToRender: tab.fields }" />
</div>
</ng-template>
</mat-tab>
}
</mat-tab-group>
</div>
</div>
}
}
<div *ngIf="!formDefinition.hasTabs() && formDefinition.hasFields()">
@if (!formDefinition.hasTabs() && formDefinition.hasFields()) {
<div>
<ng-template *ngTemplateOutlet="render; context: { fieldToRender: formDefinition.fields }" />
</div>
}
</div>
<ng-template #render let-fieldToRender="fieldToRender">
<div *ngFor="let currentRootElement of fieldToRender">
<div *ngIf="currentRootElement.type === 'section'" [id]="'field-' + currentRootElement?.id + '-container'" class="adf-container-widget">
<adf-form-section [field]="currentRootElement" />
@for (currentRootElement of fieldToRender; track currentRootElement) {
@if (currentRootElement.type === 'section') {
<div [id]="'field-' + currentRootElement?.id + '-container'" class="adf-container-widget">
<adf-form-section [field]="currentRootElement.field" />
</div>
}
<div *ngIf="currentRootElement.type === 'container' || currentRootElement.type === 'group'"
@if (currentRootElement.type === 'container' || currentRootElement.type === 'group') {
<div
[id]="'field-' + currentRootElement?.id + '-container'"
class="adf-container-widget"
[hidden]="!currentRootElement?.isVisible">
<adf-header-widget [element]="currentRootElement" />
<div *ngIf="currentRootElement?.form?.enableFixedSpace; else fixingTemplate">
<div class="adf-grid-list"
[ngStyle]="{ 'grid-template-columns': 'repeat(' + getNumberOfColumns(currentRootElement) + ', 1fr)' }"
*ngIf="currentRootElement?.isExpanded">
<div class="adf-grid-list-item"
*ngFor="let field of getContainerFields(currentRootElement)"
[ngStyle]="{ 'grid-area': 'auto / auto / span ' + (field?.rowspan || 1) + ' / span ' + (field?.colspan || 1) }">
<adf-form-field *ngIf="field" [field]="field" />
@if (currentRootElement?.form?.enableFixedSpace) {
@if (currentRootElement?.isExpanded) {
<div class="adf-grid-list"
[ngStyle]="{ 'grid-template-columns': 'repeat(' + getNumberOfColumns(currentRootElement) + ', 1fr)' }">
@for (field of getContainerFields(currentRootElement); track field ?? $index) {
<div class="adf-grid-list-item"
[ngStyle]="{ 'grid-area': 'auto / auto / span ' + (field?.rowspan || 1) + ' / span ' + (field?.colspan || 1) }">
@if (field) {
<adf-form-field [field]="field" />
}
</div>
}
</div>
</div>
</div>
}
} @else {
<ng-template [ngTemplateOutlet]="fixingTemplate" />
}
<ng-template #fixingTemplate>
<section class="adf-grid-list-column-view" *ngIf="currentRootElement?.isExpanded">
<div class="adf-grid-list-single-column"
*ngFor="let column of currentRootElement?.columns"
[style.width.%]="getColumnWidth(currentRootElement)"
>
<ng-container *ngFor="let field of column?.fields">
<ng-container *ngIf="field.type === 'section'; else formField">
<adf-form-section [field]="field"/>
</ng-container>
<ng-template #formField>
<div class="adf-grid-list-column-view-item">
<adf-form-field [field]="field"/>
</div>
</ng-template>
</ng-container>
</div>
@if (currentRootElement?.isExpanded) {
<section class="adf-grid-list-column-view">
@for (column of currentRootElement?.columns; track column; let columnIndex = $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, currentRootElement?.columns, columnIndex)">
@for (field of column?.fields; track field) {
@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>
}
</section>
}
</ng-template>
<ng-template #columnViewItem let-column="column">
<div class="adf-grid-list-column-view-item" *ngFor="let field of column?.fields">
<adf-form-field *ngIf="field" [field]="field" />
</div>
@for (field of column?.fields; track field) {
<div class="adf-grid-list-column-view-item">
@if (field) {
<adf-form-field [field]="field" />
}
</div>
}
</ng-template>
</div>
}
@if (currentRootElement.type === 'repeatable-section') {
<div
<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) {
@for (row of currentRootElement.field.rows; track row; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1;
<div
<div
class="adf-grid-list-container"
[class.adf-grid-list-container-multiple]="hasMultipleRows"
[class.adf-grid-list-container-disabled]="currentRootElement.field.readOnly"
>
>
<div class="adf-grid-list-row">
<span class="adf-grid-list-row-label">{{ 'FORM.FORM_RENDERER.ROW_LABEL' | translate: {number: rowIndex + 1} }}</span>
@let shouldDisplayRemoveRowButton = !currentRootElement.field.rows[rowIndex].isInitial || (currentRootElement.field.rows[rowIndex].isInitial && currentRootElement.field.params.allowInitialRowsDelete);
@if (shouldDisplayRemoveRowButton) {
<button
<button
mat-icon-button
class="adf-grid-list-row-remove-button"
[disabled]="currentRootElement.field.readOnly"
@@ -100,21 +122,20 @@
}
</div>
<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>
@for (column of row.columns; track column; let columnIndex = $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)">
@for (field of column?.fields; track field) {
@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>
</div>
}
</section>
</div>
@@ -123,13 +144,16 @@
</div>
}
<div *ngIf="currentRootElement.type === 'dynamic-table'" class="adf-container-widget">
<adf-form-field [field]="currentRootElement" />
</div>
@if (currentRootElement.type === 'dynamic-table') {
<div class="adf-container-widget">
<adf-form-field [field]="currentRootElement" />
</div>
}
<div class="adf-container-widget"
*ngIf="currentRootElement.type === 'readonly' && currentRootElement.field.params.field.type === 'dynamic-table'">
<adf-form-field [field]="currentRootElement.field"/>
</div>
</div>
@if (currentRootElement.type === 'readonly' && currentRootElement.field.params.field.type === 'dynamic-table') {
<div class="adf-container-widget">
<adf-form-field [field]="currentRootElement.field"/>
</div>
}
}
</ng-template>
@@ -24,6 +24,7 @@ import { FormRendererComponent } from './form-renderer.component';
import {
amountWidgetFormVisibilityMock,
checkboxWidgetFormVisibilityMock,
colspanAnyColumnsForm,
colspanForm,
customWidgetForm,
customWidgetFormWithVisibility,
@@ -439,6 +440,65 @@ describe('Form Renderer Component', () => {
expect(fullWidthElement.style['width']).toBe('100%');
});
it('Should display widths 25% and 75% in non-grid mode for 4-column container with colspan 1 and 3', async () => {
formRendererComponent.formDefinition = formService.parseForm(colspanAnyColumnsForm.formRepresentation.formDefinition, null, false, false);
fixture.detectChanges();
await fixture.whenStable();
const columns = testingUtils
.getAllByCSS('#field-4col-container-id-container section.adf-grid-list-column-view .adf-grid-list-single-column')
.map((element) => element.nativeElement as HTMLElement);
expect(columns.length).toBe(4);
expect(columns[0].style['width']).toBe('25%');
expect(columns[1].style['width']).toBe('75%');
expect(columns[2].style['width']).toBe('0%');
expect(columns[3].style['width']).toBe('0%');
});
it('Should display widths 33.333% and 66.666% in non-grid mode for 12-column container with colspan 4 and 8', async () => {
formRendererComponent.formDefinition = formService.parseForm(colspanAnyColumnsForm.formRepresentation.formDefinition, null, false, false);
fixture.detectChanges();
await fixture.whenStable();
const columns = testingUtils
.getAllByCSS('#field-12col-container-id-container section.adf-grid-list-column-view .adf-grid-list-single-column')
.map((element) => element.nativeElement as HTMLElement);
expect(columns.length).toBe(12);
expect(parseFloat(columns[0].style['width'])).toBeCloseTo(33.33, 2);
expect(columns[1].style['width']).toBe('0%');
expect(columns[2].style['width']).toBe('0%');
expect(columns[3].style['width']).toBe('0%');
expect(parseFloat(columns[4].style['width'])).toBeCloseTo(66.67, 2);
expect(columns[5].style['width']).toBe('0%');
expect(columns[6].style['width']).toBe('0%');
expect(columns[7].style['width']).toBe('0%');
expect(columns[8].style['width']).toBe('0%');
expect(columns[9].style['width']).toBe('0%');
expect(columns[10].style['width']).toBe('0%');
expect(columns[11].style['width']).toBe('0%');
});
it('Should preserve width for an intentionally empty leading spacer column in non-grid mode', async () => {
formRendererComponent.formDefinition = formService.parseForm(
amountWidgetFormVisibilityMock.formRepresentation.formDefinition,
null,
false,
false
);
fixture.detectChanges();
await fixture.whenStable();
const columns = testingUtils
.getAllByCSS('#field-ce32844c-20b2-4361-88b5-a56f28219aef-container section.adf-grid-list-column-view .adf-grid-list-single-column')
.map((element) => element.nativeElement as HTMLElement);
expect(columns.length).toBe(2);
expect(columns[0].style['width']).toBe('50%');
expect(columns[1].style['width']).toBe('50%');
});
it('[C309872] - Should display Text widget spans on 2 columns when colspan is set to 2', () => {
formRendererComponent.formDefinition = formService.parseForm(colspanForm.formRepresentation.formDefinition, null, false, false);
fixture.detectChanges();
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { NgClass, NgForOf, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
import { ChangeDetectorRef, Component, DestroyRef, inject, Injector, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
@@ -36,11 +36,12 @@ import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from '../../../lib/dialogs/confirm-dialog/confirm.dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IconModule } from '../../icon/icon.module';
import { FormLayoutColumn, getFormLayoutColumnWidth } from './helpers/column-width';
@Component({
selector: 'adf-form-renderer',
templateUrl: './form-renderer.component.html',
styleUrls: ['./form-renderer.component.scss'],
styleUrl: './form-renderer.component.scss',
providers: [
{
provide: FormRulesManager,
@@ -54,9 +55,7 @@ import { IconModule } from '../../icon/icon.module';
}
],
imports: [
NgIf,
MatTabsModule,
NgForOf,
NgTemplateOutlet,
TranslatePipe,
MatButtonModule,
@@ -182,16 +181,8 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
});
}
/**
* Calculate the column width based on the numberOfColumns and current field's colspan property
*
* @param container container model
* @returns the column width for the given model
*/
getColumnWidth(container: ContainerModel): string {
const { field } = container;
const colspan = field ? field.colspan : 1;
return (100 / field.numberOfColumns) * colspan + '';
getColumnWidth(container: ContainerModel, columns: FormLayoutColumn[], columnIndex: number): string {
return getFormLayoutColumnWidth(container.field?.numberOfColumns, columns, columnIndex);
}
private runMiddlewareServices(): void {
@@ -1,11 +1,13 @@
<div class="adf-grid-list-section-single-column"
[id]="'field-' + field?.id + '-container'"
[style.display]="field?.isVisible ? 'flex' : 'none'">
<div *ngFor="let sectionColumn of field.columns"
[style.width.%]="getSectionColumnWidth(field.numberOfColumns, sectionColumn.fields)"
>
<div *ngFor="let sectionField of sectionColumn.fields" class="adf-grid-list-section-column-view-item">
<adf-form-field [field]="sectionField"/>
</div>
</div>
@for (sectionColumn of field.columns; track sectionColumn; let columnIndex = $index) {
<div [style.width.%]="getSectionColumnWidth(field.numberOfColumns, field.columns, columnIndex)">
@for (sectionField of sectionColumn.fields; track sectionField) {
<div class="adf-grid-list-section-column-view-item">
<adf-form-field [field]="sectionField"/>
</div>
}
</div>
}
</div>
@@ -38,17 +38,17 @@ describe('FormSectionComponent', () => {
it('should calculate the correct width for section columns', () => {
const numberOfColumns = 3;
const columnField = { colspan: 2 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 2 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, [columnField]);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('66.66666666666667');
});
it('should handle columns with no colspan defined', () => {
const numberOfColumns = 3;
const columnField = {} as FormFieldModel;
const columns = [{ fields: [{} as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, [columnField]);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('33.333333333333336');
});
@@ -63,84 +63,93 @@ describe('FormSectionComponent', () => {
describe('getSectionColumnWidth', () => {
it('should cap width at 100% when numberOfColumns is not a number', () => {
const columnField = { colspan: 2 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 2 } as FormFieldModel] }];
const width = component.getSectionColumnWidth('invalid' as unknown as number, [columnField]);
const width = component.getSectionColumnWidth('invalid' as unknown as number, columns, 0);
expect(width).toBe('100');
});
it('should cap width at 100% when numberOfColumns is null', () => {
const columnField = { colspan: 3 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 3 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(null as unknown as number, [columnField]);
const width = component.getSectionColumnWidth(null as unknown as number, columns, 0);
expect(width).toBe('100');
});
it('should return 100% when numberOfColumns is undefined', () => {
const columnField = { colspan: 1 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 1 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(undefined as unknown as number, [columnField]);
const width = component.getSectionColumnWidth(undefined as unknown as number, columns, 0);
expect(width).toBe('100');
});
it('should cap width at 100% when numberOfColumns is 0', () => {
const columnField = { colspan: 2 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 2 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(0, [columnField]);
const width = component.getSectionColumnWidth(0, columns, 0);
expect(width).toBe('100');
});
it('should cap width at 100% when numberOfColumns is negative', () => {
const columnField = { colspan: 3 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 3 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(-1, [columnField]);
const width = component.getSectionColumnWidth(-1, columns, 0);
expect(width).toBe('100');
});
it('should return 100 when numberOfColumns is falsy and no colspan is defined', () => {
const columnField = {} as FormFieldModel;
const columns = [{ fields: [{} as FormFieldModel] }];
const width = component.getSectionColumnWidth(null as unknown as number, [columnField]);
const width = component.getSectionColumnWidth(null as unknown as number, columns, 0);
expect(width).toBe('100');
});
it('should calculate percentage width when numberOfColumns is a valid number', () => {
const numberOfColumns = 4;
const columnField = { colspan: 2 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 2 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, [columnField]);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('50');
});
it('should cap width at 100% when colspan exceeds numberOfColumns', () => {
const numberOfColumns = 2;
const columnField = { colspan: 5 } as FormFieldModel;
const columns = [{ fields: [{ colspan: 5 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, [columnField]);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('100');
});
it('should use default colspan of 1 when field has no colspan and numberOfColumns is valid', () => {
const numberOfColumns = 5;
const columnField = {} as FormFieldModel;
const columns = [{ fields: [{} as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, [columnField]);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('20');
});
it('should handle empty columnFields array', () => {
it('should return default width for an authored empty spacer column', () => {
const numberOfColumns = 3;
const columns = [{ fields: [] }, { fields: [{ colspan: 1 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, []);
expect(parseFloat(width)).toBeCloseTo(33.33);
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('33.333333333333336');
});
it('should use first field colspan when multiple fields are provided', () => {
const numberOfColumns = 2;
const columnFields = [{ colspan: 1 } as FormFieldModel, { colspan: 3 } as FormFieldModel];
it('should return 0 width for an empty column covered by a previous colspan', () => {
const numberOfColumns = 4;
const columns = [{ fields: [{ colspan: 3 } as FormFieldModel] }, { fields: [] }, { fields: [] }, { fields: [] }];
const width = component.getSectionColumnWidth(numberOfColumns, columnFields);
expect(width).toBe('50');
const width = component.getSectionColumnWidth(numberOfColumns, columns, 1);
expect(width).toBe('0');
});
it('should use max field colspan when multiple fields are provided', () => {
const numberOfColumns = 2;
const columns = [{ fields: [{ colspan: 1 } as FormFieldModel, { colspan: 3 } as FormFieldModel] }];
const width = component.getSectionColumnWidth(numberOfColumns, columns, 0);
expect(width).toBe('100');
});
});
});
@@ -19,14 +19,14 @@ import { Component, inject, Input, OnInit, ViewEncapsulation } from '@angular/co
import { WidgetVisibilityService } from '../../services/widget-visibility.service';
import { FormFieldModel } from '../widgets/core/form-field.model';
import { FormFieldComponent } from '../form-field/form-field.component';
import { NgFor } from '@angular/common';
import { FormLayoutColumn, getFormLayoutColumnWidth } from '../helpers/column-width';
@Component({
selector: 'adf-form-section',
templateUrl: './form-section.component.html',
encapsulation: ViewEncapsulation.None,
styleUrls: ['./form-section.component.scss'],
imports: [NgFor, FormFieldComponent]
styleUrl: './form-section.component.scss',
imports: [FormFieldComponent]
})
export class FormSectionComponent implements OnInit {
@Input()
@@ -38,15 +38,7 @@ export class FormSectionComponent implements OnInit {
this.visibilityService.refreshVisibility(this.field.form);
}
getSectionColumnWidth(numberOfColumns: number, columnFields: FormFieldModel[]): string {
const firstColumnFieldIndex = 0;
const defaultColspan = 1;
const fieldColspan = columnFields[firstColumnFieldIndex]?.colspan ?? defaultColspan;
if (typeof numberOfColumns !== 'number' || !numberOfColumns || numberOfColumns <= 0) {
numberOfColumns = 1;
}
return Math.min(100, (100 / numberOfColumns) * fieldColspan) + '';
getSectionColumnWidth(numberOfColumns: number, columns: FormLayoutColumn[], columnIndex: number): string {
return getFormLayoutColumnWidth(numberOfColumns, columns, columnIndex);
}
}
@@ -0,0 +1,82 @@
/*!
* @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 { FormLayoutColumn, getFormLayoutColumnWidth } from './column-width';
describe('getFormLayoutColumnWidth', () => {
it('should return the default width when numberOfColumns is invalid', () => {
const columns: FormLayoutColumn[] = [{ fields: [{ colspan: 1 }] }];
const width = getFormLayoutColumnWidth(undefined, columns, 0);
expect(width).toBe('100');
});
it('should return the default width for an authored empty spacer column', () => {
const NUMBER_OF_COLUMNS = 3;
const columns: FormLayoutColumn[] = [{ fields: [] }, { fields: [{ colspan: 1 }] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 0);
expect(width).toBe('33.333333333333336');
});
it('should return zero width for an empty column covered by a previous colspan', () => {
const NUMBER_OF_COLUMNS = 4;
const columns: FormLayoutColumn[] = [{ fields: [{ colspan: 3 }] }, { fields: [] }, { fields: [] }, { fields: [] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 1);
expect(width).toBe('0');
});
it('should use the maximum colspan when a column contains multiple fields', () => {
const NUMBER_OF_COLUMNS = 4;
const columns: FormLayoutColumn[] = [{ fields: [{ colspan: 1 }, { colspan: 3 }] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 0);
expect(width).toBe('75');
});
it('should cap the width at 100 percent when colspan exceeds the available columns', () => {
const NUMBER_OF_COLUMNS = 2;
const columns: FormLayoutColumn[] = [{ fields: [{ colspan: 5 }] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 0);
expect(width).toBe('100');
});
it('should treat a missing colspan as one column', () => {
const NUMBER_OF_COLUMNS = 5;
const columns: FormLayoutColumn[] = [{ fields: [{}] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 0);
expect(width).toBe('20');
});
it('should not mark a later empty spacer as covered when an earlier column is also empty', () => {
const NUMBER_OF_COLUMNS = 4;
const columns: FormLayoutColumn[] = [{ fields: [] }, { fields: [] }, { fields: [{ colspan: 1 }] }, { fields: [] }];
const width = getFormLayoutColumnWidth(NUMBER_OF_COLUMNS, columns, 1);
expect(width).toBe('25');
});
});
@@ -0,0 +1,62 @@
/*!
* @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.
*/
export interface FormLayoutField {
colspan?: number | null;
}
export interface FormLayoutColumn {
fields?: FormLayoutField[] | null;
}
export const getFormLayoutColumnWidth = (
numberOfColumns: number | null | undefined,
columns: FormLayoutColumn[] | null | undefined,
columnIndex: number
): string => {
const normalizedColumnCount = typeof numberOfColumns === 'number' && numberOfColumns > 0 ? numberOfColumns : 1;
const defaultColumnWidth = 100 / normalizedColumnCount;
const columnFields = columns?.[columnIndex]?.fields ?? [];
if (columnFields.length === 0) {
return isColumnCoveredByPreviousField(columns, columnIndex) ? '0' : `${defaultColumnWidth}`;
}
const maxColspan = Math.max(...columnFields.map((field) => field.colspan || 1));
return `${Math.min(100, defaultColumnWidth * maxColspan)}`;
};
const isColumnCoveredByPreviousField = (columns: FormLayoutColumn[] | null | undefined, columnIndex: number): boolean => {
if (!columns || columnIndex <= 0) {
return false;
}
for (let previousColumnIndex = 0; previousColumnIndex < columnIndex; previousColumnIndex++) {
const previousFields = columns[previousColumnIndex]?.fields ?? [];
if (previousFields.length === 0) {
continue;
}
const previousColumnSpan = Math.max(...previousFields.map((field) => field.colspan || 1));
if (previousColumnIndex + previousColumnSpan > columnIndex) {
return true;
}
}
return false;
};
@@ -981,6 +981,130 @@ export const colspanForm = {
}
};
export const colspanAnyColumnsForm = {
formRepresentation: {
id: 'form-any-columns-colspan',
name: 'any-columns-colspan',
description: '',
version: 0,
standAlone: true,
formDefinition: {
tabs: [],
fields: [
{
id: '4col-container-id',
name: 'Label',
type: 'container',
tab: null,
numberOfColumns: 4,
fields: {
'1': [
{
id: 'Text4ColLeft',
name: 'Left',
type: 'text',
readOnly: false,
required: false,
colspan: 1,
placeholder: null,
minLength: 0,
maxLength: 0,
regexPattern: null,
visibilityCondition: null,
params: {
existingColspan: 1,
maxColspan: 4
}
}
],
'2': [
{
id: 'Text4ColRight',
name: 'Right',
type: 'text',
readOnly: false,
required: false,
colspan: 3,
placeholder: null,
minLength: 0,
maxLength: 0,
regexPattern: null,
visibilityCondition: null,
params: {
existingColspan: 1,
maxColspan: 4
}
}
],
'3': [],
'4': []
}
},
{
id: '12col-container-id',
name: 'Label',
type: 'container',
tab: null,
numberOfColumns: 12,
fields: {
'1': [
{
id: 'Text12ColLeft',
name: 'Left',
type: 'text',
readOnly: false,
required: false,
colspan: 4,
placeholder: null,
minLength: 0,
maxLength: 0,
regexPattern: null,
visibilityCondition: null,
params: {
existingColspan: 1,
maxColspan: 12
}
}
],
'2': [],
'3': [],
'4': [],
'5': [
{
id: 'Text12ColRight',
name: 'Right',
type: 'text',
readOnly: false,
required: false,
colspan: 8,
placeholder: null,
minLength: 0,
maxLength: 0,
regexPattern: null,
visibilityCondition: null,
params: {
existingColspan: 1,
maxColspan: 12
}
}
],
'6': [],
'7': [],
'8': [],
'9': [],
'10': [],
'11': [],
'12': []
}
}
],
outcomes: [],
metadata: {},
variables: []
}
}
};
export const numberNotRequiredForm = {
formRepresentation: {
id: 'form-d4c462db-3838-442e-a006-171e6ccafe61',