Add hierarchical side-navigation form layout with mixed tabs support

This commit is contained in:
copilot-swe-agent[bot]
2026-08-25 16:55:28 +00:00
committed by GitHub
parent 9f37f22702
commit 694463f914
13 changed files with 1062 additions and 8 deletions
@@ -1,6 +1,33 @@
<div id="adf-form-renderer" class="{{ formDefinition.className }} adf-form-renderer"
[ngClass]="{ 'adf-readonly-form': formDefinition.readOnly }">
@if (formDefinition.hasTabs()) {
@if (formDefinition.hasSideNav()) {
<adf-form-side-nav
class="adf-form-side-nav"
[nodes]="visibleTabs()"
[activeNodeId]="activeSideNavSectionId()"
(sectionSelected)="selectSideNavSection($event)"
>
@if (activeSideNavSection(); as activeSection) {
@if (activeSection.hasTabbedChildren()) {
<div class="alfresco-tabs-widget">
<mat-tab-group [preserveContent]="true">
@for (childSection of activeSection.visibleChildren(); track childSection.id) {
<mat-tab [label]="childSection.title | translate">
<ng-template matTabContent>
<div class="adf-form-tab-content">
<ng-template *ngTemplateOutlet="render; context: { fieldToRender: childSection.fields }" />
</div>
</ng-template>
</mat-tab>
}
</mat-tab-group>
</div>
} @else {
<ng-template *ngTemplateOutlet="render; context: { fieldToRender: activeSection.fields }" />
}
}
</adf-form-side-nav>
} @else if (formDefinition.hasTabs()) {
@if (hasTabs()) {
<div class="alfresco-tabs-widget">
<mat-tab-group [preserveContent]="true">
@@ -40,6 +40,12 @@
}
}
.adf-form-side-nav {
display: block;
width: 100%;
min-height: 300px;
}
.mat-mdc-card-content:first-child {
padding-top: 1em;
}
@@ -110,6 +110,74 @@ const buildTabbedForm = (tabCount: number, hiddenTabIndices: number[] = []): For
return form;
};
const buildSideNavForm = (): FormModel => {
const json = {
layout: 'sidenav',
tabs: [
{
id: 'section-1',
title: 'Section 1'
},
{
id: 'category-1',
title: 'Category 1',
children: [
{ id: 'category-1-child-1', title: 'Category 1 Child 1' },
{ id: 'category-1-child-2', title: 'Category 1 Child 2' }
]
},
{
id: 'tabbed-category',
title: 'Tabbed Category',
childrenLayout: 'tabs',
children: [
{ id: 'tabbed-child-1', title: 'Tabbed Child 1' },
{ id: 'tabbed-child-2', title: 'Tabbed Child 2' }
]
}
],
fields: [
{
id: 'container-section-1',
type: 'container',
tab: 'section-1',
numberOfColumns: 1,
fields: { 1: [{ id: 'text-section-1', type: 'text', name: 'Text in Section 1' }] }
},
{
id: 'container-category-1-child-1',
type: 'container',
tab: 'category-1-child-1',
numberOfColumns: 1,
fields: { 1: [{ id: 'text-category-1-child-1', type: 'text', name: 'Text in Category 1 Child 1', required: true }] }
},
{
id: 'container-category-1-child-2',
type: 'container',
tab: 'category-1-child-2',
numberOfColumns: 1,
fields: { 1: [{ id: 'text-category-1-child-2', type: 'text', name: 'Text in Category 1 Child 2' }] }
},
{
id: 'container-tabbed-child-1',
type: 'container',
tab: 'tabbed-child-1',
numberOfColumns: 1,
fields: { 1: [{ id: 'text-tabbed-child-1', type: 'text', name: 'Text in Tabbed Child 1' }] }
},
{
id: 'container-tabbed-child-2',
type: 'container',
tab: 'tabbed-child-2',
numberOfColumns: 1,
fields: { 1: [{ id: 'text-tabbed-child-2', type: 'text', name: 'Text in Tabbed Child 2' }] }
}
]
};
return new FormModel(json);
};
describe('Form Renderer Component', () => {
let formRendererComponent: FormRendererComponent<any>;
let fixture: ComponentFixture<FormRendererComponent<any>>;
@@ -1044,6 +1112,92 @@ describe('Form Renderer Component', () => {
});
});
describe('Side navigation', () => {
it('should report hasSideNav as false for a classic tabbed form', () => {
const form = buildTabbedForm(2);
formRendererComponent.formDefinition = form;
expect(form.hasSideNav()).toBeFalse();
});
it('should default the active section to the first visible leaf node', () => {
formRendererComponent.formDefinition = buildSideNavForm();
expect(formRendererComponent.activeSideNavSectionId()).toBe('section-1');
});
it('should skip a hidden default section and select the next visible one', () => {
const form = buildSideNavForm();
form.tabs[0].isVisible = false;
formRendererComponent.formDefinition = form;
expect(formRendererComponent.activeSideNavSectionId()).toBe('category-1-child-1');
});
it('should select a leaf section on selectSideNavSection', () => {
const form = buildSideNavForm();
formRendererComponent.formDefinition = form;
const leaf = form.findTabById('category-1-child-2');
formRendererComponent.selectSideNavSection(leaf);
expect(formRendererComponent.activeSideNavSectionId()).toBe('category-1-child-2');
});
it('should not select a category node whose children are rendered as further side nav entries', () => {
const form = buildSideNavForm();
formRendererComponent.formDefinition = form;
const category = form.findTabById('category-1');
formRendererComponent.selectSideNavSection(category);
expect(formRendererComponent.activeSideNavSectionId()).toBe('section-1');
});
it('should select a category node whose children are configured to render as tabs', () => {
const form = buildSideNavForm();
formRendererComponent.formDefinition = form;
const tabbedCategory = form.findTabById('tabbed-category');
formRendererComponent.selectSideNavSection(tabbedCategory);
expect(formRendererComponent.activeSideNavSectionId()).toBe('tabbed-category');
expect(formRendererComponent.activeSideNavSection().hasTabbedChildren()).toBeTrue();
});
it('should render the side nav and the active section content', () => {
formRendererComponent.formDefinition = buildSideNavForm();
fixture.detectChanges();
expect(testingUtils.getByCSS('adf-form-side-nav')).toBeTruthy();
expect(testingUtils.getByCSS('#field-text-section-1-container')).toBeTruthy();
});
it('should render a tab group for a category configured with childrenLayout tabs', async () => {
const form = buildSideNavForm();
formRendererComponent.formDefinition = form;
formRendererComponent.selectSideNavSection(form.findTabById('tabbed-category'));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(testingUtils.getByCSS('.alfresco-tabs-widget')).toBeTruthy();
expect(testingUtils.getByCSS('#field-container-tabbed-child-1-container')).toBeTruthy();
expect(fixture.nativeElement.textContent).toContain('Tabbed Child 2');
});
it('should re-sync the active section when it becomes hidden after a rules event', () => {
const form = buildSideNavForm();
formRendererComponent.formDefinition = form;
fixture.detectChanges();
const visibilityService = TestBed.inject(WidgetVisibilityService);
spyOn(visibilityService, 'refreshVisibility');
form.findTabById('section-1').isVisible = false;
formService.formRulesEvent.next({ type: 'fieldValueChanged', form } as any);
expect(formRendererComponent.activeSideNavSectionId()).toBe('category-1-child-1');
});
});
describe('Repeatable section', () => {
const repeatableSectionField = new FormFieldModel(new FormModel(), {
id: 'RepeatableSection0tbw2y',
@@ -43,6 +43,7 @@ import { FORM_FIELD_MODEL_RENDER_MIDDLEWARE, FormFieldModelRenderMiddleware } fr
import { ContainerModel, FormFieldModel, FormModel, TabModel, RepeatWidgetComponent } from './widgets';
import { HeaderWidgetComponent } from './widgets/header/header.widget';
import { FormSectionComponent } from './form-section/form-section.component';
import { FormSideNavComponent } from './form-side-nav/form-side-nav.component';
import { DecimalRenderMiddlewareService } from './middlewares/decimal-middleware.service';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from '../../../lib/dialogs/confirm-dialog/confirm.dialog';
@@ -79,6 +80,7 @@ import { RepeatableRowLabelPipe } from '../pipes/repeatable-row-label.pipe';
NgClass,
HeaderWidgetComponent,
FormSectionComponent,
FormSideNavComponent,
RepeatWidgetComponent,
MatTooltipModule,
RepeatableRowLabelPipe
@@ -99,6 +101,7 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
set formDefinition(formDefinition: FormModel) {
this._formDefinition = formDefinition;
this.syncCurrentTabIndex();
this.syncActiveSection();
}
get formDefinition(): FormModel {
@@ -124,6 +127,7 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
}
private readonly currentTabIndex = signal(0);
private readonly activeSection = signal<TabModel | undefined>(undefined);
private _formDefinition: FormModel;
private _tabGroup?: MatTabGroup;
private tabGroupSelectionSubscription?: Subscription;
@@ -159,7 +163,10 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.formDefinition?.id),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.visibilityService.refreshVisibility(this.formDefinition));
.subscribe(() => {
this.visibilityService.refreshVisibility(this.formDefinition);
this.syncActiveSection();
});
}
ngOnDestroy() {
@@ -175,6 +182,53 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
return this.formDefinition?.tabs?.filter((tab) => tab.isVisible) ?? [];
}
activeSideNavSection(): TabModel | undefined {
return this.activeSection();
}
activeSideNavSectionId(): string | undefined {
return this.activeSection()?.id;
}
selectSideNavSection(node: TabModel): void {
if (node && (!node.hasChildren() || node.hasTabbedChildren())) {
this.activeSection.set(node);
}
}
private syncActiveSection(): void {
if (!this.formDefinition?.hasSideNav()) {
this.activeSection.set(undefined);
return;
}
const current = this.activeSection();
const stillVisible = current && this.formDefinition.findTabById(current.id)?.isVisible;
if (!stillVisible) {
this.activeSection.set(this.findDefaultSection(this.formDefinition.tabs));
}
}
private findDefaultSection(nodes: TabModel[]): TabModel | undefined {
for (const node of nodes ?? []) {
if (!node.isVisible) {
continue;
}
if (!node.hasChildren() || node.hasTabbedChildren()) {
return node;
}
const childSection = this.findDefaultSection(node.children);
if (childSection) {
return childSection;
}
}
return undefined;
}
navigateToNextTab(): void {
if (this.tabGroup && this.canNavigateNext) {
this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) + 1;
@@ -0,0 +1,101 @@
<mat-sidenav-container class="adf-form-side-nav-container" [class.adf-form-side-nav-container-small]="isSmallScreen()">
<mat-sidenav
#drawer
class="adf-form-side-nav-drawer"
[mode]="isSmallScreen() ? 'over' : 'side'"
[opened]="drawerOpened"
(openedChange)="drawerOpened = $event"
>
<div class="adf-form-side-nav-progress">
<span class="adf-form-side-nav-progress-label">
{{ 'FORM.FORM_RENDERER.SIDE_NAV.PROGRESS' | translate: { completed: completedSections, total: totalSections } }}
</span>
<div class="adf-form-side-nav-progress-bar" role="progressbar" [attr.aria-valuenow]="completedSections" [attr.aria-valuemin]="0" [attr.aria-valuemax]="totalSections">
<div
class="adf-form-side-nav-progress-bar-fill"
[style.width.%]="totalSections ? (completedSections / totalSections) * 100 : 0"
></div>
</div>
</div>
<mat-nav-list class="adf-form-side-nav-list" role="tree">
<ng-container *ngTemplateOutlet="nodeList; context: { nodes: nodes, level: 0 }" />
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="adf-form-side-nav-content">
@if (isSmallScreen()) {
<button
mat-icon-button
class="adf-form-side-nav-toggle"
type="button"
[attr.aria-label]="'FORM.FORM_RENDERER.SIDE_NAV.TOGGLE_MENU' | translate"
(click)="toggleDrawer()"
>
<mat-icon adf-icon="menu" />
</button>
}
<ng-content />
</mat-sidenav-content>
</mat-sidenav-container>
<ng-template #nodeList let-nodes="nodes" let-level="level">
@for (node of nodes; track node.id) {
@if (node.isVisible) {
<mat-list-item
class="adf-form-side-nav-item"
role="treeitem"
[class.adf-form-side-nav-item-active]="isActive(node)"
[class.adf-form-side-nav-item-error]="node.hasErrors()"
[style.padding-left.px]="16 + level * 16"
[attr.aria-current]="isActive(node) ? 'true' : null"
[attr.aria-expanded]="node.hasChildren() && !node.hasTabbedChildren() ? isExpanded(node) : null"
(click)="handleNodeClick(node)"
>
@if (node.hasChildren() && !node.hasTabbedChildren()) {
<button
mat-icon-button
class="adf-form-side-nav-item-toggle"
type="button"
[attr.aria-label]="(isExpanded(node) ? 'FORM.FORM_RENDERER.SIDE_NAV.COLLAPSE' : 'FORM.FORM_RENDERER.SIDE_NAV.EXPAND') | translate"
(click)="toggleExpanded(node); $event.stopPropagation()"
>
<mat-icon [adf-icon]="isExpanded(node) ? 'expand_more' : 'chevron_right'" />
</button>
}
@if (node.icon) {
<mat-icon [adf-icon]="node.icon" class="adf-form-side-nav-item-icon" />
}
<span class="adf-form-side-nav-item-title">{{ node.title | translate }}</span>
@if (node.hasErrors()) {
<mat-icon
adf-icon="error"
class="adf-form-side-nav-item-status adf-form-side-nav-item-status-error"
[matTooltip]="'FORM.FORM_RENDERER.SIDE_NAV.HAS_ERRORS' | translate"
/>
} @else if (node.getRequiredFieldsCount() > 0 && node.isComplete()) {
<mat-icon
adf-icon="check_circle"
class="adf-form-side-nav-item-status adf-form-side-nav-item-status-complete"
[matTooltip]="'FORM.FORM_RENDERER.SIDE_NAV.COMPLETE' | translate"
/>
} @else if (node.getRequiredFieldsCount() > 0) {
<span
class="adf-form-side-nav-item-required-count"
[matTooltip]="'FORM.FORM_RENDERER.SIDE_NAV.REQUIRED_FIELDS' | translate: { count: node.getRequiredFieldsCount() }"
>
{{ node.getCompletedRequiredFieldsCount() }}/{{ node.getRequiredFieldsCount() }}
</span>
}
</mat-list-item>
@if (node.hasChildren() && !node.hasTabbedChildren() && isExpanded(node)) {
<ng-container *ngTemplateOutlet="nodeList; context: { nodes: node.children, level: level + 1 }" />
}
}
}
</ng-template>
@@ -0,0 +1,120 @@
@use '../../../styles/flex' as flex;
.adf-form-side-nav-toggle {
display: none;
margin-bottom: 8px;
}
.adf-form-side-nav-container {
width: 100%;
min-height: 400px;
background: transparent;
&-small {
.adf-form-side-nav-toggle {
display: flex;
}
}
}
.adf-form-side-nav-drawer {
width: 260px;
padding: 16px 0;
border-right: 1px solid var(--mat-sys-outline-variant);
background: transparent;
@include flex.layout-bp(lt-md) {
width: 85%;
max-width: 300px;
}
}
.adf-form-side-nav-progress {
padding: 0 16px 16px;
&-label {
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
&-bar {
margin-top: 8px;
height: 4px;
border-radius: 2px;
background: var(--mat-sys-surface-variant);
overflow: hidden;
&-fill {
height: 100%;
background: var(--mat-sys-primary);
transition: width 0.2s ease-in-out;
}
}
}
.adf-form-side-nav-list {
padding-top: 0;
}
.adf-form-side-nav-item {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
&-title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-toggle {
width: 28px;
height: 28px;
flex: 0 0 auto;
}
&-icon {
flex: 0 0 auto;
}
&-status {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
&-error {
color: var(--mat-sys-error);
}
&-complete {
color: var(--mat-sys-primary);
}
}
&-required-count {
flex: 0 0 auto;
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
}
&-active {
background: var(--mat-sys-secondary-container);
font-weight: 600;
}
&-error {
color: var(--mat-sys-error);
}
}
.adf-form-side-nav-content {
padding: 8px 16px;
}
@@ -0,0 +1,172 @@
/*!
* @license
* Copyright © 2005-2026 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 { BreakpointObserver } from '@angular/cdk/layout';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { UnitTestingUtils } from '../../../testing';
import { ContainerModel } from '../widgets/core/container.model';
import { FormFieldModel } from '../widgets/core/form-field.model';
import { FormModel } from '../widgets/core/form.model';
import { TabModel } from '../widgets/core/tab.model';
import { FormSideNavComponent } from './form-side-nav.component';
describe('FormSideNavComponent', () => {
let fixture: ComponentFixture<FormSideNavComponent>;
let component: FormSideNavComponent;
let testingUtils: UnitTestingUtils;
let breakpointObserverStub: { observe: jasmine.Spy };
const buildNode = (json: any): TabModel => new TabModel(new FormModel(), json);
beforeEach(() => {
breakpointObserverStub = { observe: jasmine.createSpy('observe').and.returnValue(of({ matches: false })) };
TestBed.configureTestingModule({
imports: [FormSideNavComponent],
providers: [{ provide: BreakpointObserver, useValue: breakpointObserverStub }]
});
fixture = TestBed.createComponent(FormSideNavComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
});
afterEach(() => {
fixture.destroy();
});
it('should render one navigation item per visible top-level node', () => {
fixture.componentRef.setInput('nodes', [buildNode({ id: 'a', title: 'A' }), buildNode({ id: 'b', title: 'B' })]);
fixture.detectChanges();
expect(testingUtils.getAllByCSS('.adf-form-side-nav-item').length).toBe(2);
});
it('should not render a hidden top-level node', () => {
const hiddenNode = buildNode({ id: 'hidden', title: 'Hidden' });
hiddenNode.isVisible = false;
fixture.componentRef.setInput('nodes', [buildNode({ id: 'visible', title: 'Visible' }), hiddenNode]);
fixture.detectChanges();
expect(testingUtils.getAllByCSS('.adf-form-side-nav-item').length).toBe(1);
});
it('should emit sectionSelected when a leaf node is clicked', () => {
const leaf = buildNode({ id: 'leaf', title: 'Leaf' });
fixture.componentRef.setInput('nodes', [leaf]);
fixture.detectChanges();
const emitSpy = jasmine.createSpy('sectionSelected');
component.sectionSelected.subscribe(emitSpy);
testingUtils.clickByCSS('.adf-form-side-nav-item');
expect(emitSpy).toHaveBeenCalledWith(leaf);
});
it('should toggle expansion instead of emitting selection when a non-tabbed category node is clicked', () => {
const category = buildNode({ id: 'category', title: 'Category', children: [{ id: 'child', title: 'Child' }] });
fixture.componentRef.setInput('nodes', [category]);
fixture.detectChanges();
const emitSpy = jasmine.createSpy('sectionSelected');
component.sectionSelected.subscribe(emitSpy);
expect(component.isExpanded(category)).toBeFalse();
testingUtils.clickByCSS('.adf-form-side-nav-item');
expect(component.isExpanded(category)).toBeTrue();
expect(emitSpy).not.toHaveBeenCalled();
});
it('should emit sectionSelected when a tabbed-children category node is clicked', () => {
const tabbedCategory = buildNode({
id: 'tabbed-category',
title: 'Tabbed Category',
childrenLayout: 'tabs',
children: [{ id: 'child', title: 'Child' }]
});
fixture.componentRef.setInput('nodes', [tabbedCategory]);
fixture.detectChanges();
const emitSpy = jasmine.createSpy('sectionSelected');
component.sectionSelected.subscribe(emitSpy);
testingUtils.clickByCSS('.adf-form-side-nav-item');
expect(emitSpy).toHaveBeenCalledWith(tabbedCategory);
});
it('should mark the active node as active', () => {
const node = buildNode({ id: 'active-node', title: 'Active' });
fixture.componentRef.setInput('nodes', [node]);
fixture.componentRef.setInput('activeNodeId', 'active-node');
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-form-side-nav-item-active')).toBeTruthy();
});
it('should expand the ancestors of the active node when it changes', () => {
const category = buildNode({ id: 'category', title: 'Category', children: [{ id: 'child', title: 'Child' }] });
fixture.componentRef.setInput('nodes', [category]);
fixture.componentRef.setInput('activeNodeId', 'child');
fixture.detectChanges();
expect(component.isExpanded(category)).toBeTrue();
});
it('should compute total and completed section counts', () => {
const form = new FormModel();
const completedField = new FormFieldModel(form, { id: 'f1', required: true, value: 'value' });
const incompleteField = new FormFieldModel(form, { id: 'f2', required: true });
const completedLeaf = buildNode({ id: 'completed', title: 'Completed' });
completedLeaf.fields = [new ContainerModel(completedField)];
const incompleteLeaf = buildNode({ id: 'incomplete', title: 'Incomplete' });
incompleteLeaf.fields = [new ContainerModel(incompleteField)];
fixture.componentRef.setInput('nodes', [completedLeaf, incompleteLeaf]);
fixture.detectChanges();
expect(component.totalSections).toBe(2);
expect(component.completedSections).toBe(1);
});
it('should toggle the drawer state', () => {
fixture.componentRef.setInput('nodes', []);
fixture.detectChanges();
const initialState = (component as any).drawerOpened;
component.toggleDrawer();
expect((component as any).drawerOpened).toBe(!initialState);
});
it('should show a menu toggle button on small screens', () => {
breakpointObserverStub.observe.and.returnValue(of({ matches: true }));
fixture = TestBed.createComponent(FormSideNavComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
fixture.componentRef.setInput('nodes', []);
fixture.detectChanges();
expect(testingUtils.getByCSS('.adf-form-side-nav-toggle')).toBeTruthy();
});
});
@@ -0,0 +1,140 @@
/*!
* @license
* Copyright © 2005-2026 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 { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { NgTemplateOutlet } from '@angular/common';
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { map } from 'rxjs/operators';
import { IconModule } from '../../../icon/icon.module';
import { TabModel } from '../widgets/core/tab.model';
@Component({
selector: 'adf-form-side-nav',
templateUrl: './form-side-nav.component.html',
styleUrl: './form-side-nav.component.scss',
encapsulation: ViewEncapsulation.None,
imports: [MatSidenavModule, MatListModule, MatButtonModule, MatTooltipModule, IconModule, TranslatePipe, NgTemplateOutlet]
})
export class FormSideNavComponent implements OnChanges {
private readonly breakpointObserver = inject(BreakpointObserver);
@Input()
nodes: TabModel[] = [];
@Input()
activeNodeId: string;
@Output()
sectionSelected = new EventEmitter<TabModel>();
protected readonly isSmallScreen = toSignal(
this.breakpointObserver.observe([Breakpoints.XSmall, Breakpoints.Small]).pipe(map(({ matches }) => matches)),
{ initialValue: false }
);
protected expandedNodeIds = new Set<string>();
protected drawerOpened = true;
ngOnChanges(changes: SimpleChanges): void {
if (changes.activeNodeId) {
this.expandAncestorsOf(this.activeNodeId);
}
}
get totalSections(): number {
return this.countLeafNodes(this.nodes);
}
get completedSections(): number {
return this.countLeafNodes(this.nodes, (node) => node.isComplete());
}
isExpanded(node: TabModel): boolean {
return this.expandedNodeIds.has(node.id);
}
toggleExpanded(node: TabModel): void {
if (this.expandedNodeIds.has(node.id)) {
this.expandedNodeIds.delete(node.id);
} else {
this.expandedNodeIds.add(node.id);
}
}
isActive(node: TabModel): boolean {
return node.id === this.activeNodeId;
}
selectSection(node: TabModel): void {
this.sectionSelected.emit(node);
if (this.isSmallScreen()) {
this.drawerOpened = false;
}
}
handleNodeClick(node: TabModel): void {
if (node.hasChildren() && !node.hasTabbedChildren()) {
this.toggleExpanded(node);
if (!node.hasContent()) {
return;
}
}
this.selectSection(node);
}
toggleDrawer(): void {
this.drawerOpened = !this.drawerOpened;
}
private expandAncestorsOf(nodeId: string, nodes: TabModel[] = this.nodes): boolean {
for (const node of nodes) {
if (node.id === nodeId) {
return true;
}
if (this.expandAncestorsOf(nodeId, node.children)) {
this.expandedNodeIds.add(node.id);
return true;
}
}
return false;
}
private countLeafNodes(nodes: TabModel[], predicate: (node: TabModel) => boolean = () => true): number {
return nodes.reduce((count, node) => {
if (!node.isVisible) {
return count;
}
if (node.hasChildren() && !node.hasTabbedChildren()) {
return count + this.countLeafNodes(node.children, predicate);
}
return predicate(node) ? count + 1 : count;
}, 0);
}
}
@@ -41,6 +41,9 @@ export interface ConfirmMessage {
show: boolean;
message: string;
}
export type FormLayoutMode = 'tabs' | 'sidenav';
export interface FormRepresentationModel {
[key: string]: any;
@@ -55,6 +58,7 @@ export interface FormRepresentationModel {
selectedOutcome?: string;
fields?: any[];
tabs?: any[];
layout?: string;
outcomes?: any[];
formDefinition?: {
[key: string]: any;
@@ -77,6 +81,7 @@ export class FormModel implements ProcessFormModel {
readonly processDefinitionId: string;
readonly enableFixedSpace: boolean;
readonly displayMode: any;
readonly layout: FormLayoutMode = 'tabs';
fieldsCache: FormFieldModel[] = [];
@@ -126,6 +131,7 @@ export class FormModel implements ProcessFormModel {
this.confirmMessage = json.confirmMessage || {};
this.displayMode = json.displayMode;
this.theme = json.theme || json.formDefinition?.theme;
this.layout = json.layout || json.formDefinition?.layout || 'tabs';
this.tabs = (json.tabs || []).map((tabJson) => new TabModel(this, tabJson));
@@ -235,7 +241,7 @@ export class FormModel implements ProcessFormModel {
}
if (field.tab) {
const tab = this.tabs.find((currentTab) => field.tab === currentTab.id);
const tab = this.findTabById(field.tab);
if (tab) {
tab.fields.push(currentRootElement);
}
@@ -344,6 +350,33 @@ export class FormModel implements ProcessFormModel {
return this.tabs && this.tabs.length > 0;
}
/**
* Indicates whether the form should be rendered using the hierarchical side navigation
* layout instead of the classic tabs layout.
*
* @returns true when the form has tabs and its layout is set to `sidenav`
*/
hasSideNav(): boolean {
return this.layout === 'sidenav' && this.hasTabs();
}
/**
* Recursively searches the tabs tree (including nested children) for a node matching the given id.
*
* @param tabId id of the tab/section to find
* @returns the matching `TabModel`, or `undefined` when not found
*/
findTabById(tabId: string): TabModel | undefined {
for (const tab of this.tabs) {
const found = tab.findTabById(tabId);
if (found) {
return found;
}
}
return undefined;
}
hasFields(): boolean {
return this.fields && this.fields.length > 0;
}
@@ -69,4 +69,95 @@ describe('TabModel', () => {
const model = new TabModel(null, json);
expect(model.json).toBe(json);
});
it('should default to sidenav children layout when not specified', () => {
const model = new TabModel(null, { id: 'parent' });
expect(model.childrenLayout).toBe('sidenav');
});
it('should parse children nodes and childrenLayout from json', () => {
const json = {
id: 'parent',
title: 'Parent',
childrenLayout: 'tabs',
children: [
{ id: 'child1', title: 'Child 1' },
{ id: 'child2', title: 'Child 2' }
]
};
const model = new TabModel(null, json);
expect(model.childrenLayout).toBe('tabs');
expect(model.children.length).toBe(2);
expect(model.children[0].id).toBe('child1');
expect(model.children[1].id).toBe('child2');
expect(model.hasChildren()).toBeTruthy();
expect(model.hasTabbedChildren()).toBeTruthy();
});
it('should not consider children as tabbed when childrenLayout is sidenav', () => {
const json = {
id: 'parent',
children: [{ id: 'child1' }]
};
const model = new TabModel(null, json);
expect(model.hasTabbedChildren()).toBeFalsy();
});
it('should find a nested tab by id', () => {
const json = {
id: 'root',
children: [
{
id: 'child1',
children: [{ id: 'grandchild1' }]
},
{ id: 'child2' }
]
};
const model = new TabModel(null, json);
expect(model.findTabById('root')).toBe(model);
expect(model.findTabById('child2')).toBe(model.children[1]);
expect(model.findTabById('grandchild1')).toBe(model.children[0].children[0]);
expect(model.findTabById('unknown')).toBeUndefined();
});
it('should filter visible children', () => {
const model = new TabModel(null, {
id: 'root',
children: [{ id: 'visible-child' }, { id: 'hidden-child' }]
});
model.children[1].isVisible = false;
const visibleChildren = model.visibleChildren();
expect(visibleChildren.length).toBe(1);
expect(visibleChildren[0].id).toBe('visible-child');
});
it('should collect own and nested fields, and compute completion/error state', () => {
const form = new FormModel();
const requiredField = new FormFieldModel(form, { id: 'required-field', required: true });
const requiredFieldFilled = new FormFieldModel(form, { id: 'required-field-filled', required: true, value: 'value' });
const model = new TabModel(form, { id: 'root' });
model.fields = [new ContainerModel(requiredField)];
const childModel = new TabModel(form, { id: 'child' });
childModel.fields = [new ContainerModel(requiredFieldFilled)];
model.children = [childModel];
expect(model.getOwnFields()).toEqual([requiredField]);
expect(model.getAllFields()).toEqual([requiredField, requiredFieldFilled]);
expect(model.getRequiredFieldsCount()).toBe(2);
expect(model.getCompletedRequiredFieldsCount()).toBe(1);
expect(model.isComplete()).toBeFalsy();
expect(childModel.isComplete()).toBeTruthy();
requiredField.markAsInvalid();
expect(model.hasErrors()).toBeTruthy();
expect(childModel.hasErrors()).toBeFalsy();
});
});
@@ -16,18 +16,30 @@
*/
import { WidgetVisibilityModel } from '../../../models/widget-visibility.model';
import { ContainerModel } from './container.model';
import { FormFieldModel } from './form-field.model';
import { FormFieldTypes } from './form-field-types';
import { FormWidgetModel } from './form-widget.model';
export type TabChildrenLayout = 'sidenav' | 'tabs';
export class TabModel extends FormWidgetModel {
title: string;
isVisible: boolean = true;
visibilityCondition: WidgetVisibilityModel;
icon: string;
order: number;
/**
* Controls how the `children` of this node are rendered when the form uses the `sidenav` layout:
* - `sidenav` (default): children are shown as further expandable entries in the side navigation tree.
* - `tabs`: children are hidden from the side navigation tree and instead rendered as a `mat-tab-group`
* inside this node's content pane, allowing a sidenav entry to internally group its sub-sections as tabs.
*/
childrenLayout: TabChildrenLayout = 'sidenav';
fields: FormWidgetModel[] = [];
hasContent(): boolean {
return this.fields && this.fields.length > 0;
}
children: TabModel[] = [];
constructor(form: any, json?: any) {
super(form, json);
@@ -35,6 +47,136 @@ export class TabModel extends FormWidgetModel {
if (json) {
this.title = json.title;
this.visibilityCondition = new WidgetVisibilityModel(json.visibilityCondition);
this.icon = json.icon;
this.order = json.order;
this.childrenLayout = json.childrenLayout === 'tabs' ? 'tabs' : 'sidenav';
this.children = (json.children || json.subTabs || []).map((childJson) => new TabModel(form, childJson));
}
}
hasContent(): boolean {
return this.fields && this.fields.length > 0;
}
hasChildren(): boolean {
return this.children && this.children.length > 0;
}
/**
* Indicates whether this node's children should be rendered as tabs, within this node's content pane,
* instead of as further nested side navigation entries.
*
* @returns true when this node has children and they are configured to render as tabs
*/
hasTabbedChildren(): boolean {
return this.childrenLayout === 'tabs' && this.hasChildren();
}
/**
* Returns the direct children of this node that are currently visible.
*
* @returns list of visible child nodes
*/
visibleChildren(): TabModel[] {
return (this.children || []).filter((child) => child.isVisible);
}
/**
* Recursively looks for a node (this tab or one of its descendants) matching the given id.
*
* @param tabId id of the tab/section to find
* @returns the matching `TabModel`, or `undefined` when not found
*/
findTabById(tabId: string): TabModel | undefined {
if (this.id === tabId) {
return this;
}
for (const child of this.children) {
const found = child.findTabById(tabId);
if (found) {
return found;
}
}
return undefined;
}
/**
* Collects all the form fields owned by this node, excluding descendants.
*
* @returns list of form fields directly assigned to this node
*/
getOwnFields(): FormFieldModel[] {
const collected: FormFieldModel[] = [];
this.collectFields(this.fields, collected);
return collected;
}
/**
* Collects all the form fields owned by this node and its descendants.
*
* @returns list of form fields assigned to this node or any of its children
*/
getAllFields(): FormFieldModel[] {
return this.children.reduce((fields, child) => [...fields, ...child.getAllFields()], this.getOwnFields());
}
/**
* Indicates whether this node, or any of its descendants, contains an invalid field.
*
* @returns true when at least one field is invalid
*/
hasErrors(): boolean {
return this.getOwnFields().some((field) => !field.isValid) || this.children.some((child) => child.hasErrors());
}
/**
* Indicates whether all the required fields owned by this node, and its descendants, are filled in.
*
* @returns true when the node (and its descendants) has no incomplete required field
*/
isComplete(): boolean {
const ownFieldsComplete = this.getOwnFields()
.filter((field) => field.required)
.every((field) => !this.isFieldEmpty(field));
return ownFieldsComplete && this.children.every((child) => child.isComplete());
}
/**
* Total count of required fields owned by this node and its descendants.
*
* @returns number of required fields
*/
getRequiredFieldsCount(): number {
return this.getAllFields().filter((field) => field.required).length;
}
/**
* Count of required fields, owned by this node and its descendants, that have a value.
*
* @returns number of completed required fields
*/
getCompletedRequiredFieldsCount(): number {
return this.getAllFields().filter((field) => field.required && !this.isFieldEmpty(field)).length;
}
private isFieldEmpty(field: FormFieldModel): boolean {
return field.value === undefined || field.value === null || field.value === '';
}
private collectFields(fields: FormWidgetModel[], collected: FormFieldModel[]): void {
(fields || []).forEach((field) => {
if (field instanceof ContainerModel) {
collected.push(field.field);
(field.columns || []).forEach((column) => this.collectFields(column.fields, collected));
} else if (field instanceof FormFieldModel) {
collected.push(field);
if (field.type === FormFieldTypes.SECTION) {
(field.columns || []).forEach((column) => this.collectFields(column.fields, collected));
}
}
});
}
}
@@ -41,7 +41,7 @@ export class WidgetVisibilityService {
if (form) {
if (form.tabs?.length > 0) {
form.tabs.map((tabModel) => this.refreshEntityVisibility(tabModel));
form.tabs.map((tabModel) => this.refreshTabVisibility(tabModel));
}
if (form.outcomes?.length > 0) {
@@ -58,6 +58,11 @@ export class WidgetVisibilityService {
element.isVisible = this.isParentTabVisible(this.form, element) && this.evaluateVisibility(element.form, element.visibilityCondition);
}
private refreshTabVisibility(tabModel: TabModel) {
this.refreshEntityVisibility(tabModel);
tabModel.children?.forEach((childTab) => this.refreshTabVisibility(childTab));
}
private refreshOutcomeVisibility(element: FormOutcomeModel) {
element.isVisible = this.evaluateVisibility(element.form, element.visibilityCondition);
}
+9
View File
@@ -85,6 +85,15 @@
"MESSAGE": "Are you sure you want to delete this row?",
"YES_LABEL": "Delete row",
"NO_LABEL": "Cancel"
},
"SIDE_NAV": {
"PROGRESS": "{{ completed }} of {{ total }} sections complete",
"TOGGLE_MENU": "Toggle navigation menu",
"EXPAND": "Expand section",
"COLLAPSE": "Collapse section",
"HAS_ERRORS": "This section contains validation errors",
"COMPLETE": "This section is complete",
"REQUIRED_FIELDS": "{{ count }} required fields"
}
},
"BUTTON": {