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