@WidgetComponent

nm-tabs

File

src/app/shared/widgets/tabs/tabs.component.ts

Description

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.

Implements

OnDestroy

Metadata

selector nm-tabs
styleUrls tabs.component.scss
templateUrl ./tabs.component.html

Index

Widget inputs
Widget outputs
Properties
Methods

Constructor

constructor(route: ActivatedRoute, localstorageService: LocalStorageService, appContext: AppContext, cdr: ChangeDetectorRef)
Parameters :
Name Type Optional
route ActivatedRoute no
localstorageService LocalStorageService no
appContext AppContext no
cdr ChangeDetectorRef no

Methods

Private collectTabs
collectTabs(configuredTabs: Tab[], interactions: Content[])

merges the configured tabs from the json config and all interactions already loaded into one list, sorted by the order property

Parameters :
Name Type Optional
configuredTabs Tab[] no
interactions Content[] no
Returns : Tab[]
Protected configureWidget
configureWidget(configuration: WidgetConfig)
Decorators : WidgetConfigure
Parameters :
Name Type Optional
configuration WidgetConfig<TabConfiguration> no
Returns : void
Private emitTabChange
emitTabChange(tabs: Tab[], index: number, modifyUrl: boolean)
Parameters :
Name Type Optional
tabs Tab[] no
index number no
modifyUrl boolean no
Returns : void
Private getLink
getLink(tab: )
Parameters :
Name Optional
tab no
Returns : string
Private modifyUrl
modifyUrl(tab: Tab)
Parameters :
Name Type Optional
tab Tab no
Returns : void
ngOnDestroy
ngOnDestroy()
Returns : void
Public onTabClick
onTabClick(tab: , event: )
Parameters :
Name Optional
tab no
event no
Returns : boolean
Public selectedTabChange
selectedTabChange(event: MatTabChangeEvent)
Parameters :
Name Type Optional
event MatTabChangeEvent no
Returns : void
Private selectTab
selectTab(index: number, modifyUrl: )
Parameters :
Name Type Optional Default value
index number no
modifyUrl no false
Returns : void

Properties

Public activeComponent
activeComponent:
Private allowUrlModification
allowUrlModification:
Public animationDuration
animationDuration: number
Type : number
Private beforeClickFunction
beforeClickFunction:
Public beforeClickInput
beforeClickInput: Subject<any>
Type : Subject<any>
Default value : new ReplaySubject<any>(1)
Decorators : WidgetInput

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)

Public configuration
configuration: WidgetConfig<TabConfiguration>
Type : WidgetConfig<TabConfiguration>
Decorators : WidgetConfiguration
Public id
id: string
Type : string
Decorators : WidgetId
Public interactionParam
interactionParam: Subject<any>
Type : Subject<any>
Default value : new Subject<any>()
Decorators : WidgetInput

Input to pass the interaction parameter from outside the tab into the internally display interaction.

Private preSelectedIndex
preSelectedIndex:
Private preventClickHandle
preventClickHandle:
Private queryParameterName
queryParameterName:
Public selectedIndex
selectedIndex:
Public selectedTabChangeSubject
selectedTabChangeSubject:
Default value : new Subject<MatTabChangeEvent>()
Private selectedTabLocalStorageEntry
selectedTabLocalStorageEntry: LocalStorageEntry
Type : LocalStorageEntry
Public selectedTabOutput
selectedTabOutput: Subject<any>
Type : Subject<any>
Default value : new ReplaySubject(1)
Decorators : WidgetOutput

Get selected tab

Public selectTabInput
selectTabInput: Subject<any>
Type : Subject<any>
Default value : new Subject<any>()
Decorators : WidgetInput

Set selected tab

Public tabCount
tabCount: Subject<any>
Type : Subject<any>
Default value : new Subject<any>()
Decorators : WidgetInput
Private tabGroup
tabGroup: MatTabGroup
Type : MatTabGroup
Decorators : ViewChild
Public tabs
tabs: BehaviorSubject<Tab[]>
Type : BehaviorSubject<Tab[]>
Default value : new BehaviorSubject<Tab[]>([])
Private unsubscribe
unsubscribe:
Default value : NgUnsubscribe.create()
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;
  }
}
<mat-tab-group
  dynamicHeight
  (selectedTabChange)="selectedTabChange($event)"
  [(selectedIndex)]="selectedIndex"
  [animationDuration]="animationDuration"
  color="accent"
  #tabGroup
>
  <mat-tab *ngFor="let tab of tabs | async">
    <ng-template mat-tab-label>
      <ng-container *ngIf="tab.href; else noLink">
        <a [href]="tab.link" (click)="onTabClick(tab, $event)">{{
          tab.label | translate
        }}</a>

        <mat-chip-list *ngIf="tab.withCounts" class="mat-chip-list__wrapper">
          <mat-chip
            color="accent"
            [selected]="true"
            [disableRipple]="true"
            [selectable]="false"
          >
            {{ tab.count }}
          </mat-chip>
        </mat-chip-list>
      </ng-container>

      <ng-template #noLink>
        {{ tab.label | translate }}
      </ng-template>
    </ng-template>

    <div *ngIf="tab.target == 'inline'" class="nm-tab__wrapper">
      <nm-container
        *ngIf="
          tab.source == 'config' &&
          !!activeComponent &&
          activeComponent === tab.component
        "
        [configuration]="activeComponent | widgetFor: configuration"
        [parent]="id"
        [id]="activeComponent"
      >
      </nm-container>

      <nm-interaction
        *ngIf="tab.source == 'interaction'"
        [param]="interactionParam | async"
        [configuration]="tab.interaction"
      ></nm-interaction>
    </div>
  </mat-tab>
</mat-tab-group>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""