Skip to content

Commit

Permalink
feat(platform/client): enable microfrontend to display a splash until…
Browse files Browse the repository at this point in the history
… loaded

Loading and bootstrapping a microfrontend can take some time, at worst, only displaying content once initialized.

To indicate the loading of a microfrontend, the navigator can instruct the router outlet to display a splash until the microfrontend signals readiness.

```
Beans.get(OutletRouter).navigate('path/to/microfrontend', {showSplash: true});
```

The splash is the markup between the opening and closing tags of the router outlet element.

```
<sci-router-outlet>
  Loading...
</sci-router-outlet>
```

The splash is displayed until the embedded microfrontend signals readiness.

```
MicrofrontendPlatformClient.signalReady();
```

To lay out the content of the splash use the pseudo-element selector `::part(splash)`.

Example of centering splash content in a CSS grid container:
```
sci-router-outlet::part(splash) {
  display: grid;
  place-content: center;
}
```
  • Loading branch information
Marcarrian authored and danielwiehl committed Oct 23, 2023
1 parent 8a8e4ac commit f44087f
Show file tree
Hide file tree
Showing 24 changed files with 798 additions and 79 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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
*/

import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion';

/**
* Parses the typed string values of given object to its actual type.
*
* Supported typed values:
* - '<undefined>' => undefined
* - '<null>' => null
* - '<number>123</number>' => 123
* - '<boolean>true</boolean>' => true
* - '<string>value</string>' => 'value'
* - '<json>{"key": "value"}</json>' => {"key": "value"}
* - 'value' => 'value'
*/
export function parseTypedValues(object: Record<string, unknown> | null | undefined): Record<string, unknown> | null | undefined;
export function parseTypedValues(object: Map<string, unknown> | null | undefined): Map<string, unknown> | null | undefined;
export function parseTypedValues(object: Record<string, unknown> | Map<string, unknown> | null | undefined): Record<string, unknown> | Map<string, unknown> | null | undefined {
if (object === null || object === undefined) {
return object;
}
else if (object instanceof Map) {
return Array
.from(object.entries())
.reduce((acc, [key, value]) => acc.set(key, parseTypedValue(value)), new Map<string, unknown>());
}
else {
return Object.entries(object).reduce((acc, [key, value]) => {
acc[key] = parseTypedValue(value);
return acc;
}, {} as Record<string, unknown>);
}
}

/**
* Parses the typed string value to its actual type.
*
* Supported typed values:
* - '<undefined>' => undefined
* - '<null>' => null
* - '<number>123</number>' => 123
* - '<boolean>true</boolean>' => true
* - '<string>value</string>' => 'value'
* - '<json>{"key": "value"}</json>' => {"key": "value"}
* - 'value' => 'value'
*/
export function parseTypedValue(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
if (value === '<null>') {
return null;
}
if (value === '<undefined>') {
return undefined;
}

const paramMatch = value.match(/<(?<type>.+)>(?<value>.+)<\/\k<type>>/);
switch (paramMatch?.groups!['type']) {
case 'number': {
return coerceNumberProperty(paramMatch.groups['value']);
}
case 'boolean': {
return coerceBooleanProperty(paramMatch.groups['value']);
}
case 'string': {
return paramMatch.groups['value'];
}
case 'json': {
return JSON.parse(paramMatch.groups['value']);
}
default: {
return value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox';
import {SciFormFieldComponent} from '@scion/components.internal/form-field';
import {SciListComponent, SciListItemDirective} from '@scion/components.internal/list';
import {SciQualifierChipListComponent} from '@scion/components.internal/qualifier-chip-list';
import {parseTypedValues} from '../../common/typed-value-parser.util';

@Component({
selector: 'app-register-capability',
Expand Down Expand Up @@ -77,7 +78,7 @@ export default class RegisterCapabilityComponent {
qualifier: SciKeyValueFieldComponent.toDictionary(this.registerForm.controls.qualifier) ?? undefined,
params: params ? JSON.parse(params) : undefined,
private: this.registerForm.controls.private.value,
properties: SciKeyValueFieldComponent.toDictionary(this.registerForm.controls.properties) ?? undefined,
properties: parseTypedValues(SciKeyValueFieldComponent.toDictionary(this.registerForm.controls.properties)) ?? undefined,
};

Beans.get(ManifestService).registerCapability(capability)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {Subscription} from 'rxjs';
import {distinctUntilChanged, finalize, startWith} from 'rxjs/operators';
import {KeyValueEntry, SciKeyValueFieldComponent} from '@scion/components.internal/key-value-field';
import {Beans} from '@scion/toolkit/bean-manager';
import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion';
import {AsyncPipe, NgFor, NgIf} from '@angular/common';
import {SciCheckboxComponent} from '@scion/components.internal/checkbox';
import {TopicSubscriberCountPipe} from '../topic-subscriber-count.pipe';
Expand All @@ -24,6 +23,7 @@ import {AppAsPipe} from '../../common/as.pipe';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {SciFormFieldComponent} from '@scion/components.internal/form-field';
import {SciListComponent, SciListItemDirective} from '@scion/components.internal/list';
import {parseTypedValues} from '../../common/typed-value-parser.util';

@Component({
selector: 'app-publish-message',
Expand Down Expand Up @@ -166,10 +166,7 @@ export default class PublishMessageComponent implements OnDestroy {
const destinationFormGroup = this.form.controls.destination as FormGroup<IntentMessageDestination>;
const type = destinationFormGroup.controls.type.value;
const qualifier = SciKeyValueFieldComponent.toDictionary(destinationFormGroup.controls.qualifier) ?? undefined;
const params = SciKeyValueFieldComponent.toMap(destinationFormGroup.controls.params) ?? undefined;

// Convert entered params to their actual values.
params?.forEach((paramValue, paramName) => params.set(paramName, convertValueFromUI(paramValue)));
const params = parseTypedValues(SciKeyValueFieldComponent.toMap(destinationFormGroup.controls.params)) ?? undefined;

const message = this.form.controls.message.value || undefined;
const requestReply = this.form.controls.requestReply.value;
Expand Down Expand Up @@ -212,45 +209,6 @@ export default class PublishMessageComponent implements OnDestroy {
}
}

/**
* Converts the value entered via the UI to its actual type.
*
* Examples:
* - '<undefined>' => undefined
* - '<null>' => null
* - '<number>123</number>' => 123
* - '<boolean>true</boolean>' => true
* - '<string>value</string>' => 'value'
* - '<json>{"key": "value"}</json>' => {"key": "value"}
* - 'value' => 'value'
*/
function convertValueFromUI(value: string): string | number | boolean | object | undefined | null {
if ('<undefined>' === value) {
return undefined;
}
else if ('<null>' === value) {
return null;
}
const paramMatch = value.match(/<(?<type>.+)>(?<value>.+)<\/\k<type>>/);
switch (paramMatch?.groups!['type']) {
case 'number': {
return coerceNumberProperty(paramMatch.groups['value']);
}
case 'boolean': {
return coerceBooleanProperty(paramMatch.groups['value']);
}
case 'string': {
return paramMatch.groups['value'];
}
case 'json': {
return JSON.parse(paramMatch.groups['value']);
}
default: {
return value;
}
}
}

enum MessagingFlavor {
Topic = 'Topic', Intent = 'Intent',
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
<sci-form-field label="Push State" title="Pushes a state to the browser's session history stack, allowing the user to use the back button to navigate back.">
<sci-checkbox [formControl]="form.controls.pushSessionHistoryState" class="e2e-push-state"></sci-checkbox>
</sci-form-field>
<sci-form-field label="Show Splash" title="Shows a splash until the microfrontend signals readiness.">
<sci-checkbox [formControl]="form.controls.showSplash" class="e2e-show-splash"></sci-checkbox>
</sci-form-field>

<button type="submit" class="e2e-navigate" (click)="onNavigateClick()" [disabled]="form.invalid">Navigate</button>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default class OutletRouterComponent {
destination: this._formBuilder.group<UrlDestination | IntentDestination>(this.createUrlDestination()),
params: this._formBuilder.array<FormGroup<KeyValueEntry>>([]),
pushSessionHistoryState: this._formBuilder.control(false),
showSplash: this._formBuilder.control(false),
});

public navigateError: string | undefined;
Expand All @@ -66,6 +67,7 @@ export default class OutletRouterComponent {
const options: NavigationOptions = {
outlet: this.form.controls.outlet.value || undefined,
params: SciKeyValueFieldComponent.toMap(this.form.controls.params) ?? undefined,
showSplash: this.form.controls.showSplash.value,
};
if (this.form.controls.pushSessionHistoryState.value) {
options.pushStateToSessionHistoryStack = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {A11yModule} from '@angular/cdk/a11y';
import {ContextEntryComponent} from '../context-entry/context-entry.component';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {SciListComponent, SciListItemDirective} from '@scion/components.internal/list';
import {parseTypedValue} from '../common/typed-value-parser.util';

const OVERLAY_POSITION_SOUTH: ConnectedPosition = {originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'};

Expand Down Expand Up @@ -60,22 +61,10 @@ export class RouterOutletContextComponent {
}

public onAddClick(): void {
this.routerOutlet.setContextValue(this.form.controls.name.value, this.parseContextValueFromUI());
this.routerOutlet.setContextValue(this.form.controls.name.value, parseTypedValue(this.form.controls.value.value));
this.form.reset();
}

private parseContextValueFromUI(): string | null | undefined {
const value = this.form.controls.value.value;
switch (value) {
case '<undefined>':
return undefined;
case '<null>':
return null;
default:
return value;
}
}

public onRemoveClick(name: string): void {
this.routerOutlet.removeContextValue(name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
(activate)="onActivate($event)"
(deactivate)="onDeactivate($event)"
(focuswithin)="onFocusWithin($event)">
<sci-throbber type="ellipsis" class="e2e-slotted-content"></sci-throbber>
</sci-router-outlet>
<div class="empty-note e2e-empty" *ngIf="empty$ | async">Router Outlet is empty</div>
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
flex: auto;
border: 1px solid var(--sci-color-P300);
border-radius: 3px;

&::part(splash) {
display: grid;
place-content: center;
}
}

> div.empty-note {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {SciRouterOutletElement} from '@scion/microfrontend-platform';
import {RouterOutletSettingsComponent} from '../router-outlet-settings/router-outlet-settings.component';
import {NEVER, Observable} from 'rxjs';
import {AsyncPipe, NgIf} from '@angular/common';
import {SciThrobberComponent} from '@scion/components/throbber';

@Component({
selector: 'app-router-outlet',
Expand All @@ -27,6 +28,7 @@ import {AsyncPipe, NgIf} from '@angular/common';
NgIf,
AsyncPipe,
ReactiveFormsModule,
SciThrobberComponent,
],
})
export default class RouterOutletComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ export default [
loadComponent: () => import('./scrollable-microfrontend/scrollable-microfrontend.component'),
data: {pageTitle: 'Displays a microfrontend with some tall content displayed in a viewport'},
},
{
path: 'signal-ready-test-page',
loadComponent: () => import('./signal-ready-test-page/signal-ready-test-page.component'),
data: {pageTitle: 'Signals readiness when a message is published to the topic `signal-ready/outletName`'},
},
] satisfies Routes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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
*/
import {Component} from '@angular/core';
import {Beans} from '@scion/toolkit/bean-manager';
import {ContextService, MessageClient, MicrofrontendPlatformClient, OUTLET_CONTEXT, OutletContext} from '@scion/microfrontend-platform';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {from, switchMap} from 'rxjs';

/**
* Signals readiness when a message is published to the topic `signal-ready/outletName`.
*/
@Component({
selector: 'app-signal-ready-test-page',
template: '',
standalone: true,
})
export default class SignalReadyTestPageComponent {

constructor() {
from(Beans.get(ContextService).lookup<OutletContext>(OUTLET_CONTEXT))
.pipe(
switchMap(outletContext => Beans.get(MessageClient).observe$(`signal-ready/${outletContext!.name}`)),
takeUntilDestroyed(),
)
.subscribe(() => MicrofrontendPlatformClient.signalReady());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ In this Chapter
- <<chapter:router-outlet:keystroke-bubbling>>
- <<chapter:router-outlet:router-outlet-events>>
- <<chapter:router-outlet:outlet-context>>
- <<chapter:router-outlet:splash>>
- <<chapter:router-outlet:router-outlet-api>>
****
'''
Expand Down Expand Up @@ -209,6 +210,39 @@ include::outlet.snippets.ts[tags=router-outlet:observe-context-value]
<1> Looks up the `highlighting-topic` from the current context.
<2> Sends an event to the `highlighting-topic` when its data changes.

[[chapter:router-outlet:splash]]
[discrete]
=== Splash
Loading and bootstrapping a microfrontend can take some time, at worst, only displaying content once initialized. To indicate the loading of a microfrontend, the navigator can instruct the router outlet to display a splash until the microfrontend signals readiness.

[source,typescript]
----
include::outlet.snippets.ts[tags=router-outlet:show-splash-flag]
----

The splash is the markup between the opening and closing tags of the router outlet element.

[source,html]
----
include::outlet.snippets.ts[tags=router-outlet:splash]
----

The splash is displayed until the embedded microfrontend signals readiness.

[source,typescript]
----
include::outlet.snippets.ts[tags=router-outlet:signal-readiness]
----

To lay out the content of the splash use the pseudo-element selector `::part(splash)`.

Example of centering splash content in a CSS grid container:

[source,typescript]
----
include::outlet.snippets.ts[tags=router-outlet:lay-out-splash-content]
----

[[chapter:router-outlet:router-outlet-api]]
[discrete]
=== Router Outlet API
Expand Down
Loading

0 comments on commit f44087f

Please sign in to comment.