@scion/workbench
Version:
SCION Workbench enables the creation of Angular web applications that require a flexible layout to display content side-by-side or stacked, all personalizable by the user via drag & drop. This type of layout is ideal for applications with non-linear workf
1,268 lines (1,245 loc) • 995 kB
JavaScript
import * as i0 from '@angular/core';
import { effect, untracked, Injectable, inject, Injector, signal, booleanAttribute, numberAttribute, DestroyRef, DOCUMENT, computed, InjectionToken, makeEnvironmentProviders, runInInjectionContext, NgZone, assertNotInReactiveContext, ApplicationInitStatus, EnvironmentInjector, createEnvironmentInjector, isSignal, Pipe, IterableDiffers, TemplateRef, createComponent, input, output, ElementRef, Directive, Renderer2, ChangeDetectionStrategy, Component, viewChild, HostListener, ApplicationRef, viewChildren, HostBinding, isDevMode, ChangeDetectorRef, linkedSignal, forwardRef, ViewContainerRef, CUSTOM_ELEMENTS_SCHEMA, provideAppInitializer, provideEnvironmentInitializer } from '@angular/core';
import { Router, ChildrenOutletContexts, NavigationStart, ActivationStart, ActivationEnd, PRIMARY_OUTLET, UrlSegment, RouterOutlet, RouterEvent, NavigationEnd, NavigationCancel, NavigationError, ActivatedRoute } from '@angular/router';
import { Arrays, Objects as Objects$1, Dictionaries, Defined, Observables, Maps } from '@scion/toolkit/util';
import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { skipUntil, mergeMap, filter, observeOn, catchError, startWith, take, map, switchMap as switchMap$1, skip, subscribeOn, distinctUntilChanged, takeUntil, finalize, tap, first, combineLatestWith } from 'rxjs/operators';
import { coerceElement } from '@angular/cdk/coercion';
import { UUID } from '@scion/toolkit/uuid';
import { Observable, Subject, asapScheduler, AsyncSubject, lastValueFrom, iif, switchMap, race, pairwise, EMPTY, of, firstValueFrom, from, animationFrameScheduler, BehaviorSubject, merge, fromEvent, mergeWith, mergeMap as mergeMap$1, noop, map as map$1, concatWith, delay, identity, audit, share, timer, withLatestFrom, combineLatest } from 'rxjs';
import { fromMutation$, fromResize$ } from '@scion/toolkit/observable';
import { subscribeIn, observeIn, filterArray, tapFirst } from '@scion/toolkit/operators';
import { ComponentPortal, CdkPortalOutlet, DomPortalOutlet } from '@angular/cdk/portal';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { CdkTrapFocus, FocusMonitor } from '@angular/cdk/a11y';
import { NgTemplateOutlet, NgComponentOutlet, AsyncPipe, KeyValuePipe, NgClass, LocationStrategy, Location } from '@angular/common';
import { SciViewportComponent } from '@scion/components/viewport';
import { trigger, transition, style, animate } from '@angular/animations';
import { SciDimensionDirective, boundingClientRect, dimension } from '@scion/components/dimension';
import { SciSashboxComponent, SciSashDirective } from '@scion/components/sashbox';
import * as i1 from '@angular/forms';
import { NonNullableFormBuilder, ReactiveFormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ManifestService, MicrofrontendPlatform, PlatformState, MessageHeaders, MessageClient, ResponseStatusCodes, OutletRouter, MicrofrontendPlatformConfig, APP_IDENTITY, mapToBody, HostManifestInterceptor, ObservableDecorator, IntentInterceptor, CapabilityInterceptor, MicrofrontendPlatformHost, IntentClient, PlatformPropertyService } from '@scion/microfrontend-platform';
import { Beans } from '@scion/toolkit/bean-manager';
import { ɵMicrofrontendRouteParams as _MicrofrontendRouteParams, WorkbenchCapabilities, WorkbenchMessageBox, eMESSAGE_BOX_MESSAGE_PARAM, ɵTHEME_CONTEXT_KEY as _THEME_CONTEXT_KEY, ɵPOPUP_CONTEXT as _POPUP_CONTEXT, ɵWorkbenchCommands as _WorkbenchCommands, ɵWorkbenchPopupMessageHeaders as _WorkbenchPopupMessageHeaders, WorkbenchPopup, ɵMESSAGE_BOX_CONTEXT as _MESSAGE_BOX_CONTEXT, ɵDIALOG_CONTEXT as _DIALOG_CONTEXT, ɵWorkbenchDialogMessageHeaders as _WorkbenchDialogMessageHeaders, WorkbenchDialog as WorkbenchDialog$1, WorkbenchRouter as WorkbenchRouter$1, WorkbenchPopupService, WorkbenchMessageBoxService as WorkbenchMessageBoxService$1, ɵWorkbenchMessageBoxService as _WorkbenchMessageBoxService, WorkbenchDialogService as WorkbenchDialogService$1, ɵWorkbenchDialogService as _WorkbenchDialogService, WorkbenchNotificationService, ɵVIEW_ID_CONTEXT_KEY as _VIEW_ID_CONTEXT_KEY } from '@scion/workbench-client';
import { SciThrobberComponent } from '@scion/components/throbber';
import { Crypto } from '@scion/toolkit/crypto';
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Configuration of the SCION Workbench.
*/
class WorkbenchConfig {
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Throws the specified error, useful in arrow functions that do not support throwing without a body.
*/
function throwError(error) {
throw Error(error);
}
/*
* Copyright (c) 2018-2025 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Returns a Promise that resolves when signaling `true`.
*
* This function must be called within an injection context, or an explicit {@link Injector} passed.
*/
function resolveWhen(condition, options) {
return new Promise(resolve => {
const effectRef = effect(() => {
if (condition()) {
untracked(() => resolve());
effectRef.destroy();
}
}, { injector: options?.injector });
});
}
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides persistent storage to the SCION Workbench.
*/
class WorkbenchStorage {
}
/**
* Default storage used by the SCION workbench to persist data in local storage.
*
* Local storage maintains a persistent storage area per origin. Data does not expire and remains after the browser restarts.
*
* @internal
*/
class DefaultWorkbenchStorage {
load(key) {
return localStorage.getItem(key);
}
store(key, value) {
localStorage.setItem(key, value);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: DefaultWorkbenchStorage, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: DefaultWorkbenchStorage });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: DefaultWorkbenchStorage, decorators: [{
type: Injectable
}] });
/*
* Copyright (c) 2018-2025 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Creates a writable signal to read and write specified flag from and to workbench storage.
*/
function renderingFlag(storageKey, defaultValue, options) {
const injector = options?.injector ?? inject(Injector);
const workbenchStorage = injector.get(WorkbenchStorage);
const flag = signal(defaultValue);
// Read flag from storage.
const whenReadFromStorage = readFromStorage(storageKey).then(storedValue => {
if (storedValue === null) {
return;
}
switch (typeof defaultValue) {
case 'number': {
flag.set(numberAttribute(storedValue, defaultValue));
break;
}
case 'boolean': {
flag.set(booleanAttribute(storedValue));
break;
}
default: {
flag.set(storedValue);
}
}
});
// Write flag to storage.
toObservable(flag, { injector })
.pipe(skipUntil(whenReadFromStorage), takeUntilDestroyed(injector.get(DestroyRef)))
.subscribe(value => void workbenchStorage.store(storageKey, `${value}`));
return flag;
}
async function readFromStorage(key) {
return inject(WorkbenchStorage).load(key);
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Generates a UID (unique identifier).
*/
const UID = {
/**
* Generates a UID (unique identifier) with length 8.
*/
randomUID: () => {
return UUID.randomUUID().substring(0, 8);
},
};
/**
* Creates an HTML element and optionally adds it to the DOM.
*/
function createElement(tag, options) {
const element = document.createElement(tag);
options.style && setStyle(element, options.style);
options.cssClass && element.classList.add(...Arrays.coerce(options.cssClass));
options.attributes && setAttribute(element, options.attributes);
options.text && (element.innerText = options.text);
options.parent?.appendChild(element);
return element;
}
/**
* Applies the given style(s) to the given element.
*
* Specify styles to be modified by passing a dictionary containing CSS property names (hyphen case).
* To remove a style, set its value to `null`.
*/
function setStyle(element, styles) {
const target = coerceElement(element);
Object.entries(styles).forEach(([name, value]) => {
if (value === null) {
target.style.removeProperty(name);
}
else {
target.style.setProperty(name, value);
}
});
}
/**
* Sets the given attribute(s) on the given element.
* To remove an attribute, set its value to `null`.
*/
function setAttribute(element, attributes) {
const target = coerceElement(element);
Object.entries(attributes).forEach(([name, value]) => {
if (value === null) {
target.removeAttribute(name);
}
else {
target.setAttribute(name, value);
}
});
}
/**
* Sets specified CSS variable(s) to the given element.
*
* To remove a CSS variable, set its value to `null`, or use {@link unsetCssVariable}.
*/
function setCssVariable(element, variables) {
const target = coerceElement(element);
Object.entries(variables).forEach(([name, value]) => {
if (value === null) {
target.style.removeProperty(name);
}
else {
target.style.setProperty(name, value);
}
});
}
/**
* Removes specified CSS variable(s) from the given element.
*/
function unsetCssVariable(element, ...names) {
const target = coerceElement(element);
names.forEach(name => target.style.removeProperty(name));
}
/**
* Reads and parses specified CSS variable from given element. Defaults to the specified value if not available.
*/
function readCssVariable(element, variable, defaultValue) {
const value = getComputedStyle(element).getPropertyValue(variable) || defaultValue; // empty string if not set
switch (typeof defaultValue) {
case 'number': {
return numberAttribute(value, defaultValue);
}
case 'boolean': {
return booleanAttribute(value);
}
default: {
return value;
}
}
}
/**
* Sets specified CSS class(es) to the given element.
*/
function setCssClass(element, ...classes) {
coerceElement(element).classList.add(...classes);
}
/**
* Removes specified CSS class(es) from the given element.
*/
function unsetCssClass(element, ...classes) {
coerceElement(element).classList.remove(...classes);
}
/**
* Reads the current vertical and horizontal translation of given element.
*/
function getCssTranslation(element) {
const transformStyle = getComputedStyle(element).getPropertyValue('transform');
if (transformStyle === 'none') {
return {
translateX: 'none',
translateY: 'none',
};
}
// The transform property returns a matrix in the form `matrix(a, b, c, d, tx, ty)`, where `tx` is the horizontal translation and `ty` is the vertical translation.
const matrix = transformStyle.slice('matrix('.length, -1).split(/,\s+/);
return {
translateX: matrix[4],
translateY: matrix[5],
};
}
/**
* Ensures that the given HTML element is positioned, setting its position to `relative` if it is not already positioned.
*
* Positioning is set using a constructable CSS stylesheet with a CSS layer. CSS layers have lower priority than "normal"
* CSS declarations, and the layer name indicates the styling originates from `@scion/workbench`.
*
* This function adds an attribute to the element to locate it from the stylesheet, with the name containing a random identifier plus the context.
*
* This function must be passed an injector or called in an injection context. Destroying the injector will remove the attribute and the stylesheet.
*/
function positionElement(elementLike, options) {
const injector = options.injector ?? inject(Injector);
const element = coerceElement(elementLike);
const document = injector.get(DOCUMENT);
const disposables = new Array();
// Generate identifiers to locate the element from the stylesheet.
const elementIdentifier = `data-wb-${options.context}-${UID.randomUID()}`;
const elementIdentifierImportant = `${elementIdentifier}-important`;
// Add stylesheet to change the element's position to relative.
const styleSheet = new CSSStyleSheet({});
styleSheet.insertRule(`
@layer sci-workbench {
[${elementIdentifier}] {
position: relative;
}
}`);
document.adoptedStyleSheets.push(styleSheet);
disposables.push(() => Arrays.remove(document.adoptedStyleSheets, styleSheet));
// Add attribute to locate the element from the stylesheet.
element.setAttribute(elementIdentifier, '');
disposables.push(() => element.removeAttribute(elementIdentifier));
// If CSS layer styles have no effect due to 'static' positioning or unset styles, enforce positioning with !important.
const animationFrame = requestAnimationFrame(() => {
if (getComputedStyle(element).position === 'static') {
styleSheet.insertRule(`
[${elementIdentifierImportant}] {
position: relative !important;
}
`);
element.setAttribute(elementIdentifierImportant, '');
disposables.push(() => element.removeAttribute(elementIdentifierImportant));
}
});
disposables.push(() => cancelAnimationFrame(animationFrame));
// Clean up when the injection context is destroyed.
injector.get(DestroyRef).onDestroy(() => disposables.forEach(disposable => disposable()));
}
/**
* Emits when the next animation frame is executed.
*
* Unlike using `timer(0, animationFrameScheduler)`, this observable emits within the zone where it is subscribed.
*
* Note that the RxJS `animationFrameScheduler` may not necessarily execute in the current execution context, such as inside or outside Angular.
* The scheduler always executes tasks in the zone where it was first used in the application.
*/
function nextAnimationFrame$() {
return new Observable(observer => {
const animationFrame = requestAnimationFrame(() => observer.next());
return () => cancelAnimationFrame(animationFrame);
});
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides access to the workbench layout.
*/
class WorkbenchLayoutService {
_layout = signal(null);
_dragging = signal(false);
_moving = signal(false);
_resizing = signal(false);
/**
* Provides the layout of the workbench, throwing an error if the initial layout is not yet available.
*/
layout = computed(() => this._layout() ?? throwError('[NullLayoutError] Workbench layout not available yet.'));
/**
* Indicates whether the layout is available, i.e., after Angular has performed the initial navigation.
*/
hasLayout = computed(() => this._layout() !== null);
/**
* Resolves when the layout is available, i.e., after Angular has performed the initial navigation.
*/
whenLayoutAvailable = resolveWhen(this.hasLayout);
/**
* Indicates if a drag operation is active, such as moving a view or dialog, or resizing a part.
*/
dragging = computed(() => this._dragging() || this._moving() || this._resizing());
/**
* Controls the alignment of the bottom activity panel.
*
* Defaults to the `--sci-workbench-layout-panel-align` design token, or `justify` if not set.
*/
panelAlignment = renderingFlag('scion.workbench.layout.panel.align', readCssVariable(inject(DOCUMENT).documentElement, '--sci-workbench-layout-panel-align', 'justify'));
/**
* Controls animation of activity panels.
*
* Defaults to the `--sci-workbench-layout-panel-animate` design token, or `true` if not set.
*/
panelAnimation = renderingFlag('scion.workbench.layout.panel.animate', readCssVariable(inject(DOCUMENT).documentElement, '--sci-workbench-layout-panel-animate', true));
/**
* Signals dragging a workbench element, such as dragging a view tab.
*/
signalDragging(dragging) {
this._dragging.set(dragging);
}
/**
* Signals moving a workbench element, such as moving a dialog.
*/
signalMoving(moving) {
this._moving.set(moving);
}
/**
* Signals resizing a workbench element, such as resizing a dialog or part.
*/
signalResizing(resizing) {
this._resizing.set(resizing);
}
/**
* Sets the given {@link WorkbenchLayout}.
*/
setLayout(layout) {
this._layout.set(layout);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: WorkbenchLayoutService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: WorkbenchLayoutService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: WorkbenchLayoutService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Represents the id prefix of parts.
*
* @see PartId
*/
const PART_ID_PREFIX = 'part.';
/**
* Represents the id prefix of views.
*
* @see ViewId
*/
const VIEW_ID_PREFIX = 'view.';
/**
* Represents the id prefix of activities.
*/
const ACTIVITY_ID_PREFIX = 'activity.';
/**
* Represents the id prefix of popups.
*/
const POPUP_ID_PREFIX = 'popup.';
/**
* Represents the id prefix of dialogs.
*/
const DIALOG_ID_PREFIX = 'dialog.';
/**
* Represents the id prefix of message boxes.
*/
const MESSAGE_BOX_ID_PREFIX = 'messagebox.';
/**
* Name of the query parameter that contains the layout of the main area.
*/
const MAIN_AREA_LAYOUT_QUERY_PARAM = 'main_area';
/**
* DI token to inject the context in which the view tab is rendered.
*/
const VIEW_TAB_RENDERING_CONTEXT = new InjectionToken('VIEW_TAB_RENDERING_CONTEXT');
/**
* Prefix used to identify an anonymous perspective that the workbench creates for views moved to a new window.
*/
const ANONYMOUS_PERSPECTIVE_ID_PREFIX = 'anonymous.';
/**
* DI token for registering workbench-specific routes for workbench outlets.
*/
const WORKBENCH_ROUTE = new InjectionToken('WORKBENCH_ROUTE');
/*
* Copyright (c) 2018-2022 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Serializes the execution of elements emitted by the source Observable.
*
* For each element emitted by the source, the specified callback function is called.
* The next element will only be processed once the Promise or Observable returned by
* the callback function resolves or completes.
*/
function serializeExecution(fn) {
return mergeMap(element => fn(element), 1);
}
/**
* Mirrors the source except for `null` emissions.
*/
function filterNull() {
return filter((item) => item !== null);
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Serializes navigation requests to the Angular Router to prevent the cancellation of previously initiated asynchronous navigations.
*/
const ANGULAR_ROUTER_MUTEX = new InjectionToken('ANGULAR_ROUTER_MUTEX', {
providedIn: 'root',
factory: () => new SingleTaskExecutor(),
});
/**
* Allows the serial execution of tasks.
*
* At any one time, there is only one task executing. When submitting a task and if there is a task already executing,
* the submitted task will be queued for later execution.
*
* This executor must be constructed within an injection context. Destroying the injection context will also destroy the executor.
*/
class SingleTaskExecutor {
_task$ = new Subject();
constructor() {
this._task$
.pipe(
// Schedule the task asynchronously so that it is not executed if the executor is destroyed in the same "call stack",
// happening, for example, when destroying the Testbed in unit tests. Angular destroys contexts from the bottom up,
// i.e., child contexts are destroyed before parent contexts. If a task is scheduled in a destroy lifecycle hook of
// a child context, the task would still be executed because the executor is not destroyed yet.
observeOn(asapScheduler), serializeExecution(task => task.execute()), catchError((error, caught) => caught), takeUntilDestroyed())
.subscribe();
}
/**
* Submits a task for serial execution.
*
* Returns a Promise that resolves to the result of the passed task.
*/
submit(task) {
const ɵtask = new Task(task);
this._task$.next(ɵtask);
return ɵtask.await();
}
}
class Task {
_work;
_done$ = new AsyncSubject();
constructor(_work) {
this._work = _work;
}
async execute() {
try {
const result = await this._work();
this._done$.next(result);
this._done$.complete();
}
catch (error) {
this._done$.error(error);
}
}
await() {
return lastValueFrom(this._done$);
}
}
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides methods to associate {@link WorkbenchNavigationalState} with a navigation.
*/
const WorkbenchNavigationalStates = {
/**
* Returns workbench-specific state associated with given navigation, or `null` if the navigation was not performed through the workbench router.
*/
fromNavigation: (navigation) => {
return navigation.extras.state?.[WORKBENCH_NAVIGATION_STATE_KEY] ?? null;
},
/**
* Creates a state object with workbench-specific data that can be passed to a workbench navigation.
*/
create: (state) => {
return {
[WORKBENCH_NAVIGATION_STATE_KEY]: state,
};
},
};
/**
* Key for associating workbench-specific state with a navigation.
*/
const WORKBENCH_NAVIGATION_STATE_KEY = 'ɵworkbench';
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides utility functions for router operations.
*/
const Routing = {
/**
* Converts given URL segments into an array of commands that can be passed to the Angular router for navigation.
*/
segmentsToCommands: (segments) => {
const commands = new Array();
segments.forEach(segment => {
if (segment.path) {
commands.push(segment.path);
}
if (Object.keys(segment.parameters).length) {
commands.push(segment.parameters);
}
});
return commands;
},
/**
* Constructs URL segments from given commands, resolving any relative navigational symbols.
*
* This function must be called within an injection context.
*/
commandsToSegments: (commands, options) => {
// Ignore `relativeTo` for absolute commands.
const isAbsolutePath = typeof commands[0] === 'string' && commands[0].startsWith('/');
const relativeTo = isAbsolutePath ? null : (options?.relativeTo ?? null);
// Angular throws the error 'NG04003: Root segment cannot have matrix parameters' when passing an empty-path command
// followed by a matrix params object, but not when passing matrix params as the first command. For consistency, when
// passing matrix params as the first command, we prepend an empty-path for Angular to throw the same error.
if (typeof commands[0] === 'object' && (!relativeTo || Routing.hasEmptyPathFromRoot(relativeTo))) {
commands = ['', ...commands];
}
const urlTree = inject(Router).createUrlTree(commands, { relativeTo });
return urlTree.root.children[relativeTo?.pathFromRoot[1]?.outlet ?? PRIMARY_OUTLET]?.segments ?? [];
},
/**
* Parses the given path and matrix parameters into an array of commands that can be passed to the Angular router for navigation.
*
* This function must be called within an injection context.
*/
pathToCommands: (path) => {
const urlTree = inject(Router).parseUrl(path);
const segmentGroup = urlTree.root.children[PRIMARY_OUTLET];
if (!segmentGroup?.segments) {
throw Error(`[RouterError] Cannot match any routes for path '${path}'.`);
}
return Routing.segmentsToCommands(segmentGroup.segments);
},
/**
* Resolves the effective (=leaf) {@link ActivatedRoute} activated in a router outlet.
*
* Depending on the route hierarchy, when navigating to a child route with component-less parent routes, the route of an outlet may not be the effectively activated child route.
*/
resolveEffectiveRoute: (route) => {
return route.firstChild ? Routing.resolveEffectiveRoute(route.firstChild) : route;
},
/**
* Resolves the effective (=leaf) {@link OutletContext} of a router outlet.
*
* Depending on the route hierarchy, when navigating to a child route with component-less parent routes, the context of an outlet may not be the effectively activated child context.
*/
resolveEffectiveOutletContext: (outletContext) => {
const childOutletContext = outletContext?.children.getContext(PRIMARY_OUTLET);
return childOutletContext ? Routing.resolveEffectiveOutletContext(childOutletContext) : outletContext;
},
/**
* Looks for requested data on given route, or its parent route(s) if not found.
*/
lookupRouteData: (activatedRoute, dataKey) => {
return activatedRoute.pathFromRoot.reduceRight((resolvedData, route) => resolvedData ?? route.data[dataKey], undefined);
},
/**
* Tests if the given outlet matches the format of a workbench outlet.
*/
isWorkbenchOutlet: (outlet) => {
return Routing.isPartOutlet(outlet) || Routing.isViewOutlet(outlet) || Routing.isPopupOutlet(outlet) || Routing.isDialogOutlet(outlet) || Routing.isMessageBoxOutlet(outlet);
},
/**
* Tests if the given outlet matches the format of the view outlet.
*/
isViewOutlet: (outlet) => {
return outlet?.startsWith(VIEW_ID_PREFIX) ?? false;
},
/**
* Tests if the given outlet matches the format of the part outlet.
*/
isPartOutlet: (outlet) => {
return outlet?.startsWith(PART_ID_PREFIX) ?? false;
},
/**
* Tests if the given outlet matches the format of a popup outlet.
*/
isPopupOutlet: (outlet) => {
return outlet?.startsWith(POPUP_ID_PREFIX) ?? false;
},
/**
* Tests if the given outlet matches the format of a dialog outlet.
*/
isDialogOutlet: (outlet) => {
return outlet?.startsWith(DIALOG_ID_PREFIX) ?? false;
},
/**
* Tests if the given outlet matches the format of a message box outlet.
*/
isMessageBoxOutlet: (outlet) => {
return outlet?.startsWith(MESSAGE_BOX_ID_PREFIX) ?? false;
},
/**
* Reads outlets from given URL.
*/
parseOutlets: parseOutlets,
/**
* Tests if given route has an empty path from root.
*/
hasEmptyPathFromRoot(route) {
return route.snapshot.pathFromRoot.flatMap(route => route.url).filter(segment => segment.path.length).length === 0;
},
/**
* Observes the route activation for the specified outlet.
*
* The observable emits after the previous component has been destroyed (if any) but before constructing the new component.
*
* Options to control when to emit:
* - `routeChange`: Emit only when navigating the outlet to a different route.
* - `routeOrParamChange`: Emit when navigating the outlet to a different route or changing params (named path params or matrix params).
* - `always`: Emit at every navigation, even if the outlet is not the target of the navigation.
*
* This method must be called within an injection context.
*
* Note: Listening for route activations/deactivations on the router outlet alone is insufficient for nested routes, as the outlet does not report
* activations/deactivations of child routes.
*
* @param outlet - Specifies the outlet to observe.
* @param options - Controls when to emit the observable.
* @return An observable emitting a tuple of the previous and current route snapshots.
*/
activatedRoute$(outlet, options) {
const router = inject(Router);
const childrenOutletContexts = inject(ChildrenOutletContexts);
const onNavigationStart$ = router.events.pipe(filterNavigationStart());
const onActivationStart$ = router.events.pipe(filterActivationStart(), filterByOutlet(outlet));
const onActivationEnd$ = router.events.pipe(filterActivationEnd(), filterByOutlet(outlet));
// On each navigation start (`NavigationStart`), subscribe to the outlet's `ActivationStart` event and then to the outlet's deactivate event,
// enabling continuing of the execution chain after the previous component has been destroyed.
//
// If `emitOn` is set to `always`, additionally subscribe to the outlet's `ActivationEnd` event, since no activation (`ActivationStart`) occurs if
// the outlet is not navigated (e.g., navigating another outlet or updating query params).
//
// Note that Angular activates nested routes from parent to child and deactivates them from child to parent, triggering multiple `ActivationStart`
// and `ActivationEnd` events. For the outlet, the latest `ActivationStart` and the first `ActivationEnd` events are relevant.
//
// `ActivationStart` is emitted only if the outlet is actually navigated (route or parameter change), whereas `ActivationEnd` is always emitted.
//
// If a navigation is currently in progress, start immediately, allowing subscriptions even during ongoing navigation, such as after the navigation
// has started.
return iif(() => !router.getCurrentNavigation(), onNavigationStart$, onNavigationStart$.pipe(startWith(undefined)))
.pipe(
// Subscribe to the outlet's `ActivationStart` (and also `ActivationEnd` if the `emitOn` option is set to `always`).
switchMap(() => options.emitOn === 'always' ? race(onActivationStart$, onActivationEnd$.pipe(take(1))) : onActivationStart$), map(event => event.snapshot), startWith(null), // initialize pairwise operator
pairwise(), switchMap(([previous, current]) => {
// If the route hasn't changed and `emitOn` is set to `always` or `routeOrParamChange`, emit immediately since no deactivation will occur.
const routeChanged = previous?.routeConfig !== current.routeConfig;
if (!routeChanged) {
return options.emitOn === 'routeChange' ? EMPTY : of([previous, current]);
}
// If there's no previously activated component in the outlet, emit immediately since no deactivation will occur.
const outletContext = Routing.resolveEffectiveOutletContext(childrenOutletContexts.getContext(outlet));
if (!outletContext?.outlet?.isActivated) {
return of([previous, current]);
}
// Delay emission until the previously activated component has been destroyed by subscribing to the outlet's deactivation event.
// Nested routes activate from parent to child, canceling "deactivation" subscriptions of parent outlets, so only the deactivation of the actual outlet is emitted.
return outletContext.outlet.deactivateEvents.pipe(map(() => [previous, current]));
}));
/**
* Filters {@link NavigationStart} router events.
*/
function filterNavigationStart() {
return filter((event) => event instanceof NavigationStart);
}
/**
* Filters {@link ActivationStart} router events.
*/
function filterActivationStart() {
return filter((event) => event instanceof ActivationStart);
}
/**
* Filters {@link ActivationEnd} router events. This event is emitted for each outlet regardless of whether it has been navigated or not.
*/
function filterActivationEnd() {
return filter((event) => event instanceof ActivationEnd);
}
/**
* Filters router events for the specified outlet.
*/
function filterByOutlet(outlet) {
return filter(event => event.snapshot.pathFromRoot[1]?.outlet === outlet);
}
},
};
function parseOutlets(url, selector) {
const outlets = new Map();
Object.entries(url.root.children).forEach(([outlet, segmentGroup]) => {
if (selector.view && Routing.isViewOutlet(outlet)) {
outlets.set(outlet, segmentGroup.segments);
}
if (selector.part && Routing.isPartOutlet(outlet)) {
outlets.set(outlet, segmentGroup.segments);
}
if (selector.dialog && Routing.isDialogOutlet(outlet)) {
outlets.set(outlet, segmentGroup.segments);
}
if (selector.popup && Routing.isPopupOutlet(outlet)) {
outlets.set(outlet, segmentGroup.segments);
}
if (selector.messagebox && Routing.isMessageBoxOutlet(outlet)) {
outlets.set(outlet, segmentGroup.segments);
}
});
return outlets;
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Matches an array of URL segments against another array of URL segments.
*
* Flags:
* - matchWildcardPath: Indicates if wildcard path matching is enabled. If enabled, the asterisk `*` path matches any path of the segment.
* - matchMatrixParams: Controls whether to match matrix parameters.
*/
class UrlSegmentMatcher {
_pattern;
_flags;
constructor(_pattern, _flags) {
this._pattern = _pattern;
this._flags = _flags;
}
/**
* Matches given array of URL segments.
*/
matches(segments) {
if (segments === this._pattern) {
return true;
}
if (segments.length !== this._pattern.length) {
return false;
}
return segments.every((segment, index) => {
const checkMatrixParams = this._flags.matchMatrixParams;
const checkPath = !this._flags.matchWildcardPath || this._pattern[index].path !== '*';
if (checkPath && segment.path !== this._pattern[index].path) {
return false;
}
if (checkMatrixParams && !Objects$1.isEqual(segment.parameters, this._pattern[index].parameters)) {
return false;
}
return true;
});
}
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides helper functions for working with objects.
*/
const Objects = {
/**
* Like {@link Object.keys}, but preserving the data type of keys.
*/
keys: (object) => {
return Object.keys(object);
},
/**
* Like {@link Object.values}, but preserving the data type of values and supporting optional properties.
*/
values: (object) => {
return Object.values(object);
},
/**
* Like {@link Object.entries}, but preserving the data type of keys and supporting optional properties.
*/
entries: (object) => {
return Object.entries(object);
},
/**
* Like {@link Dictionaries.withoutUndefinedEntries}, but preserving the object data type.
*/
withoutUndefinedEntries: (object) => {
return Dictionaries.withoutUndefinedEntries(object);
},
/**
* Stringifies given object to matrix notation: a=b;c=d;e=f
*/
toMatrixNotation: (object) => {
return Object.entries(object ?? {}).map(([key, value]) => `${key}=${value}`).join(';');
},
/**
* Compares the two objects for shallow equality, ignoring the propery order.
*/
isEqual: (a, b) => {
return Objects$1.isEqual(a, b);
},
};
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Provides a registry for workbench model objects.
*/
class WorkbenchObjectRegistry {
_objects = signal([]);
_objectsById = new Map();
_nullObjectErrorFn;
_onUnregister;
objects = this._objects.asReadonly();
/**
* Creates an instance of the registry.
*
* This registry must be constructed within an injection context. Destroying the injection context will also destroy the registry,
* invoking the specified `onUnregister` function for each object in the registry.
*
* @param config - Configures the registry.
* @param config.nullObjectErrorFn - Function to provide an error when looking up an object not contained in the registry.
* @param config.onUnregister - Function invoked when an object is unregistered.
*/
constructor(config) {
this._nullObjectErrorFn = config?.nullObjectErrorFn ?? ((key) => Error(`[NullObjectError] Object '${key}' not found.`));
this._onUnregister = config?.onUnregister;
// Clear registry when the current injection context is destroyed.
inject(DestroyRef).onDestroy(() => this.clear());
}
/**
* Registers an object under given key, replacing any previously registered object with the same key.
*/
register(key, object) {
const prevObject = this._objectsById.get(key);
// Add to Map.
this._objectsById.set(key, object);
// Add to Signal.
this._objects.update(objects => {
const copy = [...objects];
if (prevObject) {
copy.splice(copy.indexOf(prevObject), 1, object); // Replace object.
}
else {
copy.push(object); // Append object.
}
return copy;
});
}
/**
* Unregisters specified object.
*/
unregister(key) {
const object = this._objectsById.get(key);
if (!object) {
return null;
}
// Invoke unregister function.
this._onUnregister?.(object);
// Remove from Map.
this._objectsById.delete(key);
// Remove from Signal.
this._objects.update(objects => {
const copy = [...objects];
copy.splice(copy.indexOf(object), 1);
return copy;
});
return object;
}
get(key, options) {
const object = this._objectsById.get(key);
if (!object && !options) {
throw this._nullObjectErrorFn(key);
}
return object ?? null;
}
/**
* Indicates whether an object with the specified key exists or not.
*/
has(key) {
return this._objectsById.has(key);
}
/**
* Indicates whether this registry is empty.
*/
isEmpty() {
return this._objectsById.size === 0;
}
/**
* Clears this registry.
*/
clear() {
this._onUnregister && this.objects().forEach(this._onUnregister);
this._objectsById.clear();
this._objects.set([]);
}
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Registry for {@link WorkbenchView} model objects.
*/
const WORKBENCH_VIEW_REGISTRY = new InjectionToken('WORKBENCH_VIEW_REGISTRY', {
providedIn: 'root',
factory: () => new WorkbenchObjectRegistry({
nullObjectErrorFn: viewId => Error(`[NullViewError] View '${viewId}' not found.`),
onUnregister: view => view.destroy(),
}),
});
/**
* Asserts the given object to be of the given type (and not simply a JavaScript object literal).
*
* Throws an error if not of the given type, or if `null` or `undefined`.
*/
function assertType(object, assert) {
if (object === null) {
throw Error(`[AssertError] Object must not be 'null'.`);
}
if (object === undefined) {
throw Error(`[AssertError] Object must not be 'undefined'.`);
}
if (!Arrays.coerce(assert.toBeOneOf).some(expectedType => object instanceof expectedType)) {
const expectedType = Arrays.coerce(assert.toBeOneOf).map(it => it.name).join(' or ');
const actualType = object.constructor.name;
throw Error(`[AssertError] Object not of the expected type [expected=${expectedType}, actual=${actualType}].`);
}
}
/**
* Asserts the given object not to be `null` or `undefined.
*/
function assertNotNullish(value, errorFn) {
if (value === null) {
throw errorFn?.() ?? Error('[AssertError] Value expected not to be `null`, but was `null`.');
}
if (value === undefined) {
throw errorFn?.() ?? Error('[AssertError] Value expected not to be `undefined`, but was `undefined`.');
}
}
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Represents a node in the grid.
*
* A node contains two children, which are either a {@link MPart} or a {@link MTreeNode}, respectively.
* The ratio together with the direction describes how to arrange the two children.
*
* The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change.
*/
class MTreeNode {
/**
* Discriminator to unmarshall {@link MTreeNode} to its class type.
*/
type = 'MTreeNode';
id;
child1;
child2;
ratio;
direction;
parent;
constructor(treeNode) {
treeNode.parent && assertType(treeNode.parent, { toBeOneOf: MTreeNode }); // assert not to be an object literal
assertType(treeNode.child1, { toBeOneOf: [MTreeNode, MPart] }); // assert not to be an object literal
assertType(treeNode.child2, { toBeOneOf: [MTreeNode, MPart] }); // assert not to be an object literal
Object.assign(this, treeNode);
}
/**
* Tests if the given object is a {@link MTreeNode}.
*/
static isMTreeNode(object) {
return !!object && object.type === 'MTreeNode';
}
}
/**
* Represents a part in the grid.
*
* A part can be a stack of views or display content.
*
* The M-prefix indicates this object is a model object that is serialized and stored, requiring migration on breaking change.
*/
class MPart {
/**
* Discriminator to unmarshall {@link MPart} to its class type.
*/
type = 'MPart';
id;
alternativeId;
title;
parent;
views;
activeViewId;
structural;
cssClass;
navigation;
constructor(part) {
part.parent && assertType(part.parent, { toBeOneOf: MTreeNode }); // assert not to be an object literal
Object.assign(this, part);
}
/**
* Tests if the given object is a {@link MPart}.
*/
static isMPart(object) {
return !!object && object.type === 'MPart';
}
}
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Identifies the main area part in the workbench layout.
*
* Refer to this part to align parts relative to the main area.
*
* The main area is a special part that can be added to the layout. The main area is where the workbench opens new views by default.
* It is shared between perspectives and its layout is not reset when resetting perspectives.
*/
const MAIN_AREA = 'part.main-area';
/**
* Represents the alternative id of the main area part.
*
* @see MAIN_AREA
*/
const MAIN_AREA_ALTERNATIVE_ID = 'main-area';
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Migrates serialized workbench data to the latest version.
*/
class WorkbenchMigrator {
_migrators = new Map();
/**
* Registers a migration from a specific version to the next version.
*/
registerMigration(fromVersion, migration) {
this._migrators.set(fromVersion, migration);
return this;
}
/**
* Migrates serialized workbench data to the latest version.
*/
migrate(json, version) {
for (let v = version.from; v < version.to; v++) {
const migrator = this._migrators.get(v);
if (!migrator) {
throw Error(`[NullMigrationError] Cannot perform workbench data migration. No migration registered for version ${v}.`);
}
json = migrator.migrate(json);
}
return json;
}
}
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
const VIEW_ID_PREFIX_V3 = 'view.';
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
/**
* Migrates the workbench layout from version 2 to version 3.
*/
class WorkbenchGridMigrationV3 {
_router = inject(Router);
migrate(json) {
const partGridV2 = JSON.parse(json);
// Consider the ids of views contained in the URL as already used.
// Otherwise, when migrating the main area and using a view id already present in the perspective,
// the view outlet would not be removed from the URL, resulting the migrated view to display
// "Not Found" page or incorrect content.
const viewOutlets = Routing.pa