Commit ca4afee8 authored by Oliver Hader's avatar Oliver Hader Committed by Benni Mack
Browse files

[TASK] Introduce ModuleStateStorage replacing fsMod

JavaScript object :js:`top.fsMod` managing the "state" for page-tree and
file-tree related contexts in the backend user-interface like this:

* `top.fsMod.recentIds.web` contained the current ("recent")
  page or file related identifier details were shown for
* `top.fsMod.navFrameHighlightedID.web` contained the currently
  selected identifier that was highlighted in page-tree or file-tree
* `top.fsMod.currentBank` contained the current mount point or
  file mount ("bank") used in page-tree or file-tree

To get rid of inline JavaScript and reduce usage of JavaScript `top.*`,
mentioned `top.fsMod` has been deprecated and replaced by new component
`ModuleStateStorage`. Reading data from `top.fsMod` is still possible
as a fall-back.

Resolves: #94762
Releases: master
Change-Id: I9e02a1e4c59ad3a007f5244197c1cdaa2a31ce22
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67680

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Wouter Wolters's avatarWouter Wolters <typo3@wouterwolters.nl>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 44821e39
......@@ -21,7 +21,7 @@ import Modal = require('./Modal');
import ModuleMenu = require('./ModuleMenu');
import Notification = require('TYPO3/CMS/Backend/Notification');
import Viewport = require('./Viewport');
import { PageTree } from './PageTree/PageTree';
import {ModuleStateStorage} from './Storage/ModuleStateStorage';
/**
* @exports TYPO3/CMS/Backend/ContextMenuActions
......@@ -240,7 +240,8 @@ class ContextMenuActions {
const eventData = {component: 'contextmenu', action: 'delete', table, uid};
AjaxDataHandler.process('cmd[' + table + '][' + uid + '][delete]=1', eventData).then((): void => {
if (table === 'pages') {
if (uid === top.fsMod.recentIds.web) {
// base on the assumption that the last selected node, is the one that got deleted
if (ModuleStateStorage.current('web').identifier === uid.toString()) {
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:selectFirstNode'));
}
ContextMenuActions.refreshPageTree();
......
......@@ -37,6 +37,10 @@ export class ImmediateActionElement extends HTMLElement {
case 'TYPO3.WindowManager.localOpen':
const windowManager = await import('TYPO3/CMS/Backend/WindowManager');
return windowManager.localOpen.bind(windowManager);
case 'TYPO3.Backend.Storage.ModuleStateStorage.update':
return (await import('TYPO3/CMS/Backend/Storage/ModuleStateStorage')).ModuleStateStorage.update;
case 'TYPO3.Backend.Storage.ModuleStateStorage.updateWithCurrentMount':
return (await import('TYPO3/CMS/Backend/Storage/ModuleStateStorage')).ModuleStateStorage.updateWithCurrentMount;
default:
throw Error('Unknown action "' + action + '"');
}
......
......@@ -76,7 +76,7 @@ export class SelectTree extends SvgTree
* Node selection logic (triggered by different events) to select multiple
* nodes (unlike SVG Tree itself).
*/
public selectNode(node: TreeNode): void {
public selectNode(node: TreeNode, propagate: boolean = true): void {
if (!this.isNodeSelectable(node)) {
return;
}
......@@ -92,7 +92,7 @@ export class SelectTree extends SvgTree
node.checked = !checked;
this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node}}));
this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node, propagate: propagate}}));
this.updateVisibleNodes();
}
......
......@@ -22,9 +22,11 @@ import TriggerRequest = require('./Event/TriggerRequest');
import InteractionRequest = require('./Event/InteractionRequest');
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
import {ModuleStateStorage} from './Storage/ModuleStateStorage';
/**
* Class to render the module menu and handle the BE navigation
* Module: TYPO3/CMS/Backend/ModuleMenu
*/
class ModuleMenu {
private loadedModule: string = null;
......@@ -83,10 +85,10 @@ class ModuleMenu {
} else {
section = moduleData.name.split('_')[0];
}
if (top.fsMod.recentIds[section]) {
params = 'id=' + top.fsMod.recentIds[section] + '&' + params;
const moduleStateStorage = ModuleStateStorage.current(section);
if (moduleStateStorage.selection) {
params = 'id=' + moduleStateStorage.selection + '&' + params;
}
return params;
}
......
......@@ -28,6 +28,7 @@ import {DragDrop, DragDropHandler, DraggablePositionEnum} from '../Tree/DragDrop
import {D3DragEvent} from 'd3-drag';
import Modal = require('../Modal');
import Severity = require('../Severity');
import {ModuleStateStorage} from '../Storage/ModuleStateStorage';
/**
* This module defines the Custom Element for rendering the navigation component for an editable page tree
......@@ -51,7 +52,7 @@ export class EditablePageTree extends PageTree {
public dragDrop: PageTreeDragDrop;
public selectFirstNode(): void {
this.selectNode(this.nodes[0]);
this.selectNode(this.nodes[0], true);
}
public sendChangeCommand(data: any): void {
......@@ -74,7 +75,9 @@ export class EditablePageTree extends PageTree {
params = '&data[pages][' + data.uid + '][' + data.nameSourceField + ']=' + encodeURIComponent(data.title);
} else {
if (data.command === 'delete') {
if (data.uid === window.fsMod.recentIds.web) {
// @todo currently it's "If uid of deleted record (data.uid) is still selected, randomly select the first node"
const moduleStateStorage = ModuleStateStorage.current('web');
if (data.uid === moduleStateStorage.identifier) {
this.selectFirstNode();
}
params = '&cmd[pages][' + data.uid + '][delete]=1';
......@@ -140,13 +143,13 @@ export class EditablePageTree extends PageTree {
return super.appendTextElement(nodes)
.on('click', (event, node: TreeNode) => {
if (node.identifier === '0') {
this.selectNode(node);
this.selectNode(node, true);
return;
}
if (++clicks === 1) {
setTimeout(() => {
if (clicks === 1) {
this.selectNode(node);
this.selectNode(node, true);
} else {
this.editNodeLabel(node);
}
......@@ -396,9 +399,11 @@ export class PageTreeNavigationComponent extends LitElement {
return;
}
//remember the selected page in the global state
top.window.fsMod.recentIds.web = node.identifier;
top.window.fsMod.currentBank = node.stateIdentifier.split('_')[0];
top.window.fsMod.navFrameHighlightedID.web = node.stateIdentifier;
ModuleStateStorage.update('web', node.identifier, true, node.stateIdentifier.split('_')[0]);
if (evt.detail.propagate === false) {
return;
}
let separator = '?';
if (top.window.currentSubScript.indexOf('?') !== -1) {
......@@ -427,10 +432,10 @@ export class PageTreeNavigationComponent extends LitElement {
/**
* Event listener called for each loaded node,
* here used to mark node remembered in fsMod as selected
* here used to mark node remembered in ModuleState as selected
*/
private selectActiveNode = (evt: CustomEvent): void => {
const selectedNodeIdentifier = window.fsMod.navFrameHighlightedID.web;
const selectedNodeIdentifier = ModuleStateStorage.current('web').selection;
let nodes = evt.detail.nodes as Array<TreeNode>;
evt.detail.nodes = nodes.map((node: TreeNode) => {
if (node.stateIdentifier === selectedNodeIdentifier) {
......
/*
* 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!
*/
interface StateChange {
mount?: string;
identifier: string;
selected: boolean;
}
interface CurrentState {
mount?: string;
identifier: string;
selection?: string
}
/**
* Module State previous known as `fsMod` with the previous description:
*
* > Used in main modules with a frameset for submodules to keep the ID
* > between modules Typically that is set by something like this in a
* > Web>* sub module
*
* Reading from `fsMod` is still possible as fall-back, however write
* access is only possible via static API in this `ModuleStateStorage` class.
*
* @exports TYPO3/CMS/Backend/Storage/ModuleStateStorage
*/
export class ModuleStateStorage {
private static prefix = 't3-module-state-';
public static update(module: string, identifier: string|number, selected: boolean, mount?: string|number)
{
if (typeof identifier === 'number') {
identifier = identifier.toString(10);
} else if (typeof identifier !== 'string') {
throw new SyntaxError('identifier must be of type string');
}
if (typeof mount === 'number') {
mount = mount.toString(10);
} else if (typeof mount !== 'string' && typeof mount !== 'undefined') {
throw new SyntaxError('mount must be of type string');
}
const state = ModuleStateStorage.assignProperties(
{mount, identifier, selected} as StateChange,
ModuleStateStorage.fetch(module)
);
ModuleStateStorage.commit(module, state);
}
public static updateWithCurrentMount(module: string, identifier: string|number, selected: boolean)
{
ModuleStateStorage.update(
module,
identifier,
selected,
ModuleStateStorage.current(module).mount
)
}
public static current(module: string): CurrentState {
return ModuleStateStorage.fetch(module) || ModuleStateStorage.createCurrentState();
}
public static purge(): void
{
Object.keys(sessionStorage)
.filter((key: string) => key.startsWith(ModuleStateStorage.prefix))
.forEach((key: string) => sessionStorage.removeItem(key));
}
private static fetch(module: string): CurrentState|null {
const data = sessionStorage.getItem(ModuleStateStorage.prefix + module);
if (data === null) {
return null;
}
return JSON.parse(data);
}
private static commit(module: string, state: CurrentState) {
sessionStorage.setItem(ModuleStateStorage.prefix + module, JSON.stringify(state));
}
private static assignProperties(change: StateChange, state: CurrentState|null): CurrentState
{
let target = Object.assign(ModuleStateStorage.createCurrentState(), state) as CurrentState;
if (change.mount) {
target.mount = change.mount;
}
if (change.identifier) {
target.identifier = change.identifier;
}
if (change.selected) {
target.selection = target.identifier;
}
return target;
}
private static createCurrentState(): CurrentState
{
return {mount: null, identifier: '', selection: null} as CurrentState;
}
}
// exposing `ModuleStateStorage`
(window as any).ModuleStateStorage = ModuleStateStorage;
/**
* Provides fallback handling for reading from `top.fsMod` directly.
* + `top.fsMod.recentIds.web`
* + `top.fsMod.navFrameHighlightedID.web`
* + `top.fsMod.currentBank`
*
* @deprecated `top.fsMod` is deprecated, will be removed in TYPO3 v12.0
*/
if (!top.fsMod || !top.fsMod.isProxy) {
const proxy = (aspect: string): CurrentState => {
return new Proxy<CurrentState>({} as StateChange, {
get(target: Object, p: PropertyKey): any {
const prop = p.toString();
const current = ModuleStateStorage.current(prop);
if (aspect === 'identifier') {
return current.identifier;
}
if (aspect === 'selection') {
return current.selection;
}
return undefined;
},
set(target: Object, p: PropertyKey, value: any, receiver: any): boolean {
throw new Error('Writing to fsMod is not possible anymore, use ModuleStateStorage instead.');
}
});
}
const fsMod = {
isProxy: true,
recentIds:{},
navFrameHighlightedID: {},
currentBank: '0'
};
/*
*/
top.fsMod = new Proxy<Object>(fsMod, {
get(target: Object, p: PropertyKey): any {
const prop = p.toString();
if (prop === 'isProxy') {
return true;
}
console.warn('Reading from fsMod is deprecated, use ModuleStateStorage instead.');
if (prop === 'recentIds') {
return proxy('identifier');
}
if (prop === 'navFrameHighlightedID') {
return proxy('selection');
}
if (prop === 'currentBank') {
return ModuleStateStorage.current('web').mount;
}
return undefined;
},
set(target: Object, p: PropertyKey, value: any, receiver: any): boolean {
throw new Error('Writing to fsMod is not possible anymore, use ModuleStateStorage instead.');
}
});
}
......@@ -530,7 +530,7 @@ export class SvgTree extends LitElement {
.on('mouseover', (evt: MouseEvent, node: TreeNode) => this.onMouseOverNode(node))
.on('mouseout', (evt: MouseEvent, node: TreeNode) => this.onMouseOutOfNode(node))
.on('click', (evt: MouseEvent, node: TreeNode) => {
this.selectNode(node);
this.selectNode(node, true);
this.switchFocusNode(node);
})
.on('contextmenu', (evt: MouseEvent, node: TreeNode) => {
......@@ -555,15 +555,17 @@ export class SvgTree extends LitElement {
/**
* Node selection logic (triggered by different events)
* This represents a dummy method and is usually overridden
* The second argument can be interpreted by the listened events to e.g. not avoid reloading the content frame and instead
* used for just updating the state within the tree
*/
public selectNode(node: TreeNode): void {
public selectNode(node: TreeNode, propagate: boolean = true): void {
if (!this.isNodeSelectable(node)) {
return;
}
// Disable already selected nodes
this.disableSelectedNodes();
node.checked = true;
this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node}}));
this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node, propagate: propagate}}));
this.updateVisibleNodes();
}
......@@ -611,7 +613,7 @@ export class SvgTree extends LitElement {
// re-select the node from the identifier because the nodes have been updated
const currentlySelectedNode = this.getNodeByIdentifier(currentlySelected.stateIdentifier);
if (currentlySelectedNode) {
this.selectNode(currentlySelectedNode);
this.selectNode(currentlySelectedNode, false);
} else {
this.refreshTree();
}
......@@ -787,7 +789,7 @@ export class SvgTree extends LitElement {
})
.attr('dy', 5)
.attr('class', 'node-name')
.on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node));
.on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node, true));
}
protected nodesUpdate(nodes: TreeNodeSelection): TreeNodeSelection {
......@@ -1257,7 +1259,7 @@ export class SvgTree extends LitElement {
break;
case KeyTypes.ENTER:
case KeyTypes.SPACE:
this.selectNode(currentNode);
this.selectNode(currentNode, true);
break;
default:
}
......
......@@ -25,6 +25,7 @@ import Severity = require('../Severity');
import Notification = require('../Notification');
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {TreeNodeSelection, Toolbar} from '../SvgTree';
import {ModuleStateStorage} from '../Storage/ModuleStateStorage';
export const navigationComponentName: string = 'typo3-backend-navigation-component-filestoragetree';
......@@ -122,7 +123,7 @@ export class FileStorageTreeNavigationComponent extends LitElement {
private selectFirstNode = (): void => {
const node = this.tree.nodes[0];
if (node) {
this.tree.selectNode(node);
this.tree.selectNode(node, true);
}
}
......@@ -131,7 +132,7 @@ export class FileStorageTreeNavigationComponent extends LitElement {
const identifier = encodeURIComponent(evt.detail.payload.identifier);
let nodeToSelect = this.tree.nodes.filter((node: TreeNode) => { return node.identifier === identifier})[0];
if (nodeToSelect && this.tree.getSelectedNodes().filter((selectedNode: TreeNode) => { return selectedNode.identifier === nodeToSelect.identifier; }).length === 0) {
this.tree.selectNode(nodeToSelect);
this.tree.selectNode(nodeToSelect, false);
}
}
......@@ -149,8 +150,11 @@ export class FileStorageTreeNavigationComponent extends LitElement {
}
// remember the selected folder in the global state
window.fsMod.recentIds.file = node.identifier;
window.fsMod.navFrameHighlightedID.file = node.stateIdentifier;
ModuleStateStorage.update('file', node.identifier, true);
if (evt.detail.propagate === false) {
return;
}
const separator = (window.currentSubScript.indexOf('?') !== -1) ? '&' : '?';
TYPO3.Backend.ContentContainer.setUrl(
......@@ -175,13 +179,13 @@ export class FileStorageTreeNavigationComponent extends LitElement {
/**
* Event listener called for each loaded node,
* here used to mark node remembered in fsMod as selected
* here used to mark node remembered in ModuleStateStorage as selected
*/
private selectActiveNode = (evt: CustomEvent): void => {
const selectedNodeIdentifier = window.fsMod.navFrameHighlightedID.file;
const selectedNodeIdentifier = ModuleStateStorage.current('file').selection;
let nodes = evt.detail.nodes as Array<TreeNode>;
evt.detail.nodes = nodes.map((node: TreeNode) => {
if (node.stateIdentifier === selectedNodeIdentifier) {
if (node.identifier === selectedNodeIdentifier) {
node.checked = true;
}
return node;
......
......@@ -18,6 +18,7 @@ import {ModalResponseEvent} from 'TYPO3/CMS/Backend/ModalInterface';
import broadcastService = require('TYPO3/CMS/Backend/BroadcastService');
import Tooltip = require('TYPO3/CMS/Backend/Tooltip');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
import {ModuleStateStorage} from 'TYPO3/CMS/Backend/Storage/ModuleStateStorage';
type QueryParameters = {[key: string]: string};
......@@ -48,30 +49,13 @@ class Filelist {
if (mainElement === null) {
return
}
// emit event for currently shown folder
// update ModuleStateStorage to the current folder identifier
const id = encodeURIComponent(mainElement.dataset.filelistCurrentIdentifier);
ModuleStateStorage.update('file', id, true, undefined);
// emit event for currently shown folder so the folder tree gets updated
Filelist.emitTreeUpdateRequest(
mainElement.dataset.filelistCurrentFolderHash
mainElement.dataset.filelistCurrentIdentifier
);
// update recentIds so the current id will be used on accessing the FileList module again
if (top.fsMod) {
const id = encodeURIComponent(mainElement.dataset.filelistCurrentIdentifier);
// top.fsMod.recentIds should always be set by BackendController::generateJavascript(),
// however let's check the type to prevent unnecessary type errors.
if (typeof top.fsMod.recentIds !== 'object') {
top.fsMod.recentIds = {file: id};
} else {
top.fsMod.recentIds.file = id;
}
}
}
private static registerTreeUpdateEvents(): void {
// listen potential change of folder
new RegularEvent('click', function (this: HTMLElement): void {
Filelist.emitTreeUpdateRequest(
this.dataset.treeUpdateRequest
);
}).delegateTo(document.body, '[data-tree-update-request]');
}
private static emitTreeUpdateRequest(identifier: string): void {
......@@ -99,7 +83,6 @@ class Filelist {
Filelist.processTriggers();
DocumentService.ready().then((): void => {
Tooltip.initialize('.table-fit a[title]');
Filelist.registerTreeUpdateEvents();
// file index events
new RegularEvent('click', (event: Event, target: HTMLElement): void => {
event.preventDefault();
......
......@@ -17,6 +17,7 @@ import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import ModuleMenu = require('TYPO3/CMS/Backend/ModuleMenu');
import Viewport = require('TYPO3/CMS/Backend/Viewport');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
import {ModuleStateStorage} from 'TYPO3/CMS/Backend/Storage/ModuleStateStorage';
enum Identifiers {
containerSelector = '#typo3-cms-workspaces-backend-toolbaritems-workspaceselectortoolbaritem',
......@@ -144,7 +145,7 @@ class WorkspacesMenu {
private switchWorkspace(workspaceId: number): void {
(new AjaxRequest(TYPO3.settings.ajaxUrls.workspace_switch)).post({
workspaceId: workspaceId,
pageId: top.fsMod.recentIds.web
pageId: ModuleStateStorage.current('web').identifier
}).then(async (response: AjaxResponse): Promise<any> => {
const data = await response.resolve();
if (!data.workspaceId) {
......@@ -155,7 +156,8 @@ class WorkspacesMenu {
// append the returned page ID to the current module URL
if (data.pageId) {
top.fsMod.recentIds.web = data.pageId;
// @todo actually that's superfluous, since that information was known already when doing the AJAX call
ModuleStateStorage.update('web', data.pageId, true);
let url = TYPO3.Backend.ContentContainer.getUrl();
url += (!url.includes('?') ? '?' : '&') + '&id=' + data.pageId;
WorkspacesMenu.refreshPageTree();
......
......@@ -108,6 +108,7 @@ class BackendController
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/BroadcastService', 'function(service) { service.listen(); }');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Module/Router');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ModuleMenu');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Storage/ModuleStateStorage');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Toolbar');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Notification');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Modal');
......@@ -361,22 +362,7 @@ class BackendController
$this->pageRenderer->addJsInlineCode(
'BackendConfiguration',
'
TYPO3.configuration = ' . json_encode($t3Configuration) . ';
/**
* Frameset Module object
*
* Used in main modules with a frameset for submodules to keep the ID between modules
* Typically that is set by something like this in a Web>* sub module:
* if (top.fsMod) top.fsMod.recentIds["web"] = "\'.(int)$this->id.\'";
* if (top.fsMod) top.fsMod.recentIds["file"] = "...(file reference/string)...";
*/
var fsMod = {
recentIds: [], // used by frameset modules to track the most recent used id for list frame.
navFrameHighlightedID: [], // used by navigation frames to track which row id was highlighted last time
currentBank: "0"
};
',
'TYPO3.configuration = ' . json_encode($t3Configuration) . ';',
false
);
}
......
......@@ -19,6 +19,7 @@ namespace TYPO3\CMS\Backend\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Domain\Model\Element\ImmediateActionElement;
use TYPO3\CMS\Backend\Module\ModuleLoader;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
......@@ -560,19 +561,7 @@ class PageLayoutController
// The page will show only if there is a valid page and if this page may be viewed by the user
if ($this->id && is_array($this->pageinfo)) {
$this->moduleTemplate->getDocHeaderComponent()->setMetaInformation($this->pageinfo);