UNPKG

@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
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