Commit de74be87 authored by Benjamin Franzke's avatar Benjamin Franzke
Browse files

[FEATURE] Introduce backend module web component router

A custom Lit-based web componenent router is added which
reflects module URLs into the browser adress bar and
at the same time prepares for native web components to
be used as future iframe module alternatives.

Such modules will be implemented as JavaScript modules,
that provide an implementation for a custom HTML Web
Component. The first of such components (added within
this change) is a wrapper component for traditional
iframe-based backend modules.

Module state changes are advertised via DOM events and
propagated to the browser address bar, browser title,
and the module menu.

Adress bar updates
==================

The module URL to address bar synchronisation enables sharable
module deeplinks to be copied from the browser address bar.
Whenever a component advertises a state change (e.g.
iframe change), a sharable deep link is generated and
shown in the browser adressbar.

Technical preparation for the required deeplinking has been
added with #93674.

History Management
==================

Browser history state is managed via the iframe
history context of the content module frame
(that is unlike other modern Single Page Applications
which use history.pushState and history.replaceState).

This approach required some synchronisation work, but there are
technical limitations that prevent a combination of iframe history
updates in combination for newer API like history.pushState.
(The limitation is: state added by history.pushState is skipped
by iframe history handling).

The advantage of this compromise is: All "traditional" modules will
preserve their state handling as before, no breaking/behavioral
changes for iframe-based modules.

Routing
=======

The router uses two parameters to perform routing:
 * module – Module name as defined in ext_tables.php
 * endpoint – (json) api to be used by the component (= module URL)
The module attribute is used to perform the actual routing
to the respective backend module component, while the endpoint
attribute serves as API to parse/fetch the state of the module.

A named slot is used to switch between the available module components.
That means only one of the routers childNodes will be visible at a
time, while all modules are actively attached to the DOM.
State is therefore preserved when switching between modules
and the iframe is always kept active, allowing to act as history
state-container (as described in "History Management").

Example of two modules that are attached to the DOM, where
only <typo3-configuration-module> is visible as the <slot>
in the shadow root puts a reference to this childNode:

<typo3-backend-module-router module="system_config" endpoint="…">
  #shadow-root
    <slot name="TYPO3/CMS/Lowlevel/ConfigurationModule"></slot>
  <typo3-iframe-module endpoint="…"
    slot="TYPO3/CMS/Backend/Module/Iframe"></…>
  <typo3-configuration-module endpoint="…"
    slot="TYPO3/CMS/Lowlevel/ConfigurationModule"></…>
</typo3-backend-module-router>

Note: The "TYPO3/CMS/Lowlevel/ConfigurationModule" component is not
yet part of this commit, and only serves as an example (will be
implemented later on). The slot name is resolved from
the module key.

Out of scope for this patch (will follow later)
===============================================

 * Link based routing interception via data-module tag for anchor tags.
   To be added as an additional convenience API on top of the
   router module and endpoint attribute (current API).
 * Convenience components for module layout
 * Integration into shortcut handler
 * Install-tool URLs do not reflect into addressbar right now
   Install-tool modules are redirected, therefore url updates can not
   be mapped as backend URLs right now
   Solution will probably be to integrate the install tool
   components as web component into the backend.

Resolves: #93988
Related: #93674
Releases: master
Change-Id: I682e89649b597c8c74b6a0a8f198f6bcf5bbc347
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67464

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
parent d9c10375
......@@ -172,6 +172,7 @@ body {
min-height: calc(100vh - #{$scaffold-topbar-height});
display: flex;
flex-direction: row;
background: white;
}
.scaffold-content-navigation-iframe,
......@@ -205,6 +206,8 @@ body {
.scaffold-content-module {
flex: 1 0 0%;
display: flex;
flex-direction: row;
}
//
......
......@@ -17,6 +17,7 @@ export enum ScaffoldIdentifierEnum {
moduleMenu = '.t3js-scaffold-modulemenu',
content = '.t3js-scaffold-content',
contentModule = '.t3js-scaffold-content-module',
contentModuleRouter = 'typo3-backend-module-router',
contentModuleIframe = '.t3js-scaffold-content-module-iframe',
contentNavigation = '.t3js-scaffold-content-navigation',
contentNavigationDataComponent = '.t3js-scaffold-content-navigation [data-component]',
......
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
/**
* Module: TYPO3/CMS/Backend/Module
*/
export interface ModuleState {
url: string;
title?: string;
module?: string;
}
/**
* @internal
*/
export interface Module {
name: string;
component: string;
navigationComponentId: string;
navigationFrameScript: string;
navigationFrameScriptParam: string;
link: string;
}
/**
* Gets the module properties from module menu markup (data attributes)
*
* @param {string} name
* @returns {Module}
* @internal
*/
export function getRecordFromName(name: string): Module {
const moduleElement = document.getElementById(name);
if (!moduleElement) {
return {
name: name,
component: '',
navigationComponentId: '',
navigationFrameScript: '',
navigationFrameScriptParam: '',
link: ''
};
}
return {
name: name,
component: moduleElement.dataset.component,
navigationComponentId: moduleElement.dataset.navigationcomponentid,
navigationFrameScript: moduleElement.dataset.navigationframescript,
navigationFrameScriptParam: moduleElement.dataset.navigationframescriptparameters,
link: moduleElement.dataset.link
};
}
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
import {html, css, LitElement, TemplateResult} from 'lit';
import {customElement, property, query} from 'lit/decorators';
import {ModuleState} from '../Module';
import {lll} from 'TYPO3/CMS/Core/lit-helper';
/**
* Module: TYPO3/CMS/Backend/Module/Iframe
*/
export const componentName = 'typo3-iframe-module';
@customElement(componentName)
export class IframeModuleElement extends LitElement {
@property({type: String})
endpoint: string = '';
@query('iframe', true)
iframe: HTMLIFrameElement;
public createRenderRoot(): HTMLElement | ShadowRoot {
// Disable shadow root as <iframe> needs to be accessible
// via top.list_frame for legacy-code and backwards compatibility.
return this;
}
public render(): TemplateResult {
if (!this.endpoint) {
return html``;
}
return html`
<iframe
src="${this.endpoint}"
name="list_frame"
id="typo3-contentIframe"
class="scaffold-content-module-iframe t3js-scaffold-content-module-iframe"
title="${lll('iframe.listFrame')}"
scrolling="no"
@load="${this._loaded}"
></iframe>
`;
}
public attributeChangedCallback(name: string, old: string, value: string) {
super.attributeChangedCallback(name, old, value);
if (name === 'endpoint' && value === old) {
// Trigger explicit reload if value has been reset to current value,
// lit doesn't re-set the attribute in this case.
this.iframe.setAttribute('src', value);
}
}
public connectedCallback(): void {
super.connectedCallback();
if (this.endpoint) {
this.dispatch('typo3-iframe-load', { url: this.endpoint });
}
}
private registerUnloadHandler(iframe: HTMLIFrameElement): void {
try {
iframe.contentWindow.addEventListener('unload', (e: Event) => this._unload(e, iframe), { once: true});
} catch (e) {
console.error('Failed to access contentWindow of module iframe – using a foreign origin?');
throw e;
}
}
private retrieveModuleStateFromIFrame(iframe: HTMLIFrameElement): ModuleState {
try {
return {
url: iframe.contentWindow.location.href,
title: iframe.contentDocument.title,
module: iframe.contentDocument.body.querySelector('.module[data-module-name]')?.getAttribute('data-module-name')
};
} catch (e) {
console.error('Failed to access contentWindow of module iframe – using a foreign origin?');
return { url: this.endpoint };
}
}
private _loaded({target}: Event) {
const iframe = <HTMLIFrameElement> target;
// The event handler for the "unload" event needs to be attached
// after every iframe load (for the current iframes's contentWindow).
this.registerUnloadHandler(iframe);
const state = this.retrieveModuleStateFromIFrame(iframe);
this.dispatch('typo3-iframe-loaded', state);
}
private _unload(e: Event, iframe: HTMLIFrameElement) {
// Asynchronous execution needed because the URL changes immediately after
// the `unload` event is dispatched, but has not been changed right now.
new Promise((resolve) => window.setTimeout(resolve, 0)).then(() => {
if (iframe.contentWindow !== null) {
this.dispatch('typo3-iframe-load', { url: iframe.contentWindow.location.href });
}
});
}
private dispatch(type: 'typo3-iframe-load' | 'typo3-iframe-loaded', state: ModuleState) {
this.dispatchEvent(
new CustomEvent<ModuleState>(type, { detail: state, bubbles: true, composed: true })
);
}
}
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
import {html, css, LitElement, TemplateResult} from 'lit';
import {customElement, property, query} from 'lit/decorators';
import {getRecordFromName, Module, ModuleState} from '../Module';
const IFRAME_COMPONENT = 'TYPO3/CMS/Backend/Module/Iframe';
interface DecoratedModuleState {
slotName: string;
detail: ModuleState;
}
// Trigger a render cycle, even if property has been reset to
// the current value (this is to trigger a module refresh).
const alwaysUpdate = (newVal: string, oldVal: string) => true;
/**
* Module: TYPO3/CMS/Backend/Module/Router
*/
@customElement('typo3-backend-module-router')
export class ModuleRouter extends LitElement {
@property({type: String, hasChanged: alwaysUpdate})
module: string = '';
@property({type: String, hasChanged: alwaysUpdate})
endpoint: string = '';
@property({type: String, attribute: 'state-tracker'})
stateTrackerUrl: string;
@query('slot', true)
slotElement: HTMLSlotElement;
public static styles = css`
:host {
width: 100%;
min-height: 100%;
flex: 1 0 auto;
display: flex;
flex-direction: row;
}
::slotted(*) {
min-height: 100%;
width: 100%;
}
`;
constructor() {
super();
this.addEventListener('typo3-module-load', ({target, detail}: CustomEvent<ModuleState>) => {
const slotName = (target as HTMLElement).getAttribute('slot');
this.pushState({ slotName, detail });
});
this.addEventListener('typo3-module-loaded', ({detail}: CustomEvent<ModuleState>) => {
this.updateBrowserState(detail);
});
this.addEventListener('typo3-iframe-load', ({detail}: CustomEvent<ModuleState>) => {
let state: DecoratedModuleState = {
slotName: IFRAME_COMPONENT,
detail: detail
};
if (state.detail.url.includes(this.stateTrackerUrl + '?state=')) {
const parts = state.detail.url.split('?state=');
state = <DecoratedModuleState>JSON.parse(decodeURIComponent(parts[1] || '{}'));
}
/*
* Event came frame <typo3-iframe-module>, that means it may have been triggered by an
* a) explicit iframe src attribute change or by
* b) browser history backwards or forward navigation
*
* In case of b), the following code block manually synchronizes the slot attribute
*/
if (this.slotElement.getAttribute('name') !== state.slotName) {
// The "name" attribute of <slot> gets of out sync
// due to browser history backwards or forward navigation.
// Synchronize to the state as advertised by the iframe event.
this.slotElement.setAttribute('name', state.slotName)
}
// Mark active and sync endpoint attribute for modules.
// Do not reset endpoint for iframe modules as the URL has already been
// updated and a reset would trigger a reload and another event cycle.
this.markActive(
state.slotName,
this.slotElement.getAttribute('name') === IFRAME_COMPONENT ? null : state.detail.url,
false
);
this.updateBrowserState(state.detail);
// Send load event (e.g. to be handled by ModuleMenu).
// Dispated via parent element to prevent routers own event handlers to be invoked.
// @todo: Introduce a separate event (name) to prevent the parentElement workaround?
this.parentElement.dispatchEvent(new CustomEvent<ModuleState>('typo3-module-load', {
bubbles: true,
composed: true,
detail: state.detail
}));
});
this.addEventListener('typo3-iframe-loaded', ({detail}: CustomEvent<ModuleState>) => {
this.updateBrowserState(detail);
this.parentElement.dispatchEvent(new CustomEvent<ModuleState>('typo3-module-loaded', {
bubbles: true,
composed: true,
detail
}));
});
}
public render(): TemplateResult {
const moduleData = getRecordFromName(this.module);
const jsModule = moduleData.component || IFRAME_COMPONENT;
return html`<slot name="${jsModule}"></slot>`;
}
protected updated(): void {
const moduleData = getRecordFromName(this.module);
const jsModule = moduleData.component || IFRAME_COMPONENT;
this.markActive(jsModule, this.endpoint);
}
private async markActive(jsModule: string, endpoint: string|null, forceEndpointReset: boolean = true): Promise<void> {
const element = await this.getModuleElement(jsModule);
if (endpoint && (forceEndpointReset || element.getAttribute('endpoint') !== endpoint)) {
element.setAttribute('endpoint', endpoint);
}
if (!element.hasAttribute('active')) {
element.setAttribute('active', '');
}
for (let previous = element.previousElementSibling; previous !== null; previous = previous.previousElementSibling) {
previous.removeAttribute('active');
}
for (let next = element.nextElementSibling; next !== null; next = next.nextElementSibling) {
next.removeAttribute('active');
}
}
private async getModuleElement(moduleName: string): Promise<Element> {
let element = this.querySelector(`*[slot="${moduleName}"]`);
if (element !== null) {
return element;
}
try {
const module = await import(moduleName);
// @todo: Check if .componentName exists
element = document.createElement(module.componentName);
} catch (e) {
console.error({msg: `Error importing ${moduleName} as backend module`, err: e})
throw e;
}
element.setAttribute('slot', moduleName);
this.appendChild(element);
return element;
}
private async pushState(state: DecoratedModuleState): Promise<void> {
const url = this.stateTrackerUrl + '?state=' + encodeURIComponent(JSON.stringify(state));
// push dummy route to iframe. to trigger an implicit browser state update
const component = await this.getModuleElement(IFRAME_COMPONENT);
component.setAttribute('endpoint', url);
}
private updateBrowserState(state: ModuleState): void {
const url = new URL(state.url || '', window.location.origin);
const params = new URLSearchParams(url.search);
if (!params.has('token')) {
// non token-urls (e.g. backend install tool) cannot be mapped by
// the main backend controller right now
return;
}
params.delete('token');
url.search = params.toString();
const niceUrl = url.toString();
window.history.replaceState(state, '', niceUrl);
const title = state.title || null;
if (title) {
document.title = title;
}
}
}
......@@ -13,6 +13,7 @@
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {ScaffoldIdentifierEnum} from './Enum/Viewport/ScaffoldIdentifier';
import {getRecordFromName, Module, ModuleState} from './Module';
import $ from 'jquery';
import PersistentStorage = require('./Storage/Persistent');
import Viewport = require('./Viewport');
......@@ -22,14 +23,6 @@ import InteractionRequest = require('./Event/InteractionRequest');
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
interface Module {
name: string;
navigationComponentId: string;
navigationFrameScript: string;
navigationFrameScriptParam: string;
link: string;
}
/**
* Class to render the module menu and handle the BE navigation
*/
......@@ -145,23 +138,6 @@ class ModuleMenu {
$(moduleGroupContainer).stop().slideToggle();
}
/**
* Gets the module properties from module menu markup (data attributes)
*
* @param {string} name
* @returns {Module}
*/
private static getRecordFromName(name: string): Module {
const $subModuleElement = $('#' + name);
return {
name: name,
navigationComponentId: $subModuleElement.data('navigationcomponentid'),
navigationFrameScript: $subModuleElement.data('navigationframescript'),
navigationFrameScriptParam: $subModuleElement.data('navigationframescriptparameters'),
link: $subModuleElement.data('link'),
};
}
/**
* @param {string} module
*/
......@@ -253,7 +229,7 @@ class ModuleMenu {
*/
public showModule(name: string, params?: string, event: Event = null): JQueryDeferred<TriggerRequest> {
params = params || '';
const moduleData = ModuleMenu.getRecordFromName(name);
const moduleData = getRecordFromName(name);
return this.loadModuleComponents(
moduleData,
params,
......@@ -269,25 +245,6 @@ class ModuleMenu {
let deferred = $.Deferred();
deferred.resolve();
// load the start module
if (top.startInModule && top.startInModule[0] && $('#' + top.startInModule[0]).length > 0) {
deferred = this.showModule(
top.startInModule[0],
top.startInModule[1],
);
} else {
// fetch first module
const $firstModule = $('.t3js-modulemenu-action[data-link]:first');
if ($firstModule.attr('id')) {
deferred = this.showModule(
$firstModule.attr('id'),
);
}
// else case: the main module has no entries, this is probably a backend
// user with very little access rights, maybe only the logout button and
// a user settings module in topbar.
}
deferred.then((): void => {
this.initializeModuleMenuEvents();
Viewport.Topbar.Toolbar.registerEvent(() => this.initializeTopBarEvents());
......@@ -438,6 +395,46 @@ class ModuleMenu {
e.preventDefault();
ModuleMenu.toggleMenu(true);
}).bindTo(document.querySelector('.t3js-scaffold-content-overlay'));
const moduleLoadListener = (evt: CustomEvent<ModuleState>) => {
const moduleName = evt.detail.module;
if (!moduleName || this.loadedModule === moduleName) {
return;
}
ModuleMenu.highlightModuleMenuItem(moduleName);
$('#' + moduleName).focus();
this.loadedModule = moduleName;
const moduleData = getRecordFromName(moduleName);
// compatibility
top.currentSubScript = moduleData.link;
top.currentModuleLoaded = moduleName;
// Synchronisze navigation container if module is a standalone module (linked via ModuleMenu).
// Do not hide navigation for intermediate modules like record_edit, which may be used
// with our without a navigation component, depending on the context.
if (moduleData.link) {
if (moduleData.navigationComponentId) {
Viewport.NavigationContainer.showComponent(moduleData.navigationComponentId);
} else if (moduleData.navigationFrameScript) {
Viewport.NavigationContainer.show('typo3-navigationIframe');
const interactionRequest = new ClientRequest('typo3.showModule', event);
this.openInNavFrame(
moduleData.navigationFrameScript,
moduleData.navigationFrameScriptParam,
new TriggerRequest(
'typo3.loadModuleComponents',
new ClientRequest('typo3.showModule', null)
),
);
} else {
Viewport.NavigationContainer.hide(false);
}
}
};
document.addEventListener('typo3-module-load', moduleLoadListener);
document.addEventListener('typo3-module-loaded', moduleLoadListener);
}
/**
......@@ -480,7 +477,8 @@ class ModuleMenu {
ModuleMenu.highlightModuleMenuItem(moduleName);
this.loadedModule = moduleName;
params = ModuleMenu.includeId(moduleData, params);
this.openInContentFrame(
this.openInContentContainer(
moduleName,
moduleData.link,
params,
new TriggerRequest(
......@@ -526,18 +524,20 @@ class ModuleMenu {
}
/**
* @param {string} module
* @param {string} url