Commit 9859535f authored by Jasmina Ließmann's avatar Jasmina Ließmann Committed by Andreas Fernandez
Browse files

[TASK] Add search field to active 'Page TSconfig'

The "Page TSconfig" backend module now has a search that
allows an integrator to search PageTS either by a settings
identifier (e.g. "mod.linkval") or a value (e.g. "textpic").

Partial searches are also supported, "wizardItems" resolves
to "mod.wizards.newContentElement.wizardItems".

The search value is stored in the browser session and retained
when the module is visited again. To avoid conflicts with the
internal `collapse-state-persister` module, a new setting is
introduced to disable the modules recovery behavior if a
specific key is available in localStorage or sessionStorage.

Resolves: #99602
Releases: main
Change-Id: If1b91d936597406b3d553e81b2ced1076bc054cf
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77481


Tested-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Andreas Fernandez <a....
parent c0952199
......@@ -573,6 +573,23 @@ module.exports = function (grunt) {
report: false,
srcPrefix: "node_modules/"
},
backend: {
options: {
destPrefix: "<%= paths.backend %>Public",
copyOptions: {
process: (source, srcpath) => {
if (srcpath.match(/.*\.js$/)) {
return require('./util/cjs-to-esm.js').cjsToEsm(source);
}
return source;
}
}
},
files: {
'JavaScript/Contrib/mark.js': 'mark.js/dist/mark.es6.min.js'
}
},
dashboardToEs6: {
options: {
destPrefix: "<%= paths.dashboard %>Public",
......@@ -790,7 +807,7 @@ module.exports = function (grunt) {
}
},
concurrent: {
npmcopy: ['npmcopy:dashboard', 'npmcopy:umdToEs6', 'npmcopy:jqueryUi', 'npmcopy:install', 'npmcopy:all'],
npmcopy: ['npmcopy:dashboard', 'npmcopy:backend', 'npmcopy:umdToEs6', 'npmcopy:jqueryUi', 'npmcopy:install', 'npmcopy:all'],
lint: ['eslint', 'stylelint', 'exec:lintspaces'],
compile_assets: ['scripts', 'css'],
compile_flags: ['flags-build'],
......
/*
* 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 BrowserSession from '@typo3/backend/storage/browser-session';
import {Collapse as BootstrapCollapse} from 'bootstrap';
import '@typo3/backend/input/clearable';
import DocumentService from '@typo3/core/document-service';
import DebounceEvent from '@typo3/core/event/debounce-event';
import RegularEvent from '@typo3/core/event/regular-event';
import Mark from '@typo3/backend/contrib/mark';
/**
* Module: @typo3/backend/page-tsconfig
* JavaScript for Page TSconfig
* @exports @typo3/backend/page-tsconfig
*/
class PageTSconfigBrowser {
private readonly termSessionStorageKey = 'pagets-search-term';
private searchField: HTMLInputElement;
private searchForm: HTMLFormElement;
private pageTsTreeContainer: HTMLElement|null;
private markInstance: any;
constructor() {
DocumentService.ready().then(() => {
this.pageTsTreeContainer = document.querySelector('.t3js-pagets-tree');
if (this.pageTsTreeContainer === null) {
return;
}
this.searchField = document.querySelector('input[name="searchValue"]');
this.searchForm = this.searchField.closest('form');
this.registerEvents();
this.markInstance = new Mark(this.pageTsTreeContainer);
const searchTermInSession = BrowserSession.get(this.termSessionStorageKey);
if (searchTermInSession) {
this.searchField.value = searchTermInSession;
// Trigger "keyup" event to update clearable status
this.searchField.dispatchEvent(new Event('keyup'));
this.searchForm.requestSubmit();
}
});
}
private registerEvents(): void {
this.searchField.clearable({
onClear: (input: HTMLInputElement): void => {
input.closest('form').requestSubmit();
},
});
new DebounceEvent('input', (): void => {
this.searchForm.requestSubmit();
}).bindTo(this.searchField);
new RegularEvent('submit', (e: SubmitEvent): void => {
e.preventDefault();
this.filterTree(this.searchField.value);
}).bindTo(this.searchForm);
}
private filterTree(term: string): void {
// Normalize search term
term = term.toLowerCase();
this.markInstance.unmark();
BrowserSession.set(this.termSessionStorageKey, term);
if (term.length < 3) {
return;
}
const matchingCollapsibleIds = new Set();
const matchingNodes = [...this.findNodesByIdentifier(term), ...this.findNodesByValue(term)];
matchingNodes.forEach((match: Element|null): void => {
if (match === null) {
return;
}
const collapsibleIdentifier = (match.parentElement.querySelector('[data-bs-toggle="collapse"]') as HTMLElement|null)?.dataset.bsTarget;
if (collapsibleIdentifier !== undefined) {
matchingCollapsibleIds.add(collapsibleIdentifier.substring(1));
}
const parentElements = this.parents(match, '.collapse');
for (let parentEl of parentElements) {
matchingCollapsibleIds.add(parentEl.id);
}
});
const allNodes = Array.from(this.pageTsTreeContainer.querySelectorAll('.collapse')) as HTMLElement[];
for (let node of allNodes) {
const collapsible = BootstrapCollapse.getOrCreateInstance(node, { toggle: false });
if (matchingCollapsibleIds.has(node.id)) {
collapsible.show();
} else {
collapsible.hide();
}
}
this.markInstance.mark(term, {
element: 'strong',
className: 'text-danger'
});
}
private findNodesByIdentifier(term: string): Element[] {
const matches = [];
// Search for nodes identifiers matching the term
const exactMatches = this.pageTsTreeContainer.querySelectorAll('[data-pagets-identifier="' + term + '"]');
matches.push(...exactMatches);
if (exactMatches.length === 0) {
const nearestMatches = Array.from(this.pageTsTreeContainer.querySelectorAll('[data-pagets-identifier*="' + term + '"]')).filter((element: HTMLElement) => {
// Search the nearest node available (e.g. "mod.wiz" resolves to "mod.wizards", but no "mod.wizards.newContentElement")
const substrStart = element.dataset.pagetsIdentifier.indexOf(term) + term.length;
return !element.dataset.pagetsIdentifier.substring(substrStart).includes('.');
});
matches.push(...nearestMatches);
}
return matches;
}
private findNodesByValue(term: string): Element[] {
const matchingValueNodes = Array.from(this.pageTsTreeContainer.querySelectorAll('.list-tree-value')).filter((el: Element): boolean => {
return el.textContent.toLowerCase().includes(term);
});
return matchingValueNodes.map((node: Element): Element => {
return node.previousElementSibling;
});
}
private parents(el: Element, selector: string) {
const parents = [];
let closest;
while ((closest = el.parentElement.closest(selector)) !== null) {
el = closest;
parents.push(closest);
}
return parents;
}
}
export default new PageTSconfigBrowser();
......@@ -13,6 +13,7 @@
import {Collapse as BootstrapCollapse} from 'bootstrap';
import Client from '@typo3/backend/storage/client';
import BrowserSession from '@typo3/backend/storage/browser-session';
import DocumentService from '@typo3/core/document-service';
import RegularEvent from '@typo3/core/event/regular-event';
......@@ -48,8 +49,8 @@ export class CollapseStatePersister {
const currentStates = this.fromStorage();
for (const [identifier, isExpanded] of Object.entries(currentStates)) {
const element = document.getElementById(identifier);
if (element === null) {
const element = document.getElementById(identifier) as HTMLElement;
if (element === null || !this.shallRecoverState(element)) {
continue;
}
const collapsible = BootstrapCollapse.getOrCreateInstance(element, {
......@@ -64,6 +65,17 @@ export class CollapseStatePersister {
}
}
private shallRecoverState(element: HTMLElement): boolean {
if (element.dataset.persistCollapseStateNotIf === undefined) {
return true;
}
const storageKey = element.dataset.persistCollapseStateNotIf;
const storageValue = Client.get(storageKey) ?? BrowserSession.get(storageKey);
return storageValue === null || storageValue === '';
}
private fromStorage(): { [key: string]: boolean } {
const currentStates = Client.get(this.localStorageKey);
if (currentStates === null) {
......
......@@ -80,6 +80,7 @@
"lit-element": "^3.0.0",
"lit-html": "^2.0.0",
"luxon": "^3.1.0",
"mark.js": "^8.11.1",
"muuri": "^0.9.3",
"nprogress": "^0.2.0",
"requirejs": "^2.3.6",
......@@ -8043,6 +8044,11 @@
"node": ">=0.10.0"
}
},
"node_modules/mark.js": {
"version": "8.11.1",
"resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
"integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="
},
"node_modules/mathml-tag-names": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
......
......@@ -178,6 +178,7 @@
"lit-element": "^3.0.0",
"lit-html": "^2.0.0",
"luxon": "^3.1.0",
"mark.js": "^8.11.1",
"muuri": "^0.9.3",
"nprogress": "^0.2.0",
"requirejs": "^2.3.6",
......
......@@ -92,6 +92,7 @@ declare module '@typo3/backend/legacy-tree';
declare module '@typo3/install/chosen.jquery.min';
declare module '@typo3/backend/link-browser';
declare module '@typo3/dashboard/contrib/chartjs';
declare module '@typo3/backend/contrib/mark';
declare module '@typo3/t3editor/stream-parser/typoscript';
declare module '@typo3/t3editor/autocomplete/ts-code-completion';
......
......@@ -20,22 +20,13 @@ namespace TYPO3\CMS\Backend\Controller\PageTsConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\Controller;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* PageTsConfig > Active Page TsConfig
......@@ -45,17 +36,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
#[Controller]
class PageTsConfigActiveController
{
private ModuleInterface $currentModule;
private ?ModuleTemplate $view;
public array $pageinfo = [];
/**
* Value of the GET/POST var 'id' = the current page ID
*/
private int $id;
public function __construct(
private readonly IconFactory $iconFactory,
private readonly UriBuilder $uriBuilder,
private readonly ModuleTemplateFactory $moduleTemplateFactory,
) {
......@@ -63,107 +44,60 @@ class PageTsConfigActiveController
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->id = (int)($request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? 0);
if ($this->id === 0) {
return new RedirectResponse($this->uriBuilder->buildUriFromRoute('pagetsconfig_records'));
}
$this->view = $this->moduleTemplateFactory->create($request);
$this->currentModule = $request->getAttribute('module');
$this->pageinfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$this->view->setTitle(
$this->getLanguageService()->sL($this->currentModule->getTitle()),
isset($this->pageinfo['title']) ? $this->pageinfo['title'] : ''
);
$languageService = $this->getLanguageService();
// The page will show only if there is a valid page and if this page may be viewed by the user
if ($this->pageinfo !== []) {
$this->view->getDocHeaderComponent()->setMetaInformation($this->pageinfo);
}
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$currentModule = $request->getAttribute('module');
$currentModuleIdentifier = $currentModule->getIdentifier();
$moduleData = $request->getAttribute('moduleData');
$accessContent = false;
$backendUser = $this->getBackendUser();
if (($this->pageinfo !== []) || $backendUser->isAdmin()) {
$accessContent = true;
$this->view->assign('id', $this->id);
$this->view->assign('formAction', (string)$this->uriBuilder->buildUriFromRoute($this->currentModule->getIdentifier()));
// Setting up the buttons and the module menu for the doc header
$this->getButtons();
$this->view->makeDocHeaderModuleMenu(['id' => $this->id]);
$pageUid = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
if ($pageUid <= 0) {
// Redirect to records overview if on page 0 or invalid uid.
return new RedirectResponse($this->uriBuilder->buildUriFromRoute('pagetsconfig_records'));
}
$pageRecord = BackendUtility::readPageAccess($pageUid, '1=1') ?: [];
$moduleData = $request->getAttribute('moduleData');
$alphaSortStatus = $moduleData->get('tsconf_alphaSort');
$pageTsConfig = BackendUtility::getPagesTSconfig($this->id);
if ($alphaSortStatus) {
$alphaSort = (bool)$moduleData->get('alphaSort');
$pageTsConfig = BackendUtility::getPagesTSconfig($pageUid);
if ($alphaSort) {
$pageTsConfig = ArrayUtility::sortByKeyRecursive($pageTsConfig);
}
$this->view->assignMultiple([
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle($languageService->sL($currentModule->getTitle()), $pageRecord['title']);
$view->getDocHeaderComponent()->setMetaInformation($pageRecord);
$this->addShortcutButtonToDocHeader($view, $currentModuleIdentifier, $pageRecord, $pageUid);
$view->makeDocHeaderModuleMenu(['id' => $pageUid]);
$view->assignMultiple([
'pageUid' => $pageUid,
'alphaSort' => $alphaSort,
'pageTsConfig' => $pageTsConfig,
'displayAlphaSort' => true,
'alphaSortChecked' => (bool)$alphaSortStatus === true ? 'checked="checked"' : '',
'alphaSortUrl' => $this->uriBuilder->buildUriFromRoute($request->getAttribute('route')->getOption('_identifier'), ['id' => $this->id]) . '&tsconf_alphaSort=${value}',
'pageUid' => $this->id,
'accessContent' => $accessContent,
]);
return $this->view->renderResponse('PageTsConfig/Active');
return $view->renderResponse('PageTsConfig/Active');
}
/**
* Create the panel of buttons for submitting the form or otherwise perform operations.
*/
private function getButtons(): void
private function addShortcutButtonToDocHeader(ModuleTemplate $view, string $moduleIdentifier, array $pageInfo, int $pageUid): void
{
$languageService = $this->getLanguageService();
$buttonBar = $this->view->getDocHeaderComponent()->getButtonBar();
if ($this->id) {
// View
$pagesTSconfig = BackendUtility::getPagesTSconfig($this->pageinfo['uid']);
if (isset($pagesTSconfig['TCEMAIN.']['preview.']['disableButtonForDokType'])) {
$excludeDokTypes = GeneralUtility::intExplode(
',',
(string)$pagesTSconfig['TCEMAIN.']['preview.']['disableButtonForDokType'],
true
);
} else {
// exclude sysfolders and recycler by default
$excludeDokTypes = [
PageRepository::DOKTYPE_RECYCLER,
PageRepository::DOKTYPE_SYSFOLDER,
PageRepository::DOKTYPE_SPACER,
];
}
if (!in_array((int)$this->pageinfo['doktype'], $excludeDokTypes, true)) {
// View page
$previewDataAttributes = PreviewUriBuilder::create((int)$this->pageinfo['uid'])
->withRootLine(BackendUtility::BEgetRootLine($this->pageinfo['uid']))
->buildDispatcherDataAttributes();
$viewButton = $buttonBar->makeLinkButton()
->setHref('#')
->setDataAttributes($previewDataAttributes ?? [])
->setShowLabelText(true)
->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage'))
->setIcon($this->iconFactory->getIcon('actions-view-page', Icon::SIZE_SMALL));
$buttonBar->addButton($viewButton, ButtonBar::BUTTON_POSITION_LEFT);
}
}
// Shortcut
$buttonBar = $view->getDocHeaderComponent()->getButtonBar();
$shortcutTitle = sprintf(
'%s: %s [%d]',
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_pagetsconfig.xlf:module.pagetsconfig_active'),
BackendUtility::getRecordTitle('pages', $pageInfo),
$pageUid
);
$shortcutButton = $buttonBar->makeShortcutButton()
->setRouteIdentifier($this->currentModule->getIdentifier())
->setDisplayName($this->currentModule->getTitle())
->setArguments(['id' => $this->id]);
$buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);
->setRouteIdentifier($moduleIdentifier)
->setDisplayName($shortcutTitle)
->setArguments(['id' => $pageUid]);
$buttonBar->addButton($shortcutButton);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
......@@ -97,7 +97,6 @@ class PageTsConfigIncludesController
$this->pageinfo = ['title' => '[root-level]', 'uid' => 0, 'pid' => 0];
}
$this->view->assign('id', $this->id);
$this->view->assign('formAction', (string)$this->uriBuilder->buildUriFromRoute($this->currentModule->getIdentifier()));
// Setting up the buttons and the module menu for the doc header
$this->getButtons();
$this->view->makeDocHeaderModuleMenu(['id' => $this->id]);
......
......@@ -86,7 +86,6 @@ class PageTsConfigRecordsOverviewController
$pageRecord = ['title' => '[root-level]', 'uid' => 0, 'pid' => 0];
}
$view->assign('id', $pageId);
$view->assign('formAction', (string)$this->uriBuilder->buildUriFromRoute($currentModule->getIdentifier()));
// Setting up the buttons and the module menu for the doc header
$this->getButtons($view, $currentModule, $pageId, $pageRecord);
}
......
......@@ -86,7 +86,7 @@ return [
],
'pagetsconfig' => [
'parent' => 'site',
'access' => 'user',
'access' => 'admin',
'path' => '/module/pagetsconfig',
'iconIdentifier' => 'module-tstemplate',
'labels' => [
......@@ -98,8 +98,9 @@ return [
],
'pagetsconfig_records' => [
'parent' => 'pagetsconfig',
'access' => 'user',
'access' => 'admin',
'path' => '/module/pagetsconfig/records',
'iconIdentifier' => 'module-tstemplate',
'labels' => [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_pagetsconfig.xlf:module.pagetsconfig_records',
],
......@@ -111,8 +112,9 @@ return [
],
'pagetsconfig_active' => [
'parent' => 'pagetsconfig',
'access' => 'user',
'access' => 'admin',
'path' => '/module/pagetsconfig/active',
'iconIdentifier' => 'module-tstemplate',
'labels' => [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_pagetsconfig.xlf:module.pagetsconfig_active',
],
......@@ -122,13 +124,14 @@ return [
],
],
'moduleData' => [
'tsconf_alphaSort' => false,
'alphaSort' => false,
],
],
'pagetsconfig_includes' => [
'parent' => 'pagetsconfig',
'access' => 'user',
'access' => 'admin',
'path' => '/module/pagetsconfig/includes',
'iconIdentifier' => 'module-tstemplate',
'labels' => [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_pagetsconfig.xlf:module.pagetsconfig_includes',
],
......
......@@ -10,6 +10,12 @@ return [
'backend.navigation-component',
],
'imports' => [
'@typo3/backend/' => 'EXT:backend/Resources/Public/JavaScript/',
'@typo3/backend/' => [
'path' => 'EXT:backend/Resources/Public/JavaScript/',
'exclude' => [
'EXT:backend/Resources/Public/JavaScript/Contrib/',
],
],
'@typo3/backend/contrib/mark.js' => 'EXT:backend/Resources/Public/JavaScript/Contrib/mark.js',
],
];
......@@ -15,7 +15,8 @@
<f:then>
<f:if condition="{listIdentifier}">
<f:then>
<ul class="list-tree text-monospace collapse" data-persist-collapse-state="true" id="collapse-list-{listIdentifier}">
<f:variable name="hashedListIdentifier" value="{backend:hash(value: listIdentifier)}" />
<ul class="list-tree text-monospace collapse" data-persist-collapse-state="true" data-persist-collapse-state-not-if="pagets-search-term" id="collapse-list-{hashedListIdentifier}">
<f:render
section="listItem"
arguments="{
......@@ -40,30 +41,32 @@
<f:section name="listItem">
<f:for each="{pageTsConfig}" as="configItem" key="configLabel" iteration="configIterator">
<f:variable name="normalizedConfigLabel" value="{f:format.trim(characters: '.', side: 'both', value: '{configLabel}')}" />
<f:variable name="pageTsIdentifier" value="{f:format.trim(characters: '.', side: 'both', value: '{parentIdentifier}.{normalizedConfigLabel}') -> f:format.case(mode: 'lower')}" />
<li>
<f:if condition="{backend:isArray(value: configItem)}">
<f:then>
<f:comment><!-- Array --></f:comment>
<f:if condition="{parentIdentifier}">
<f:then>
<f:variable name="listIdentifier" value="{parentIdentifier}-{configLabel}" />
<f:variable name="listIdentifier" value="{parentIdentifier}.{normalizedConfigLabel}" />
</f:then>
<f:else>
<f:variable name="listIdentifier" value="{configLabel}" />
<f:variable name="listIdentifier" value="{normalizedConfigLabel}" />
</f:else>
</f:if>
<f:variable name="listIdentifier" value="{backend:hash(value: listIdentifier)}" />
<f:variable name="hashedListIdentifier" value="{backend:hash(value: listIdentifier)}" />
<span class="list-tree-group">
<a
class="list-tree-control collapsed"
data-bs-toggle="collapse"
data-bs-target="#collapse-list-{listIdentifier}"
data-bs-target="#collapse-list-{hashedListIdentifier}"
aria-expanded="false"
>
<typo3-backend-icon identifier="actions-caret-right"></typo3-backend-icon>
<typo3-backend-icon identifier="actions-caret-down"></typo3-backend-icon>