From 945a8385c732aca9fc9a70c1f6622725a4549752 Mon Sep 17 00:00:00 2001 From: ullio <ulrich.mathes@gmail.com> Date: Tue, 10 Sep 2024 18:49:09 +0200 Subject: [PATCH] [FEATURE] Introduce LatestChangedPages dashboard widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch adds a new widget for EXT:dashboard, which shows a list of the latest changed pages inside the TYPO3 system. Resolves: #104878 Releases: main Change-Id: I33f86e1cc8d4a546136bb179f19f6338e2e734f3 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/85987 Reviewed-by: Georg Ringer <georg.ringer@gmail.com> Reviewed-by: Frank Nägler <frank.naegler@typo3.com> Tested-by: core-ci <typo3@b13.com> Tested-by: Georg Ringer <georg.ringer@gmail.com> Tested-by: Frank Nägler <frank.naegler@typo3.com> --- ...shboardWidgetForPagesWithLatestChanges.rst | 24 +++ .../Widgets/LatestChangedPagesWidget.php | 197 ++++++++++++++++++ .../Backend/DashboardWidgetGroups.php | 3 + .../dashboard/Configuration/Services.yaml | 15 ++ .../Resources/Private/Language/locallang.xlf | 46 ++++ .../Widget/LatestChangedPagesWidget.html | 116 +++++++++++ 6 files changed, 401 insertions(+) create mode 100644 typo3/sysext/core/Documentation/Changelog/13.3/Feature-104878-IntroduceDashboardWidgetForPagesWithLatestChanges.rst create mode 100644 typo3/sysext/dashboard/Classes/Widgets/LatestChangedPagesWidget.php create mode 100644 typo3/sysext/dashboard/Resources/Private/Templates/Widget/LatestChangedPagesWidget.html diff --git a/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104878-IntroduceDashboardWidgetForPagesWithLatestChanges.rst b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104878-IntroduceDashboardWidgetForPagesWithLatestChanges.rst new file mode 100644 index 000000000000..b29cb271da03 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104878-IntroduceDashboardWidgetForPagesWithLatestChanges.rst @@ -0,0 +1,24 @@ +.. include:: /Includes.rst.txt + +.. _feature-104878-1725993353: + +=========================================================================== +Feature: #104878 - Introduce dashboard widget for pages with latest changes +=========================================================================== + +See :issue:`104878` + +Description +=========== + +To make it easier for TYPO3 users to view the latest changed pages in their +TYPO3 system, TYPO3 now offers a dashboard widget that lists the latest +changed pages. + +Impact +====== + +TYPO3 users who have access to the :guilabel:`Dashboard` module and are +granted access to the new widgets can now add and use this widget. + +.. index:: Backend, ext:dashboard \ No newline at end of file diff --git a/typo3/sysext/dashboard/Classes/Widgets/LatestChangedPagesWidget.php b/typo3/sysext/dashboard/Classes/Widgets/LatestChangedPagesWidget.php new file mode 100644 index 000000000000..367d9f1733ca --- /dev/null +++ b/typo3/sysext/dashboard/Classes/Widgets/LatestChangedPagesWidget.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +/* + * 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! + */ + +namespace TYPO3\CMS\Dashboard\Widgets; + +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Backend\Routing\PreviewUriBuilder; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Backend\View\BackendViewFactory; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction; +use TYPO3\CMS\Core\Type\Bitmask\Permission; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; + +/** + * This widget will show a list of pages where latest changes in pages and tt_content + * where made. The sys_history is used to get the latest changes. + * + * The list contains: + * - datetime of change + * - user (avatar, icon, name and realName) + * - page title and rootline + * - controls (show history, view webpage, edit page content, edit page properties) + * + * The following options are available during registration: + * - limit int number of pages to show in list + * - historyLimit int number of sys_history records to be fetched in order + * to find limit number of pages. Increase this value + * if number of pages in list is not achieved. + */ +class LatestChangedPagesWidget implements WidgetInterface, RequestAwareWidgetInterface +{ + /** + * @var array{limit: int, historyLimit: int} + */ + private readonly array $options; + private ServerRequestInterface $request; + + public function __construct( + private readonly BackendViewFactory $backendViewFactory, + private readonly ConnectionPool $connectionPool, + private readonly WidgetConfigurationInterface $configuration, + array $options = [], + ) { + $this->options = array_merge([ + 'limit' => 10, + 'historyLimit' => 1000, + ], $options); + } + + public function renderWidgetContent(): string + { + $sysHistoryEntries = $this->getSysHistoryEntries($this->options['historyLimit']); + $latestPages = $this->getLatestPagesFromSysHistory($sysHistoryEntries, $this->options['limit']); + $latestPages = $this->enrichPageInformation($latestPages); + + $view = $this->backendViewFactory->create($this->request); + $view->assignMultiple([ + 'latestPages' => $latestPages, + 'configuration' => $this->configuration, + 'dateFormat' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], + ]); + + return $view->render('Widget/LatestChangedPagesWidget'); + } + + private function getSysHistoryEntries(int $limit): array + { + $queryBuilder = $this->getQueryBuilderSysHistory(); + return $queryBuilder + ->select('tablename', 'recuid', 'tstamp', 'userid') + ->from('sys_history') + ->where($queryBuilder->expr()->in('tablename', [ + $queryBuilder->createNamedParameter('pages'), + $queryBuilder->createNamedParameter('tt_content'), + ])) + ->addOrderBy('tstamp', 'desc') + ->setMaxResults($limit) + ->executeQuery() + ->fetchAllAssociative(); + } + + private function getLatestPagesFromSysHistory(array $history, int $limit): array + { + $latestPages = []; + foreach ($history as $historyEntry) { + $pageId = $historyEntry['tablename'] == 'tt_content' ? $this->getPidOfContentElement($historyEntry['recuid']) : $historyEntry['recuid']; + if (!$pageId || isset($latestPages[$pageId])) { + continue; + } + + $pageRecord = BackendUtility::readPageAccess($pageId, $GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_SHOW)); + if (!$pageRecord) { + // Backend user has no access to show page information. Dismiss this page. + continue; + } + + $latestPages[$pageId]['history'] = $historyEntry; + $latestPages[$pageId]['pageRecord'] = $pageRecord; + + if (count($latestPages) >= $limit) { + break; + } + } + return $latestPages; + } + + private function enrichPageInformation(array $latestPages): array + { + $userNames = BackendUtility::getUserNames('username,realName,uid'); + + foreach ($latestPages as $pageId => &$page) { + $page['rootline'] = $this->getRootline($pageId); + + $page['viewLink'] = (string)PreviewUriBuilder::create($pageId) + ->withRootLine(BackendUtility::BEgetRootLine($pageId)) + ->buildUri(); + $page['userName'] = $userNames[$page['history']['userid']]['username'] ?? ''; + $page['realName'] = $userNames[$page['history']['userid']]['realName'] ?? ''; + } + + return $latestPages; + } + + private function getPidOfContentElement(int $uid): ?int + { + $queryBuilder = $this->getQueryBuilderForContentElements(); + $pid = $queryBuilder + ->select('pid') + ->from('tt_content') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid))) + ->executeQuery() + ->fetchOne(); + + return $pid ? (int)$pid : null; + } + + private function getRootLine(int $pageId): string + { + $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get(); + return implode(' / ', array_slice( + array_map( + function ($page) { + return $page['title']; + }, + array_reverse($rootLine) + ), + 0, + -1 + )); + } + + private function getQueryBuilderSysHistory(): QueryBuilder + { + $workspaceRestriction = GeneralUtility::makeInstance( + WorkspaceRestriction::class, + GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'id') + ); + $queryBuilder = $this->connectionPool->getConnectionForTable('sys_history')->createQueryBuilder(); + $queryBuilder->getRestrictions()->add($workspaceRestriction); + return $queryBuilder; + } + + private function getQueryBuilderForContentElements(): QueryBuilder + { + $queryBuilderTtContent = $this->connectionPool->getConnectionForTable('tt_content')->createQueryBuilder(); + $queryBuilderTtContent->getRestrictions()->removeAll(); + return $queryBuilderTtContent; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } +} diff --git a/typo3/sysext/dashboard/Configuration/Backend/DashboardWidgetGroups.php b/typo3/sysext/dashboard/Configuration/Backend/DashboardWidgetGroups.php index ef72f58696eb..eac6e3989988 100644 --- a/typo3/sysext/dashboard/Configuration/Backend/DashboardWidgetGroups.php +++ b/typo3/sysext/dashboard/Configuration/Backend/DashboardWidgetGroups.php @@ -18,4 +18,7 @@ return [ 'documentation' => [ 'title' => 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widget_group.documentation', ], + 'content' => [ + 'title' => 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widget_group.content', + ], ]; diff --git a/typo3/sysext/dashboard/Configuration/Services.yaml b/typo3/sysext/dashboard/Configuration/Services.yaml index 9d6085423f3b..7e4a7fa96b17 100644 --- a/typo3/sysext/dashboard/Configuration/Services.yaml +++ b/typo3/sysext/dashboard/Configuration/Services.yaml @@ -189,3 +189,18 @@ services: title: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.failedLogins.title' description: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.failedLogins.description' iconIdentifier: 'content-widget-number' + + dashboard.widget.latestChangedPages: + class: 'TYPO3\CMS\Dashboard\Widgets\LatestChangedPagesWidget' + arguments: + $options: + refreshAvailable: true + tags: + - name: dashboard.widget + identifier: 'latestChangedPages' + groupNames: 'content' + title: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.title' + description: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.description' + iconIdentifier: 'content-widget-list' + height: 'medium' + width: 'medium' diff --git a/typo3/sysext/dashboard/Resources/Private/Language/locallang.xlf b/typo3/sysext/dashboard/Resources/Private/Language/locallang.xlf index e8f6332ca5b8..12c0e5c5d100 100644 --- a/typo3/sysext/dashboard/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/dashboard/Resources/Private/Language/locallang.xlf @@ -199,6 +199,49 @@ <source>More TYPO3 security advisories</source> </trans-unit> + <trans-unit id="widgets.latestChangedPages.title" resname="widgets.latestChangedPages.title" xml:space="preserve"> + <source>Latest changed pages</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.description" resname="widgets.latestChangedPages.description" xml:space="preserve"> + <source>Show a list of pages where the latest changes where made</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.empty" resname="widgets.latestChangedPages.empty" xml:space="preserve"> + <source>No history found</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.datetime" resname="widgets.latestChangedPages.column.datetime" xml:space="preserve"> + <source>Datetime</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.user" resname="widgets.latestChangedPages.column.user" xml:space="preserve"> + <source>User</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.user.userNotFound" resname="widgets.latestChangedPages.column.user.userNotFound" xml:space="preserve"> + <source>Not Found</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.recordTitle" resname="widgets.latestChangedPages.column.recordTitle" xml:space="preserve"> + <source>Title</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.recordTitle.workspace" resname="widgets.latestChangedPages.column.recordTitle.workspace" xml:space="preserve"> + <source>Changes in current selected workspace</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.recordTitle.pagetree" resname="widgets.latestChangedPages.column.recordTitle.pagetree" xml:space="preserve"> + <source>Position in pagetree</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.control" resname="widgets.latestChangedPages.column.control" xml:space="preserve"> + <source>Control</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.control.recordHistory" resname="widgets.latestChangedPages.column.control.recordHistory" xml:space="preserve"> + <source>Show history</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.control.viewWebpage" resname="widgets.latestChangedPages.column.control.viewWebpage" xml:space="preserve"> + <source>View webpage</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.control.editPageContent" resname="widgets.latestChangedPages.column.control.editPageContent" xml:space="preserve"> + <source>Edit page content</source> + </trans-unit> + <trans-unit id="widgets.latestChangedPages.column.control.editPage" resname="widgets.latestChangedPages.column.control.editPage" xml:space="preserve"> + <source>Edit page properties</source> + </trans-unit> + <trans-unit id="widget_group.general" resname="widget_group.general" xml:space="preserve"> <source>General</source> </trans-unit> @@ -214,6 +257,9 @@ <trans-unit id="widget_group.system" resname="widget_group.system" xml:space="preserve"> <source>System Information</source> </trans-unit> + <trans-unit id="widget_group.content" resname="widget_group.content" xml:space="preserve"> + <source>Content</source> + </trans-unit> <trans-unit id="dashboard.empty.content.title" resname="dashboard.empty.content.title" xml:space="preserve"> <source>No widgets</source> diff --git a/typo3/sysext/dashboard/Resources/Private/Templates/Widget/LatestChangedPagesWidget.html b/typo3/sysext/dashboard/Resources/Private/Templates/Widget/LatestChangedPagesWidget.html new file mode 100644 index 000000000000..c38cf4731410 --- /dev/null +++ b/typo3/sysext/dashboard/Resources/Private/Templates/Widget/LatestChangedPagesWidget.html @@ -0,0 +1,116 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true"> +<f:layout name="Widget/Widget"/> +<f:section name="main"> + + <f:if condition="{latestPages}"> + <f:then> + <div class="widget-table-wrapper"> + <table class="widget-table table table-striped table-hover"> + <thead> + <tr> + <th class="col-time">{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.datetime')}</th> + <th colspan="2">{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.user')}</th> + <th colspan="2">{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.recordTitle')}</th> + <th class="col-control nowrap"><span class="visually-hidden">{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.control')}</span></th> + </tr> + </thead> + <tbody> + <f:for each="{latestPages}" as="page"> + <tr> + <td class="col-datetime"> + {page.pageRecord.tstamp -> f:format.date(format: dateFormat)} + </td> + <td class="col-avatar"> + <be:avatar backendUser="{page.history.userid}" size="32" showIcon="true" /> + </td> + <td class="col-username col-responsive" style="max-width: 140px;"> + <f:if condition="{page.realName}"> + <f:then> + {page.realName} + <f:if condition="{page.userName}"> + <div class="text-muted">({page.userName})</div> + </f:if> + </f:then> + <f:else> + {f:if(condition: page.userName, then: page.userName, else: '{f:translate(key: \'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.user.userNotFound\')}')} + </f:else> + </f:if> + </td> + <td class="col-icon col-nowrap"> + <span title="id={page.pageRecord.uid}"> + <core:iconForRecord table="pages" row="{page}" /> + </span> + <f:if condition="{page.pageRecord.t3ver_wsid}"> + <span title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.recordTitle.workspace')}"> + <core:icon identifier="apps-toolbar-menu-workspace" size="small" /> + </span> + </f:if> + </td> + <td class="col-title"> + {page.pageRecord.title} + <f:if condition="{page.rootline}"> + <div><small class="text-muted" title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.recordTitle.pagetree')}">{page.rootline}</small></div> + </f:if> + </td> + <td class="col-control nowrap"> + <div class="btn-group"> + <f:be.link + route="record_history" + parameters="{element: 'pages:{page.pageRecord.uid}'}" + class="btn btn-default btn-sm" + title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.control.recordHistory')}"> + <core:icon identifier="actions-history" /> + </f:be.link> + + <a href="{page.viewLink}" + class="btn btn-default btn-sm" + title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.control.viewWebpage')}" + target="_blank"> + <core:icon identifier="actions-view-page" /> + </a> + + <f:be.link + route="web_layout" + parameters="{id: page.pageRecord.uid}" + class="btn btn-default btn-sm" + title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.control.editPageContent')}"> + <core:icon identifier="actions-document-edit" /> + </f:be.link> + + <be:link.editRecord + uid="{page.pageRecord.uid}" + table="pages" + class="btn btn-default btn-sm" + title="{f:translate(key: 'LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.column.control.editPage')}" + returnUrl="{f:be.uri(route: 'dashboard')}"> + <core:icon identifier="actions-page-open" /> + </be:link.editRecord> + </div> + </td> + </tr> + </f:for> + </tbody> + </table> + </div> + </f:then> + <f:else> + <div class="callout callout-info"> + <div class="callout-icon"> + <span class="icon-emphasized"> + <core:icon identifier="actions-history" /> + </span> + </div> + <div class="callout-content"> + <div class="callout-title"> + <f:translate key="LLL:EXT:dashboard/Resources/Private/Language/locallang.xlf:widgets.latestChangedPages.empty" /> + </div> + </div> + </div> + </f:else> + </f:if> + +</f:section> +</html> -- GitLab