diff --git a/lib/core/src/lib/form/components/form-renderer.component.html b/lib/core/src/lib/form/components/form-renderer.component.html index 337ad73c58..76d41b9c2a 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.html +++ b/lib/core/src/lib/form/components/form-renderer.component.html @@ -1,6 +1,33 @@
- @if (formDefinition.hasTabs()) { + @if (formDefinition.hasSideNav()) { + + @if (activeSideNavSection(); as activeSection) { + @if (activeSection.hasTabbedChildren()) { +
+ + @for (childSection of activeSection.visibleChildren(); track childSection.id) { + + +
+ +
+
+
+ } +
+
+ } @else { + + } + } +
+ } @else if (formDefinition.hasTabs()) { @if (hasTabs()) {
diff --git a/lib/core/src/lib/form/components/form-renderer.component.scss b/lib/core/src/lib/form/components/form-renderer.component.scss index 956c09af4d..031f0c74c2 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.scss +++ b/lib/core/src/lib/form/components/form-renderer.component.scss @@ -40,6 +40,12 @@ } } +.adf-form-side-nav { + display: block; + width: 100%; + min-height: 300px; +} + .mat-mdc-card-content:first-child { padding-top: 1em; } diff --git a/lib/core/src/lib/form/components/form-renderer.component.spec.ts b/lib/core/src/lib/form/components/form-renderer.component.spec.ts index 8dd312ea9a..495aabe754 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.spec.ts +++ b/lib/core/src/lib/form/components/form-renderer.component.spec.ts @@ -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; let fixture: ComponentFixture>; @@ -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', diff --git a/lib/core/src/lib/form/components/form-renderer.component.ts b/lib/core/src/lib/form/components/form-renderer.component.ts index 8817bfb4ad..025475604d 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.ts +++ b/lib/core/src/lib/form/components/form-renderer.component.ts @@ -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 implements OnInit, OnDestroy { set formDefinition(formDefinition: FormModel) { this._formDefinition = formDefinition; this.syncCurrentTabIndex(); + this.syncActiveSection(); } get formDefinition(): FormModel { @@ -124,6 +127,7 @@ export class FormRendererComponent implements OnInit, OnDestroy { } private readonly currentTabIndex = signal(0); + private readonly activeSection = signal(undefined); private _formDefinition: FormModel; private _tabGroup?: MatTabGroup; private tabGroupSelectionSubscription?: Subscription; @@ -159,7 +163,10 @@ export class FormRendererComponent 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 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; diff --git a/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.html b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.html new file mode 100644 index 0000000000..e2594fe39c --- /dev/null +++ b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.html @@ -0,0 +1,101 @@ + + +
+ + {{ 'FORM.FORM_RENDERER.SIDE_NAV.PROGRESS' | translate: { completed: completedSections, total: totalSections } }} + +
+
+
+
+ + + + +
+ + + @if (isSmallScreen()) { + + } + + + +
+ + + @for (node of nodes; track node.id) { + @if (node.isVisible) { + + @if (node.hasChildren() && !node.hasTabbedChildren()) { + + } + + @if (node.icon) { + + } + + {{ node.title | translate }} + + @if (node.hasErrors()) { + + } @else if (node.getRequiredFieldsCount() > 0 && node.isComplete()) { + + } @else if (node.getRequiredFieldsCount() > 0) { + + {{ node.getCompletedRequiredFieldsCount() }}/{{ node.getRequiredFieldsCount() }} + + } + + + @if (node.hasChildren() && !node.hasTabbedChildren() && isExpanded(node)) { + + } + } + } + diff --git a/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.scss b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.scss new file mode 100644 index 0000000000..48e848d2f4 --- /dev/null +++ b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.scss @@ -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; +} diff --git a/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.spec.ts b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.spec.ts new file mode 100644 index 0000000000..66e479c2eb --- /dev/null +++ b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.ts b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.ts new file mode 100644 index 0000000000..a179a2f46f --- /dev/null +++ b/lib/core/src/lib/form/components/form-side-nav/form-side-nav.component.ts @@ -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(); + + protected readonly isSmallScreen = toSignal( + this.breakpointObserver.observe([Breakpoints.XSmall, Breakpoints.Small]).pipe(map(({ matches }) => matches)), + { initialValue: false } + ); + + protected expandedNodeIds = new Set(); + 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); + } +} diff --git a/lib/core/src/lib/form/components/widgets/core/form.model.ts b/lib/core/src/lib/form/components/widgets/core/form.model.ts index 2fea8ce8dd..753bcaae03 100644 --- a/lib/core/src/lib/form/components/widgets/core/form.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form.model.ts @@ -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; } diff --git a/lib/core/src/lib/form/components/widgets/core/tab.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/tab.model.spec.ts index 90e1a6d822..fcde6dd70a 100644 --- a/lib/core/src/lib/form/components/widgets/core/tab.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/tab.model.spec.ts @@ -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(); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/core/tab.model.ts b/lib/core/src/lib/form/components/widgets/core/tab.model.ts index 9932090b4a..b65f230547 100644 --- a/lib/core/src/lib/form/components/widgets/core/tab.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/tab.model.ts @@ -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)); + } + } + }); + } } diff --git a/lib/core/src/lib/form/services/widget-visibility.service.ts b/lib/core/src/lib/form/services/widget-visibility.service.ts index a7d1ea039d..ba3be7db05 100644 --- a/lib/core/src/lib/form/services/widget-visibility.service.ts +++ b/lib/core/src/lib/form/services/widget-visibility.service.ts @@ -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); } diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index db470df2ab..ec75f12a1c 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -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": {