diff --git a/lib/core/src/lib/form/components/form-renderer.component.spec.ts b/lib/core/src/lib/form/components/form-renderer.component.spec.ts index 2bdb309182..f454290ae0 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.spec.ts +++ b/lib/core/src/lib/form/components/form-renderer.component.spec.ts @@ -86,6 +86,30 @@ const expectElementToBeValid = (testingUtils: UnitTestingUtils, fieldId: string) expect(invalidElementContainer).toBeFalsy(); }; +const buildTabbedForm = (tabCount: number, hiddenTabIndices: number[] = []): FormModel => { + const tabs = Array.from({ length: tabCount }, (_, i) => ({ + id: `tab-${i}`, + title: `Tab ${i}` + })); + const fields = tabs.map((tab) => ({ + id: `container-${tab.id}`, + type: 'container', + tab: tab.id, + numberOfColumns: 1, + fields: { 1: [{ id: `text-${tab.id}`, type: 'text', name: `Text in ${tab.title}` }] } + })); + + const form = new FormModel({ tabs, fields }); + + hiddenTabIndices.forEach((tabIndex) => { + if (form.tabs[tabIndex]) { + form.tabs[tabIndex].isVisible = false; + } + }); + + return form; +}; + describe('Form Renderer Component', () => { let formRendererComponent: FormRendererComponent; let fixture: ComponentFixture>; @@ -909,6 +933,117 @@ describe('Form Renderer Component', () => { }); }); + describe('Tab navigation', () => { + describe('visibleTabs', () => { + it('should return only tabs where isVisible is true', () => { + formRendererComponent.formDefinition = buildTabbedForm(3, [1]); + expect(formRendererComponent.visibleTabs().length).toBe(2); + expect(formRendererComponent.visibleTabs().every((t) => t.isVisible)).toBeTrue(); + }); + + it('should return all tabs when none are hidden', () => { + formRendererComponent.formDefinition = buildTabbedForm(3); + expect(formRendererComponent.visibleTabs().length).toBe(3); + }); + + it('should return empty array when all tabs are hidden', () => { + formRendererComponent.formDefinition = buildTabbedForm(3, [0, 1, 2]); + expect(formRendererComponent.visibleTabs().length).toBe(0); + }); + }); + + describe('canNavigateNext and canNavigatePrevious', () => { + beforeEach(() => { + fixture.componentRef.setInput('formDefinition', buildTabbedForm(3)); + fixture.detectChanges(); + }); + + it('should not allow navigating previous on the first tab', () => { + expect(formRendererComponent.canNavigatePrevious).toBeFalse(); + expect(formRendererComponent.canNavigateNext).toBeTrue(); + }); + + it('should allow navigating previous and next between the first and last tabs', async () => { + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(formRendererComponent.canNavigatePrevious).toBeTrue(); + expect(formRendererComponent.canNavigateNext).toBeTrue(); + }); + + it('should not allow navigating next on the last tab', async () => { + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + formRendererComponent.tabGroup.selectedIndexChange.emit(1); + fixture.detectChanges(); + + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + formRendererComponent.tabGroup.selectedIndexChange.emit(2); + fixture.detectChanges(); + + expect(formRendererComponent.canNavigatePrevious).toBeTrue(); + expect(formRendererComponent.canNavigateNext).toBeFalse(); + }); + }); + + describe('navigateToNextTab and navigateToPreviousTab', () => { + beforeEach(() => { + fixture.componentRef.setInput('formDefinition', buildTabbedForm(3)); + fixture.detectChanges(); + expect(formRendererComponent.tabGroup).toBeDefined(); + }); + + it('should increment selectedIndex when navigating to next tab', async () => { + const initialIndex = formRendererComponent.tabGroup.selectedIndex; + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(formRendererComponent.tabGroup.selectedIndex).toBe(initialIndex + 1); + }); + + it('should decrement selectedIndex when navigating to previous tab', async () => { + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + const indexAfterNext = formRendererComponent.tabGroup.selectedIndex; + formRendererComponent.navigateToPreviousTab(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(formRendererComponent.tabGroup.selectedIndex).toBe(indexAfterNext - 1); + }); + + it('should not go below 0 when navigating previous on the first tab', async () => { + formRendererComponent.navigateToPreviousTab(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(formRendererComponent.tabGroup.selectedIndex).toBe(0); + }); + + it('should not exceed last index when navigating next on the last tab', async () => { + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + formRendererComponent.tabGroup.selectedIndexChange.emit(1); + fixture.detectChanges(); + + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + formRendererComponent.tabGroup.selectedIndexChange.emit(2); + fixture.detectChanges(); + const lastIndex = formRendererComponent.tabGroup.selectedIndex; + formRendererComponent.navigateToNextTab(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(formRendererComponent.tabGroup.selectedIndex).toBe(lastIndex); + }); + }); + }); + describe('Repeatable section', () => { const repeatableSectionField = new FormFieldModel(new FormModel(), { id: 'RepeatableSection0tbw2y', diff --git a/lib/core/src/lib/form/components/form-renderer.component.ts b/lib/core/src/lib/form/components/form-renderer.component.ts index 3b49b02ac0..7105dd2bb3 100644 --- a/lib/core/src/lib/form/components/form-renderer.component.ts +++ b/lib/core/src/lib/form/components/form-renderer.component.ts @@ -16,12 +16,24 @@ */ import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common'; -import { ChangeDetectorRef, Component, DestroyRef, inject, Injector, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + DestroyRef, + inject, + Injector, + Input, + OnDestroy, + OnInit, + signal, + ViewChild, + ViewEncapsulation +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { filter } from 'rxjs'; +import { filter, Subscription } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatTabsModule } from '@angular/material/tabs'; +import { MatTabGroup, MatTabsModule } from '@angular/material/tabs'; import { TranslatePipe } from '@ngx-translate/core'; import { FormRulesManager, formRulesManagerFactory } from '../models/form-rules.model'; import { FormService } from '../services/form.service'; @@ -82,11 +94,54 @@ export class FormRendererComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); @Input({ required: true }) - formDefinition: FormModel; + set formDefinition(formDefinition: FormModel) { + this._formDefinition = formDefinition; + this.syncCurrentTabIndex(); + } + + get formDefinition(): FormModel { + return this._formDefinition; + } @Input() readOnly = false; + @ViewChild(MatTabGroup) + set tabGroup(tabGroup: MatTabGroup | undefined) { + this.tabGroupSelectionSubscription?.unsubscribe(); + this._tabGroup = tabGroup; + + if (tabGroup) { + this.syncCurrentTabIndex(tabGroup.selectedIndex); + this.tabGroupSelectionSubscription = tabGroup.selectedIndexChange.subscribe((index) => this.syncCurrentTabIndex(index)); + } + } + + get tabGroup(): MatTabGroup { + return this._tabGroup as MatTabGroup; + } + + private readonly currentTabIndex = signal(0); + private _formDefinition: FormModel; + private _tabGroup?: MatTabGroup; + private tabGroupSelectionSubscription?: Subscription; + + get canNavigateNext(): boolean { + return this.currentTabIndex() < this.visibleTabCount - 1; + } + + get canNavigatePrevious(): boolean { + return this.currentTabIndex() > 0; + } + + get visibleTabCount(): number { + return this.visibleTabs().length; + } + + get selectedTabIndex(): number { + return this.currentTabIndex(); + } + debugMode: boolean; fields: FormFieldModel[]; @@ -106,15 +161,37 @@ export class FormRendererComponent implements OnInit, OnDestroy { } ngOnDestroy() { + this.tabGroupSelectionSubscription?.unsubscribe(); this.formRulesManager.destroy(); } hasTabs(): boolean { - return this.formDefinition.tabs && this.formDefinition.tabs.length > 0; + return this.formDefinition?.tabs && this.formDefinition.tabs.length > 0; } visibleTabs(): TabModel[] { - return this.formDefinition.tabs.filter((tab) => tab.isVisible); + return this.formDefinition?.tabs?.filter((tab) => tab.isVisible) ?? []; + } + + navigateToNextTab(): void { + if (this.tabGroup && this.canNavigateNext) { + this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) + 1; + this.syncCurrentTabIndex(this.tabGroup.selectedIndex); + } + } + + navigateToPreviousTab(): void { + if (this.tabGroup && this.canNavigatePrevious) { + this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) - 1; + this.syncCurrentTabIndex(this.tabGroup.selectedIndex); + } + } + + private syncCurrentTabIndex(index = this.tabGroup?.selectedIndex): void { + const maxTabIndex = Math.max(this.visibleTabCount - 1, 0); + const currentIndex = index ?? 0; + + this.currentTabIndex.set(Math.min(Math.max(currentIndex, 0), maxTabIndex)); } getNumberOfColumns(content: ContainerModel): number { diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index ff3dd7faaa..3673781bb0 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -87,6 +87,12 @@ "NO_LABEL": "Cancel" } }, + "BUTTON": { + "PREVIOUS_TAB": "Previous", + "PREVIOUS_TAB_TITLE": "Navigate to previous tab", + "NEXT_TAB": "Next", + "NEXT_TAB_TITLE": "Navigate to next tab" + }, "FIELD_STYLE": { "FONT_SIZE": "Font size", "FONT_WEIGHT": "Font weight", diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.html b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.html index b1592b1365..7405cda37c 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.html +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.html @@ -1,107 +1,146 @@ -
- -
- -
-
- -
- - {{ form.taskName }} - - {{ 'FORM.FORM_RENDERER.NAMELESS_TASK' | translate }} - - -
- - - -
- - -
- - -

-
- check_circle - - error - -
-
- -
-
- -
- {{ form.taskName }} - - {{ 'FORM.FORM_RENDERER.NAMELESS_TASK' | translate }} - - -

-
-
- - - - - - - - - -
-
+@if (!hasForm()) { +
+
-
+} @else { +
+
+ @if (displayConfiguration?.options?.displayToolbar) { + +
+ + {{ form.taskName }} + @if (!form.taskName) { + {{ 'FORM.FORM_RENDERER.NAMELESS_TASK' | translate }} + } + +
+ + @if (displayConfiguration?.options?.displayCloseButton) { + + + } +
+ } + + +
+ @if (showTitle || showRefreshButton || showValidationIcon) { + + +

+ @if (showValidationIcon) { +
+ @if (form.isValid) { + check_circle + } @else { + error + } +
+ } + @if (!displayConfiguration?.options?.fullscreen && findDisplayConfiguration('fullScreen')) { +
+ +
+ } + @if (showRefreshButton) { +
+ +
+ } + @if (isTitleEnabled()) { + + {{ form.taskName }} + @if (!form.taskName) { + {{ 'FORM.FORM_RENDERER.NAMELESS_TASK' | translate }} + } + + } +

+
+
+ } + + + + @if (form.hasOutcomes() || shouldShowTabNavigation) { + + @if (shouldShowTabNavigation) { +
+ + +
+ + } +
+ + @for (outcome of form.outcomes; track outcome.name) { + @if (outcome.isVisible) { + + } + } +
+
+ } +
+
+
+
+} diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.scss b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.scss index 98deba22fa..f62635f0ea 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.scss +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.scss @@ -1,4 +1,5 @@ @use '../../mat-selectors' as ms; +@use '../../flex' as flex; /* cspell: disable-next-line */ /* stylelint-disable scss/at-extend-no-missing-placeholder */ @@ -138,3 +139,40 @@ white-space: normal; } } + +.adf-cloud-form-content-card-actions.adf-has-tab-navigation { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + + @include flex.layout-bp(lt-sm) { + flex-direction: column; + align-items: stretch; + } +} + +.adf-tab-navigation-buttons { + display: flex; + align-items: center; + gap: 4px; + + @include flex.layout-bp(lt-sm) { + justify-content: center; + } +} + +.adf-tab-navigation-divider { + width: 1px; + height: 24px; + background-color: var(--mat-sys-outline-variant, rgba(0, 0, 0, 0.12)); + + @include flex.layout-bp(lt-sm) { + display: none; + } +} + +.adf-cloud-form-outcome-buttons { + display: flex; + align-items: center; +} diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts index 38729d3bcc..9eeb110e94 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts @@ -46,7 +46,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatDialogHarness } from '@angular/material/dialog/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateService, provideTranslateService, provideTranslateLoader } from '@ngx-translate/core'; -import { firstValueFrom, Observable, of, throwError } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, Observable, of, throwError } from 'rxjs'; import { cloudFormMock, conditionalUploadWidgetsMock, @@ -59,7 +59,7 @@ import { import { FormCloudRepresentation } from '../models/form-cloud-representation.model'; import { FormCloudService } from '../services/form-cloud.service'; import { DisplayModeService } from '../services/display-mode.service'; -import { FORM_CLOUD_FIELD_VALIDATORS_TOKEN, FormCloudComponent } from './form-cloud.component'; +import { ADF_FORM_TAB_NAV_ENABLED, FORM_CLOUD_FIELD_VALIDATORS_TOKEN, FormCloudComponent } from './form-cloud.component'; import { MatButtonHarness } from '@angular/material/button/testing'; import { FormCloudDisplayMode } from '../../services/form-fields.interfaces'; import { CloudFormRenderingService } from './cloud-form-rendering.service'; @@ -1701,6 +1701,106 @@ describe('FormCloudComponent', () => { expect(outcomeButton.nativeElement.textContent.trim()).toBe('COMPLETE'); }); }); + + describe('Tab navigation', () => { + const tabbedFormJson = { + id: 'tabbed-form', + name: 'TabbedForm', + tabs: [ + { id: 'tab1', title: 'Tab 1' }, + { id: 'tab2', title: 'Tab 2' }, + { id: 'tab3', title: 'Tab 3' } + ], + fields: [ + { + id: 'container1', + type: 'container', + tab: 'tab1', + numberOfColumns: 1, + fields: { 1: [{ id: 'text1', type: 'text', name: 'Text 1' }] } + }, + { + id: 'container2', + type: 'container', + tab: 'tab2', + numberOfColumns: 1, + fields: { 1: [{ id: 'text2', type: 'text', name: 'Text 2' }] } + }, + { + id: 'container3', + type: 'container', + tab: 'tab3', + numberOfColumns: 1, + fields: { 1: [{ id: 'text3', type: 'text', name: 'Text 3' }] } + } + ], + outcomes: [], + showBottomTabNavButtons: true + }; + + describe('shouldShowTabNavigation', () => { + it('should return false when form json showBottomTabNavButtons is false', () => { + formComponent.form = new FormModel({ ...tabbedFormJson, showBottomTabNavButtons: false }); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + }); + + it('should return false when form json showBottomTabNavButtons is undefined', () => { + const { showBottomTabNavButtons: _unused, ...jsonWithoutFlag } = tabbedFormJson; + formComponent.form = new FormModel(jsonWithoutFlag); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + }); + + it('should return false when form has only one visible tab', () => { + const singleTabJson = { + ...tabbedFormJson, + tabs: [{ id: 'tab1', title: 'Tab 1' }], + fields: [tabbedFormJson.fields[0]] + }; + formComponent.form = new FormModel(singleTabJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + }); + + it('should return true when form json showBottomTabNavButtons is true and there is more than one visible tab', () => { + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeTrue(); + }); + }); + + describe('template rendering', () => { + it('should not render tab navigation buttons when shouldShowTabNavigation is false', () => { + formComponent.form = new FormModel({ ...tabbedFormJson, showBottomTabNavButtons: false }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.adf-tab-navigation-buttons'))).toBeNull(); + expect(fixture.debugElement.query(By.css('[data-automation-id="tab-nav-previous-button"]'))).toBeNull(); + expect(fixture.debugElement.query(By.css('[data-automation-id="tab-nav-next-button"]'))).toBeNull(); + }); + + it('should render Previous and Next tab navigation buttons when shouldShowTabNavigation is true', () => { + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.adf-tab-navigation-buttons'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('[data-automation-id="tab-nav-previous-button"]'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('[data-automation-id="tab-nav-next-button"]'))).toBeTruthy(); + }); + + it('should render the form actions container when only tab nav exists and the form has no outcomes', () => { + formComponent.form = new FormModel({ ...tabbedFormJson, outcomes: [] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.adf-cloud-form-content-card-actions'))).toBeTruthy(); + }); + }); + }); }); describe('Multilingual Form', () => { @@ -1991,3 +2091,109 @@ describe('retrieve metadata on submit', () => { expect(formComponent['formCloudService'].completeTaskForm).toHaveBeenCalled(); }); }); + +describe('FormCloudComponent - ADF_FORM_TAB_NAV_ENABLED token', () => { + let formComponent: FormCloudComponent; + let fixture: ComponentFixture; + + const tabbedFormJson = { + id: 'tabbed-form', + name: 'TabbedForm', + tabs: [ + { id: 'tab1', title: 'Tab 1' }, + { id: 'tab2', title: 'Tab 2' } + ], + fields: [ + { + id: 'container1', + type: 'container', + tab: 'tab1', + numberOfColumns: 1, + fields: { 1: [{ id: 'text1', type: 'text', name: 'Text 1' }] } + }, + { + id: 'container2', + type: 'container', + tab: 'tab2', + numberOfColumns: 1, + fields: { 1: [{ id: 'text2', type: 'text', name: 'Text 2' }] } + } + ], + outcomes: [], + showBottomTabNavButtons: true + }; + + const createFixture = (tokenValue: any) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent], + providers: [ + { provide: VersionCompatibilityService, useValue: {} }, + { provide: FormRenderingService, useClass: CloudFormRenderingService }, + { provide: ADF_FORM_TAB_NAV_ENABLED, useValue: tokenValue } + ] + }); + const apiService = TestBed.inject(AlfrescoApiService); + spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth); + fixture = TestBed.createComponent(FormCloudComponent); + formComponent = fixture.componentInstance; + }; + + it('should render tab navigation buttons when token is absent and form model opts in', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent], + providers: [ + { provide: VersionCompatibilityService, useValue: {} }, + { provide: FormRenderingService, useClass: CloudFormRenderingService } + ] + }); + const apiService = TestBed.inject(AlfrescoApiService); + spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth); + fixture = TestBed.createComponent(FormCloudComponent); + formComponent = fixture.componentInstance; + + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeTrue(); + }); + + it('should hide tab navigation buttons when token resolves to static false even if form opts in', () => { + createFixture(false); + + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + }); + + it('should show tab navigation buttons when token resolves to static true and form opts in', () => { + createFixture(true); + + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeTrue(); + }); + + it('should react to observable token emissions', () => { + const subject = new BehaviorSubject(false); + createFixture(subject); + + formComponent.form = new FormModel(tabbedFormJson); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + + subject.next(true); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeTrue(); + + subject.next(false); + fixture.detectChanges(); + + expect(formComponent.shouldShowTabNavigation).toBeFalse(); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts index 0ea9989b08..2eca9be65d 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts @@ -27,9 +27,10 @@ import { OnChanges, OnInit, Output, - SimpleChanges + SimpleChanges, + ViewChild } from '@angular/core'; -import { forkJoin, Observable, of, Subscription } from 'rxjs'; +import { forkJoin, isObservable, Observable, of, Subscription } from 'rxjs'; import { filter, map, switchMap } from 'rxjs/operators'; import { ConfirmDialogComponent, @@ -60,18 +61,19 @@ import { FormCloudDisplayMode, FormCloudDisplayModeConfiguration } from '../../s import { FormCloudSpinnerService } from '../services/spinner/form-cloud-spinner.service'; import { DisplayModeService } from '../services/display-mode.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CommonModule } from '@angular/common'; +import { UpperCasePipe } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { A11yModule } from '@angular/cdk/a11y'; export const FORM_CLOUD_FIELD_VALIDATORS_TOKEN = new InjectionToken('FORM_CLOUD_FIELD_VALIDATORS_TOKEN'); +export const ADF_FORM_TAB_NAV_ENABLED = new InjectionToken | boolean>('ADF_FORM_TAB_NAV_ENABLED'); @Component({ selector: 'adf-cloud-form', imports: [ - CommonModule, + UpperCasePipe, TranslatePipe, FormatSpacePipe, MatButtonModule, @@ -178,6 +180,23 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, displayConfiguration: FormCloudDisplayModeConfiguration = DisplayModeService.DEFAULT_DISPLAY_MODE_CONFIGURATIONS[0]; style: string = ''; + @ViewChild(FormRendererComponent) + formRenderer!: FormRendererComponent; + + private tabNavEnabledByHost = true; + + get shouldShowTabNavigation(): boolean { + return this.tabNavEnabledByHost && this.currentForm?.json?.showBottomTabNavButtons === true && this.visibleTabCount > 1; + } + + get canNavigatePreviousTab(): boolean { + return this.formRenderer?.canNavigatePrevious ?? false; + } + + get canNavigateNextTab(): boolean { + return this.formRenderer?.canNavigateNext ?? this.visibleTabCount > 1; + } + protected formCloudService = inject(FormCloudService); protected formService = inject(FormService); protected visibilityService = inject(WidgetVisibilityService); @@ -188,8 +207,29 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, private readonly destroyRef = inject(DestroyRef); + private get currentForm(): FormModel | undefined { + return super.form; + } + + private get visibleTabCount(): number { + return this.currentForm?.tabs?.filter((tab) => tab.isVisible).length ?? 0; + } + + navigateToPreviousTab(): void { + if (this.formRenderer?.canNavigatePrevious) { + this.formRenderer?.navigateToPreviousTab(); + } + } + + navigateToNextTab(): void { + if (this.formRenderer?.canNavigateNext) { + this.formRenderer?.navigateToNextTab(); + } + } + constructor() { const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true }); + const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { optional: true }); super(); this.loadInjectedFieldValidators(injectedFieldValidators); @@ -197,6 +237,17 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges, this.id = uuidGeneration(); + if (tabNavEnabledToken != null) { + if (isObservable(tabNavEnabledToken)) { + tabNavEnabledToken.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled) => { + this.tabNavEnabledByHost = enabled ?? false; + this.changeDetector.markForCheck(); + }); + } else { + this.tabNavEnabledByHost = tabNavEnabledToken; + } + } + this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => { if (content instanceof UploadWidgetContentLinkModel) { this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);