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"
|
<div id="adf-form-renderer" class="{{ formDefinition.className }} adf-form-renderer"
|
||||||
[ngClass]="{ 'adf-readonly-form': formDefinition.readOnly }">
|
[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()) {
|
@if (hasTabs()) {
|
||||||
<div class="alfresco-tabs-widget">
|
<div class="alfresco-tabs-widget">
|
||||||
<mat-tab-group [preserveContent]="true">
|
<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 {
|
.mat-mdc-card-content:first-child {
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,74 @@ const buildTabbedForm = (tabCount: number, hiddenTabIndices: number[] = []): For
|
|||||||
return form;
|
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', () => {
|
describe('Form Renderer Component', () => {
|
||||||
let formRendererComponent: FormRendererComponent<any>;
|
let formRendererComponent: FormRendererComponent<any>;
|
||||||
let fixture: ComponentFixture<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', () => {
|
describe('Repeatable section', () => {
|
||||||
const repeatableSectionField = new FormFieldModel(new FormModel(), {
|
const repeatableSectionField = new FormFieldModel(new FormModel(), {
|
||||||
id: 'RepeatableSection0tbw2y',
|
id: 'RepeatableSection0tbw2y',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { FORM_FIELD_MODEL_RENDER_MIDDLEWARE, FormFieldModelRenderMiddleware } fr
|
|||||||
import { ContainerModel, FormFieldModel, FormModel, TabModel, RepeatWidgetComponent } from './widgets';
|
import { ContainerModel, FormFieldModel, FormModel, TabModel, RepeatWidgetComponent } from './widgets';
|
||||||
import { HeaderWidgetComponent } from './widgets/header/header.widget';
|
import { HeaderWidgetComponent } from './widgets/header/header.widget';
|
||||||
import { FormSectionComponent } from './form-section/form-section.component';
|
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 { DecimalRenderMiddlewareService } from './middlewares/decimal-middleware.service';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ConfirmDialogComponent } from '../../../lib/dialogs/confirm-dialog/confirm.dialog';
|
import { ConfirmDialogComponent } from '../../../lib/dialogs/confirm-dialog/confirm.dialog';
|
||||||
@@ -79,6 +80,7 @@ import { RepeatableRowLabelPipe } from '../pipes/repeatable-row-label.pipe';
|
|||||||
NgClass,
|
NgClass,
|
||||||
HeaderWidgetComponent,
|
HeaderWidgetComponent,
|
||||||
FormSectionComponent,
|
FormSectionComponent,
|
||||||
|
FormSideNavComponent,
|
||||||
RepeatWidgetComponent,
|
RepeatWidgetComponent,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
RepeatableRowLabelPipe
|
RepeatableRowLabelPipe
|
||||||
@@ -99,6 +101,7 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
|
|||||||
set formDefinition(formDefinition: FormModel) {
|
set formDefinition(formDefinition: FormModel) {
|
||||||
this._formDefinition = formDefinition;
|
this._formDefinition = formDefinition;
|
||||||
this.syncCurrentTabIndex();
|
this.syncCurrentTabIndex();
|
||||||
|
this.syncActiveSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
get formDefinition(): FormModel {
|
get formDefinition(): FormModel {
|
||||||
@@ -124,6 +127,7 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly currentTabIndex = signal(0);
|
private readonly currentTabIndex = signal(0);
|
||||||
|
private readonly activeSection = signal<TabModel | undefined>(undefined);
|
||||||
private _formDefinition: FormModel;
|
private _formDefinition: FormModel;
|
||||||
private _tabGroup?: MatTabGroup;
|
private _tabGroup?: MatTabGroup;
|
||||||
private tabGroupSelectionSubscription?: Subscription;
|
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),
|
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.formDefinition?.id),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
)
|
)
|
||||||
.subscribe(() => this.visibilityService.refreshVisibility(this.formDefinition));
|
.subscribe(() => {
|
||||||
|
this.visibilityService.refreshVisibility(this.formDefinition);
|
||||||
|
this.syncActiveSection();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@@ -175,6 +182,53 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
|
|||||||
return this.formDefinition?.tabs?.filter((tab) => tab.isVisible) ?? [];
|
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 {
|
navigateToNextTab(): void {
|
||||||
if (this.tabGroup && this.canNavigateNext) {
|
if (this.tabGroup && this.canNavigateNext) {
|
||||||
this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) + 1;
|
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;
|
show: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormLayoutMode = 'tabs' | 'sidenav';
|
||||||
|
|
||||||
export interface FormRepresentationModel {
|
export interface FormRepresentationModel {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ export interface FormRepresentationModel {
|
|||||||
selectedOutcome?: string;
|
selectedOutcome?: string;
|
||||||
fields?: any[];
|
fields?: any[];
|
||||||
tabs?: any[];
|
tabs?: any[];
|
||||||
|
layout?: string;
|
||||||
outcomes?: any[];
|
outcomes?: any[];
|
||||||
formDefinition?: {
|
formDefinition?: {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -77,6 +81,7 @@ export class FormModel implements ProcessFormModel {
|
|||||||
readonly processDefinitionId: string;
|
readonly processDefinitionId: string;
|
||||||
readonly enableFixedSpace: boolean;
|
readonly enableFixedSpace: boolean;
|
||||||
readonly displayMode: any;
|
readonly displayMode: any;
|
||||||
|
readonly layout: FormLayoutMode = 'tabs';
|
||||||
|
|
||||||
fieldsCache: FormFieldModel[] = [];
|
fieldsCache: FormFieldModel[] = [];
|
||||||
|
|
||||||
@@ -126,6 +131,7 @@ export class FormModel implements ProcessFormModel {
|
|||||||
this.confirmMessage = json.confirmMessage || {};
|
this.confirmMessage = json.confirmMessage || {};
|
||||||
this.displayMode = json.displayMode;
|
this.displayMode = json.displayMode;
|
||||||
this.theme = json.theme || json.formDefinition?.theme;
|
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));
|
this.tabs = (json.tabs || []).map((tabJson) => new TabModel(this, tabJson));
|
||||||
|
|
||||||
@@ -235,7 +241,7 @@ export class FormModel implements ProcessFormModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.tab) {
|
if (field.tab) {
|
||||||
const tab = this.tabs.find((currentTab) => field.tab === currentTab.id);
|
const tab = this.findTabById(field.tab);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
tab.fields.push(currentRootElement);
|
tab.fields.push(currentRootElement);
|
||||||
}
|
}
|
||||||
@@ -344,6 +350,33 @@ export class FormModel implements ProcessFormModel {
|
|||||||
return this.tabs && this.tabs.length > 0;
|
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 {
|
hasFields(): boolean {
|
||||||
return this.fields && this.fields.length > 0;
|
return this.fields && this.fields.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,4 +69,95 @@ describe('TabModel', () => {
|
|||||||
const model = new TabModel(null, json);
|
const model = new TabModel(null, json);
|
||||||
expect(model.json).toBe(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 { 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';
|
import { FormWidgetModel } from './form-widget.model';
|
||||||
|
|
||||||
|
export type TabChildrenLayout = 'sidenav' | 'tabs';
|
||||||
|
|
||||||
export class TabModel extends FormWidgetModel {
|
export class TabModel extends FormWidgetModel {
|
||||||
title: string;
|
title: string;
|
||||||
isVisible: boolean = true;
|
isVisible: boolean = true;
|
||||||
visibilityCondition: WidgetVisibilityModel;
|
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[] = [];
|
fields: FormWidgetModel[] = [];
|
||||||
|
children: TabModel[] = [];
|
||||||
hasContent(): boolean {
|
|
||||||
return this.fields && this.fields.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(form: any, json?: any) {
|
constructor(form: any, json?: any) {
|
||||||
super(form, json);
|
super(form, json);
|
||||||
@@ -35,6 +47,136 @@ export class TabModel extends FormWidgetModel {
|
|||||||
if (json) {
|
if (json) {
|
||||||
this.title = json.title;
|
this.title = json.title;
|
||||||
this.visibilityCondition = new WidgetVisibilityModel(json.visibilityCondition);
|
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) {
|
||||||
if (form.tabs?.length > 0) {
|
if (form.tabs?.length > 0) {
|
||||||
form.tabs.map((tabModel) => this.refreshEntityVisibility(tabModel));
|
form.tabs.map((tabModel) => this.refreshTabVisibility(tabModel));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.outcomes?.length > 0) {
|
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);
|
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) {
|
private refreshOutcomeVisibility(element: FormOutcomeModel) {
|
||||||
element.isVisible = this.evaluateVisibility(element.form, element.visibilityCondition);
|
element.isVisible = this.evaluateVisibility(element.form, element.visibilityCondition);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,15 @@
|
|||||||
"MESSAGE": "Are you sure you want to delete this row?",
|
"MESSAGE": "Are you sure you want to delete this row?",
|
||||||
"YES_LABEL": "Delete row",
|
"YES_LABEL": "Delete row",
|
||||||
"NO_LABEL": "Cancel"
|
"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": {
|
"BUTTON": {
|
||||||
|
|||||||
Reference in New Issue
Block a user