mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
Add hierarchical side-navigation form layout with mixed tabs support
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user