Skip to content

Commit

Permalink
feat(workbench/router): support navigation to children of the empty p…
Browse files Browse the repository at this point in the history
…ath route

Previously, views could not be navigated to child routes of the top-level empty path route. This limitation has now been removed, enabling the registration of route guards at a single place.

```ts
{
  path: '',
  canActivate: [authGuard()],
  children: [
    {
      path: 'view-1',
      loadComponent: () => import('./view/view-1.component'),
    },
    {
      path: 'view-2',
      loadComponent: () => import('./view/view-2.component'),
    },
  ]
},
```

closes #487
  • Loading branch information
danielwiehl authored and Marcarrian committed May 7, 2024
1 parent a280af9 commit da578a9
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {inject, Injector} from '@angular/core';
import {Commands} from '../../routing/routing.model';
import {ɵWorkbenchRouter} from '../../routing/ɵworkbench-router.service';
import {WorkbenchLayouts} from '../../layout/workbench-layouts.util';
import {WorkbenchRouteData} from '../../routing/workbench-route-data';

/**
* Provides functions and constants specific to microfrontend routes.
Expand Down Expand Up @@ -47,15 +48,19 @@ export const MicrofrontendViewRoutes = {
const injector = inject(Injector);

return (segments: UrlSegment[], group: UrlSegmentGroup, route: Route): UrlMatchResult | null => {
if (!WorkbenchLayouts.isViewId(route.outlet)) {
// Test if the path matches.
if (!MicrofrontendViewRoutes.isMicrofrontendRoute(segments)) {
return null;
}
if (!MicrofrontendViewRoutes.isMicrofrontendRoute(segments)) {

// Test if navigating a view.
const outlet = route.data?.[WorkbenchRouteData.ɵoutlet];
if (!WorkbenchLayouts.isViewId(outlet)) {
return null;
}

const {layout} = injector.get(ɵWorkbenchRouter).getCurrentNavigationContext();
const viewState = layout.viewState({viewId: route.outlet});
const viewState = layout.viewState({viewId: outlet});
const transientParams = viewState[MicrofrontendViewRoutes.STATE_TRANSIENT_PARAMS] ?? {};
const posParams = Object.entries(transientParams).map(([name, value]) => [name, new UrlSegment(value, {})]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {MicrofrontendPopupCapabilityValidator} from './microfrontend-popup/micro
import {MicrofrontendDialogIntentHandler} from './microfrontend-dialog/microfrontend-dialog-intent-handler.interceptor';
import {MicrofrontendDialogCapabilityValidator} from './microfrontend-dialog/microfrontend-dialog-capability-validator.interceptor';
import {Defined} from '@scion/toolkit/util';
import {canMatchWorkbenchView} from '../view/workbench-view-route-guards';
import './microfrontend-platform.config'; // DO NOT REMOVE to augment `MicrofrontendPlatformConfig` with `splash` property.

/**
Expand Down Expand Up @@ -138,6 +139,7 @@ function provideMicrofrontendRoute(): EnvironmentProviders {
useFactory: (): Route => ({
matcher: MicrofrontendViewRoutes.provideMicrofrontendRouteMatcher(),
component: MicrofrontendViewComponent,
canMatch: [canMatchWorkbenchView(true)],
}),
},
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<router-outlet/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:host {
display: grid;

> router-outlet {
position: absolute; // out of document flow
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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
*/

import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router';

/**
* Angular standardizes component-less auxiliary routes by adding the `ɵEmptyOutletComponent` component,
* but only for routes registered via {@link ROUTES} DI token or passed to {@link Router#resetConfig}.
*
* Consequently, auxiliary routes that the workbench dynamically registers based on the current workbench
* state must also be standardized. However, we do not use Angular's {@link ɵEmptyOutletComponent} component
* as it does not fill the content to the available space, required for view content.
*
* For more information, see the `standardizeConfig` function in Angular.
*/
@Component({
templateUrl: './empty-outlet.component.html',
styleUrls: ['./empty-outlet.component.scss'],
standalone: true,
imports: [RouterOutlet],
})
export class ɵEmptyOutletComponent {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
*/

import {Injectable, InjectionToken} from '@angular/core';
import {CanMatchFn, PRIMARY_OUTLET, Route, Router, Routes, ɵEmptyOutletComponent} from '@angular/router';
import {CanMatchFn, PRIMARY_OUTLET, Route, Router, Routes} from '@angular/router';
import {WorkbenchConfig} from '../workbench-config';
import PageNotFoundComponent from '../page-not-found/page-not-found.component';
import {WorkbenchRouteData} from './workbench-route-data';
import {ɵEmptyOutletComponent} from './empty-outlet/empty-outlet.component';

/**
* Facilitates the registration of auxiliary routes of top-level routes.
Expand All @@ -34,28 +35,24 @@ export class WorkbenchAuxiliaryRoutesRegistrator {
return [];
}

const registeredRoutes: Routes = [];
outlets.forEach(outlet => {
this._router.config
.filter(route => !route.outlet || route.outlet === PRIMARY_OUTLET)
.forEach(route => {
registeredRoutes.push(standardizeConfig({
...route,
outlet,
providers: [{provide: WORKBENCH_AUXILIARY_ROUTE_OUTLET, useValue: outlet}, ...(route.providers ?? [])],
}));
});

// Register "Page Not Found" route; must be registered as the last auxiliary route for the outlet.
registeredRoutes.push(standardizeConfig({
path: '**',
outlet,
providers: [{provide: WORKBENCH_AUXILIARY_ROUTE_OUTLET, useValue: outlet}],
loadComponent: () => this._workbenchConfig.pageNotFoundComponent ?? PageNotFoundComponent,
data: {[WorkbenchRouteData.title]: 'Page Not Found', [WorkbenchRouteData.cssClass]: 'e2e-page-not-found'},
canMatch: config.canMatchNotFoundPage || [],
}));
});
const registeredRoutes = outlets.map(outlet => ({
path: '',
outlet,
providers: [{provide: WORKBENCH_AUXILIARY_ROUTE_OUTLET, useValue: outlet}],
component: ɵEmptyOutletComponent,
children: [
...this._router.config
.filter(route => !route.outlet || route.outlet === PRIMARY_OUTLET)
.map(route => ({...route, data: {...route.data, [WorkbenchRouteData.ɵoutlet]: outlet}})),
// Register "Page Not Found" route as the last route of the outlet.
{
path: '**',
loadComponent: () => this._workbenchConfig.pageNotFoundComponent ?? PageNotFoundComponent,
data: {[WorkbenchRouteData.title]: 'Page Not Found', [WorkbenchRouteData.cssClass]: 'e2e-page-not-found'},
canMatch: config.canMatchNotFoundPage,
},
],
}));

this.replaceRouterConfig([
...this._router.config,
Expand Down Expand Up @@ -103,22 +100,8 @@ export interface AuxiliaryRouteConfig {
}

/**
* Standardizes given route for registration. Copied from Angular 'router/src/utils/config.ts#standardizeConfig'.
*
* Performs the following steps:
* - Sets the `component` property to {@link ɵEmptyOutletComponent} if given route is a component-less parent route; see Angular PR #23459.
*/
function standardizeConfig(route: Route): Route {
if (!route.component && !route.loadComponent && (route.children || route.loadChildren)) {
route.component = ɵEmptyOutletComponent;
}
route.children?.forEach(standardizeConfig);
return route;
}

/**
* DI token to inject the outlet of a workbench auxiliary route.
* DI token to inject the outlet name of a workbench auxiliary route.
*
* Can be injected in a `CanMatch` guard to obtain a reference to the workbench element.
*/
export const WORKBENCH_AUXILIARY_ROUTE_OUTLET = new InjectionToken<string>('WORKBENCH_AUXILIARY_ROUTE_OUTLET');
export const WORKBENCH_AUXILIARY_ROUTE_OUTLET = new InjectionToken<string>('ɵWORKBENCH_AUXILIARY_ROUTE_OUTLET');
11 changes: 11 additions & 0 deletions projects/scion/workbench/src/lib/routing/workbench-route-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ export const WorkbenchRouteData = {
* Property to associate CSS class(es) with a view in {@link Route.data}, e.g., to locate the view in tests.
*/
cssClass: 'ɵworkbenchViewCssClass',

/**
* @internal
*
* Property to obtain the outlet name of the route. This property is only set on the top-level route.
*
* Use if the route's injection context is not available, e.g., in a {@link UrlMatcher}.
* Otherwise, the outlet can be injected using the {@link WORKBENCH_AUXILIARY_ROUTE_OUTLET} DI token,
* even in child routes, e.g., in guards.
*/
ɵoutlet: 'ɵworkbenchOutlet',
} as const;
45 changes: 45 additions & 0 deletions projects/scion/workbench/src/lib/view/view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,51 @@ describe('View', () => {
expect(TestBed.inject(WorkbenchViewRegistry).get('view.100').getComponent()).toBe(getComponent(fixture, SpecView2Component));
});

it('should fill view content to available space', async () => {
TestBed.configureTestingModule({
providers: [
provideWorkbenchForTest(),
provideRouter([
{
path: 'view',
component: SpecViewComponent,
},
{
path: 'path',
loadChildren: () => [
{
path: 'to',
loadChildren: () => [
{
path: 'view',
component: SpecViewComponent,
},
],
},
],
},

]),
],
});
const fixture = styleFixture(TestBed.createComponent(WorkbenchComponent));
await waitForInitialWorkbenchLayout();

// Navigate to "view".
await TestBed.inject(ɵWorkbenchRouter).navigate(['view'], {target: 'view.100'});
await waitUntilStable();

// Expect size to be equal.
expect(getSize(fixture, SpecViewComponent)).toEqual(getSize(fixture, ViewComponent));

// Navigate to "path/to/view".
await TestBed.inject(ɵWorkbenchRouter).navigate(['path/to/view'], {target: 'view.100'});
await waitUntilStable();

// Expect size to be equal.
expect(getSize(fixture, SpecViewComponent)).toEqual(getSize(fixture, ViewComponent));
});

describe('Activated Route', () => {

it('should set title and heading from route', async () => {
Expand Down

0 comments on commit da578a9

Please sign in to comment.