src/app/shared/widgets/tabs/tabs.component.ts
Required properties: "label", "href" and either "payload" or "component".
Properties |
|
component |
component:
|
Type : string
|
Optional |
used for "internal" tab display should contain a widget id that is part of the loaded configuration when the tab is selected, this widget will automatically be created within the tab component |
count |
count:
|
Type : number
|
Optional |
Tab content count |
enabled |
enabled:
|
Type : boolean
|
Optional |
used for enabling/disabling certain tabs. @default(true) |
hasPermission |
hasPermission:
|
Type : boolean
|
Optional |
Processed permission to show the tab. |
href |
href:
|
Type : string
|
"href" will be pushed to the URL as a query parameter, when the user switches between tabs when the application is re-loaded, href is used to find the correct tab based on the previously set query parameter |
label |
label:
|
Type : string
|
title of the tab, will be passed through the translation service. |
link |
link:
|
Type : string
|
Optional |
computed link with query parameter for this tab |
order |
order:
|
Type : number
|
Optional |
"order" of the tab, sorted ascendingly. tabs without "order" will be sorted last, with indeterminate order between them. |
payload |
payload:
|
Type : any
|
Optional |
"payload" is used, when external tab display is required. tabs with source == "config" can set payload to a string representing a configured widget or any other logic tabs with source == "interaction" will have the payload automatically set to the interaction itself |
source |
source:
|
Type : Source
|
source of the tab configuration, either "config" when loaded from json config, or "interaction" when the tab was dynamically registered. "source" will be set automatically. |
target |
target:
|
Type : Target
|
"target" represents the outlet used to display the tab content "inline": the content will be display within the mat-tab component "external": the "payload" property will be output via "selectedTab" to allow separate business logic to handle the tab display. |
withCounts |
withCounts:
|
Type : boolean
|
Optional |
used to add count as a chip-list for the tab contents. |
import {
ChangeDetectorRef,
Component,
OnDestroy,
ViewChild,
} from "@angular/core";
import {
BehaviorSubject,
combineLatest,
from,
of,
ReplaySubject,
Subject,
} from "rxjs";
import { getOrDefault, WidgetConfig } from "../widget.configuration";
import {
WidgetComponent,
WidgetConfiguration,
WidgetConfigure,
WidgetId,
WidgetInput,
WidgetOutput,
} from "../widget.metadata";
import {
MatTab,
MatTabChangeEvent,
MatTabGroup,
MatTabHeader,
} from "@angular/material/tabs";
import { ActivatedRoute } from "@angular/router";
import {
filter,
mergeAll,
pluck,
startWith,
takeUntil,
throttleTime,
} from "rxjs/operators";
import { NgUnsubscribe } from "../../ng-unsubscribe";
import {
LocalStorageEntry,
LocalStorageService,
} from "../../components/local-storage/local-storage.service";
import {
DeletionMode,
Scope,
} from "../../components/local-storage/local-storage-constants";
import { AppContext } from "../../components/app-context/app.context";
import { Content } from "../../components/app-context/api";
import { throttleUntilChanged } from "../../components/util/throttleUntilChanged";
export interface TabConfiguration {
/**
* Static configuration of tabs.
*/
tabs?: Tab[];
/**
* Selector used to look up interaction in the browser context.
*/
tabsSelector?: any;
/**
* Name of the query parameter used to persist the active tab in the URL. @default ("tab")
*/
queryParameter: string;
/**
* Duration of the animation when switching between tabs. @default (0)
*/
animationDuration: number;
/**
* Storage key to keep the selected tab.
*/
selectedTabLocalStorageKey?: string;
/**
* If clicking a tab with the href property should modify the url. @default (true)
*/
modifyUrl: boolean;
}
type Source = "config" | "interaction";
type Target = "inline" | "external";
/**
* Required properties: "label", "href" and either "payload" or "component".
*/
export interface Tab {
/**
* source of the tab configuration, either "config" when loaded from json config, or "interaction"
* when the tab was dynamically registered.
* "source" will be set automatically.
*/
source: Source;
/**
* "target" represents the outlet used to display the tab content
* "inline": the content will be display within the mat-tab component
* "external": the "payload" property will be output via "selectedTab" to allow separate business
* logic to handle the tab display.
*/
target: Target;
/**
* "order" of the tab, sorted ascendingly. tabs without "order" will be sorted last,
* with indeterminate order between them.
*/
order?: number;
/**
* title of the tab, will be passed through the translation service.
*/
label: string;
/**
* "href" will be pushed to the URL as a query parameter, when the user switches between tabs
* when the application is re-loaded, href is used to find the correct tab based on the previously
* set query parameter
*/
href: string;
/**
* "payload" is used, when external tab display is required.
* tabs with source == "config" can set payload to a string representing a configured widget or any
* other logic
*tabs with source == "interaction" will have the payload automatically set to the interaction itself
*/
payload?: any;
/**
* used for "internal" tab display
* should contain a widget id that is part of the loaded configuration
* when the tab is selected, this widget will automatically be created within the tab component
*/
component?: string;
/**
* used for "internal" tab display
* internally set to the configuration interaction
* when the tab is selected, the interaction wil automatically be created within the tab component
*/
interaction?: Content;
/**
* used for enabling/disabling certain tabs. @default(true)
*/
enabled?: boolean;
/**
* computed link with query parameter for this tab
*/
link?: string;
/**
* Processed permission to show the tab.
*/
hasPermission?: boolean;
/**
* used to add count as a chip-list for the tab contents.
*/
withCounts?: boolean;
/**
* Tab content count
*/
count?: number;
}
/**
* The "nm-tabs" component supports the configuration of tabs from two sources
* (json configuration & interactions) and supports loading the tabs inline
* (i.e., using the default mat-tab functionality) or externally, which requires
* a separate outlet widget (nm-switch-layout or nm-interaction-layout) and a controller
* to handle the actual switching/forwarding of tabs contents.
*/
@WidgetComponent("nm-tabs")
@Component({
selector: "nm-tabs",
templateUrl: "./tabs.component.html",
styleUrls: ["./tabs.component.scss"],
})
export class TabsWidgetComponent implements OnDestroy {
@ViewChild("tabGroup", { static: true })
private tabGroup: MatTabGroup;
public tabs: BehaviorSubject<Tab[]> = new BehaviorSubject<Tab[]>([]);
public activeComponent;
private preSelectedIndex;
public selectedIndex;
public animationDuration: number;
private beforeClickFunction;
private preventClickHandle;
@WidgetId()
public id: string;
@WidgetConfiguration()
public configuration: WidgetConfig<TabConfiguration>;
/**
* Set selected tab
*/
@WidgetInput("selectTab")
public selectTabInput: Subject<any> = new Subject<any>();
/**
* Get selected tab
*/
@WidgetOutput("selectedTab")
public selectedTabOutput: Subject<any> = new ReplaySubject(1);
public selectedTabChangeSubject = new Subject<MatTabChangeEvent>();
/**
* Input to pass a handler that can interrupt the click. This is needed to prevent switching tabs if there are pending changes
* Input should be given a function, which gets two parameter, tab (the clicked tab), and a function which will perform the routing when called. E.g:
* connector.connect('beforeClick').from(of((tab, doRoute) => if(tab.payload.route) doRoute()))
* The function should return directly true and do not call doRoute if it wants to route without delay (e.g a confirmation dialog would create delay)
*/
@WidgetInput("beforeClick")
public beforeClickInput: Subject<any> = new ReplaySubject<any>(1);
/**
* Input to pass the interaction parameter from outside the tab
* into the internally display interaction.
*/
@WidgetInput("interactionParam")
public interactionParam: Subject<any> = new Subject<any>();
@WidgetInput("tabCount")
public tabCount: Subject<any> = new Subject<any>();
private selectedTabLocalStorageEntry: LocalStorageEntry;
private unsubscribe = NgUnsubscribe.create();
private queryParameterName;
private allowUrlModification;
constructor(
private route: ActivatedRoute,
private localstorageService: LocalStorageService,
private appContext: AppContext,
private cdr: ChangeDetectorRef
) {}
@WidgetConfigure()
protected configureWidget(configuration: WidgetConfig<TabConfiguration>) {
this.queryParameterName = getOrDefault(
this.configuration.configuration.queryParameter,
"tab"
);
this.animationDuration = getOrDefault(
this.configuration.configuration.animationDuration,
0
);
this.allowUrlModification = getOrDefault(
this.configuration.configuration.modifyUrl,
true
);
if (this.configuration.configuration.selectedTabLocalStorageKey) {
this.selectedTabLocalStorageEntry = this.localstorageService.getLocalStorageEntry(
this.configuration.configuration.selectedTabLocalStorageKey,
Scope.GLOBAL,
DeletionMode.LOGIN
);
}
let initialTab: string = <string>(
this.route.snapshot.params[this.queryParameterName]
);
if (
initialTab == null &&
this.selectedTabLocalStorageEntry &&
this.selectedTabLocalStorageEntry.exists()
) {
initialTab = this.selectedTabLocalStorageEntry.value;
}
// pipe all external tab change requests through a single observable
// including the initial tab selection via query param OR local storage
//
// this allows us
// (1) to only react to distinct changes
// (2) and we can combine the current tab setup
// to simplify the tab selection logic
let tabChange = from([
of(initialTab),
this.route.queryParams.pipe(
pluck(this.queryParameterName),
filter((value) => !!value)
),
this.selectTabInput,
]).pipe(mergeAll(), throttleUntilChanged(100));
combineLatest([this.tabs, tabChange])
.pipe(takeUntil(this.unsubscribe))
.subscribe(([tabs, requestedTab]) => {
let tabIndex = -1;
if (requestedTab) {
tabIndex = tabs.findIndex((tab) => tab.href === requestedTab);
}
if (tabIndex === -1) {
tabIndex = 0;
}
this.selectTab(tabIndex);
});
let configuredTabs = configuration.configuration.tabs || [];
let mappedTabs = configuredTabs
.filter((tab) => getOrDefault(tab.enabled, true))
.filter((tab) => tab.hasPermission !== false)
.map((tab) => {
tab.source = "config";
tab.target = tab.target || tab.component ? "inline" : "external";
if (tab.withCounts) {
tab.count = !tab.count ? 0 : tab.count;
}
return tab;
});
let tabsSelector = this.configuration.configuration.tabsSelector;
if (tabsSelector) {
this.appContext.browserContext
.subscribe(tabsSelector)
.pipe(takeUntil(this.unsubscribe), startWith([]))
.subscribe((interactionTabs) => {
this.tabs.next(this.collectTabs(mappedTabs, interactionTabs));
});
} else {
this.tabs.next(this.collectTabs(mappedTabs, []));
}
this.beforeClickInput
.pipe(takeUntil(this.unsubscribe))
.subscribe((input) => {
if (input) {
this.tabGroup._handleClick = (
tab: MatTab,
tabHeader: MatTabHeader,
idx: number
) => {
let result = input(this.tabs.getValue()?.[idx], () =>
MatTabGroup.prototype._handleClick.apply(this.tabGroup, [
tab,
tabHeader,
idx,
])
);
if (result) {
MatTabGroup.prototype._handleClick.apply(this.tabGroup, [
tab,
tabHeader,
idx,
]);
}
};
} else {
this.tabGroup._handleClick = MatTabGroup.prototype._handleClick;
}
});
this.tabCount.subscribe((tabCount) => {
const requestedTab = tabCount.tab;
const count = tabCount.count;
let tab = this.tabs.value.find((tab) => tab.component === requestedTab);
tab.count = count;
});
this.selectedTabChangeSubject
.pipe(throttleTime(100, undefined, { leading: true, trailing: true }))
.subscribe((event) => {
let tabs = this.tabs.value;
this.emitTabChange(tabs, event.index, true);
});
}
public selectedTabChange(event: MatTabChangeEvent) {
this.selectedTabChangeSubject.next(event);
}
public onTabClick(tab, event) {
if (event.ctrlKey) {
// open the url in a new tab
window.open(this.getLink(tab), "_blank");
// stop propagation to prevent the mat-tab component from handling the click event
event.stopPropagation();
}
// prevent the default action, i.e., following the <a> link
event.preventDefault();
return false;
}
private selectTab(index: number, modifyUrl = false) {
let tabs = this.tabs.value;
if (!tabs || !tabs[index]) {
return;
}
tabs
.filter((tab) => tab.withCounts)
.map((tab) => (tab.count = !tab.count ? 0 : tab.count));
this.selectedIndex = index;
this.preSelectedIndex = index;
this.emitTabChange(tabs, index, modifyUrl);
this.cdr.markForCheck();
}
private emitTabChange(tabs: Tab[], index: number, modifyUrl: boolean) {
const tab = tabs[index];
this.selectedTabOutput.next(tab.payload);
if (this.selectedTabLocalStorageEntry) {
this.selectedTabLocalStorageEntry.value = tab.href;
}
this.activeComponent = tab.component;
if (modifyUrl) {
this.modifyUrl(tab);
}
}
private modifyUrl(tab: Tab) {
if (this.allowUrlModification && tab.href) {
window.history.replaceState({}, "title", this.getLink(tab));
}
}
private getLink(tab): string {
const url = new URL(window.location.href);
url.searchParams.set(this.queryParameterName, tab.href);
return url.toString();
}
ngOnDestroy(): void {
this.unsubscribe.destroy();
}
/**
* merges the configured tabs from the json config and all interactions already loaded
* into one list, sorted by the order property
*/
private collectTabs(configuredTabs: Tab[], interactions: Content[]): Tab[] {
let result: Tab[] = [...configuredTabs];
if (interactions) {
let tabs = interactions.map((interaction) => {
let tabConfig = interaction.tab;
let result = <Tab>{
source: "interaction",
target: tabConfig.target,
order: tabConfig.order,
label: tabConfig.label,
href: tabConfig.href,
hasPermission: tabConfig.hasPermission,
interaction: interaction,
};
if (result.target == "external") {
result.payload = interaction;
}
return result;
});
result.push(...tabs);
}
result = result.sort((l, r) => {
let lHasOrder = l.hasOwnProperty("order");
let rHasOrder = r.hasOwnProperty("order");
if (!lHasOrder && !rHasOrder) {
return 0;
} else if (lHasOrder && rHasOrder) {
return l.order - r.order;
} else if (lHasOrder) {
return -1;
}
return 1;
});
result.forEach((tab) => (tab.link = this.getLink(tab)));
return result;
}
}