[TASK] Allow CSV Export per table in list module
authorBenni Mack <benni@typo3.org>
Thu, 17 Jun 2021 14:44:39 +0000 (16:44 +0200)
committerChristian Kuhn <lolli@schwarzbu.ch>
Fri, 18 Jun 2021 16:28:41 +0000 (18:28 +0200)
This change moves the CSV Export in the header of each
table within the record list.

This contains multiple UX improvements:

* CSV Exports are now possible in each list view, not just
  in single table view
* CSV Export Buttons are visually connected to the actual
  records and the output
* The CSV Button now has a proper label next to the icon
  instead of just an icon in the docheader

In addition, the RecordListController is thinned out as all
logic is separated and moved into a new RecordExportController.

Resolves: #94366
Releases: master
Change-Id: Ia64b513636799368a39b5028b6cad7ebac6fe835
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69512
Tested-by: Oliver Bartsch <bo@cedev.de>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Jochen <rothjochen@gmail.com>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
Reviewed-by: Jochen <rothjochen@gmail.com>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
typo3/sysext/recordlist/Classes/Controller/AccessDeniedException.php [new file with mode: 0644]
typo3/sysext/recordlist/Classes/Controller/InvalidTableException.php [new file with mode: 0644]
typo3/sysext/recordlist/Classes/Controller/RecordExportController.php [new file with mode: 0644]
typo3/sysext/recordlist/Classes/Controller/RecordListController.php
typo3/sysext/recordlist/Classes/RecordList/DatabaseRecordList.php
typo3/sysext/recordlist/Configuration/Backend/Routes.php
typo3/sysext/recordlist/Configuration/Services.yaml
typo3/sysext/recordlist/Resources/Private/Language/locallang_export.xlf [new file with mode: 0644]

diff --git a/typo3/sysext/recordlist/Classes/Controller/AccessDeniedException.php b/typo3/sysext/recordlist/Classes/Controller/AccessDeniedException.php
new file mode 100644 (file)
index 0000000..f00a192
--- /dev/null
@@ -0,0 +1,27 @@
+<?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\Recordlist\Controller;
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * This exception is thrown if a user is requesting something they should not do
+ */
+class AccessDeniedException extends Exception
+{
+}
diff --git a/typo3/sysext/recordlist/Classes/Controller/InvalidTableException.php b/typo3/sysext/recordlist/Classes/Controller/InvalidTableException.php
new file mode 100644 (file)
index 0000000..13a2bf1
--- /dev/null
@@ -0,0 +1,27 @@
+<?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\Recordlist\Controller;
+
+use TYPO3\CMS\Core\Exception;
+
+/**
+ * This exception is thrown if no table is given
+ */
+class InvalidTableException extends Exception
+{
+}
diff --git a/typo3/sysext/recordlist/Classes/Controller/RecordExportController.php b/typo3/sysext/recordlist/Classes/Controller/RecordExportController.php
new file mode 100644 (file)
index 0000000..35bbbaf
--- /dev/null
@@ -0,0 +1,137 @@
+<?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\Recordlist\Controller;
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Type\Bitmask\Permission;
+use TYPO3\CMS\Core\Utility\CsvUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Recordlist\RecordList\CsvExportRecordList;
+use TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList;
+
+/**
+ * Controller for handling exports of records, typically executed from the list module.
+ *
+ * @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
+ */
+class RecordExportController
+{
+    protected int $id = 0;
+    protected array $modTSconfig = [];
+    protected ResponseFactoryInterface $responseFactory;
+
+    public function __construct(ResponseFactoryInterface $responseFactory)
+    {
+        $this->responseFactory = $responseFactory;
+    }
+
+    /**
+     * Handle record export request
+     *
+     * @param ServerRequestInterface $request the current request
+     * @return ResponseInterface the response with the content
+     */
+    public function handleRequest(ServerRequestInterface $request): ResponseInterface
+    {
+        $queryParams = $request->getQueryParams();
+        $backendUser = $this->getBackendUserAuthentication();
+
+        $table = (string)($queryParams['table'] ?? '');
+        if ($table === '') {
+            throw new InvalidTableException('No table was given for exporting records', 1623941276);
+        }
+
+        $this->id = (int)($queryParams['id'] ?? 0);
+        $search_field = (string)($queryParams['search_field'] ?? '');
+        $search_levels = (int)($queryParams['search_levels'] ?? 0);
+
+        // Loading module configuration
+        $this->modTSconfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['web_list.'] ?? [];
+
+        // Loading current page record and checking access
+        $perms_clause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
+        $pageinfo = BackendUtility::readPageAccess($this->id, $perms_clause);
+
+        $hasAccess = is_array($pageinfo) || ($this->id === 0 && $search_levels !== 0 && $search_field !== '');
+        if ($hasAccess === false) {
+            throw new AccessDeniedException('Insufficient permissions for accessing this export', 1623941361);
+        }
+
+        $recordList = GeneralUtility::makeInstance(DatabaseRecordList::class);
+        $recordList->modTSconfig = $this->modTSconfig;
+        $recordList->setLanguagesAllowedForUser($this->getSiteLanguages($request));
+        $recordList->start($this->id, $table, 0, $search_field, $search_levels);
+
+        // Currently only CSV is supported for export. As soon as Core adds additional
+        // formats, this should be changed to e.g. a switch case on the requested $format
+        return $this->csvExportAction($recordList, $table);
+    }
+
+    protected function getSiteLanguages(ServerRequestInterface $request): array
+    {
+        $site = $request->getAttribute('site');
+        return $site->getAvailableLanguages($this->getBackendUserAuthentication(), false, $this->id);
+    }
+
+    protected function csvExportAction(DatabaseRecordList $recordList, string $table): ResponseInterface
+    {
+        $user = $this->getBackendUserAuthentication();
+        $csvExporter = GeneralUtility::makeInstance(
+            CsvExportRecordList::class,
+            $recordList,
+            GeneralUtility::makeInstance(TranslationConfigurationProvider::class)
+        );
+        // Ensure the fields chosen by the backend editor are selected / displayed
+        $recordList->setFields = $user->getModuleData('list/displayFields');
+        $columnsToRender = $recordList->getColumnsToRender($table, false);
+        $headerRow = $csvExporter->getHeaderRow($columnsToRender);
+        $hideTranslations = ($this->modTSconfig['hideTranslations'] ?? '') === '*' || GeneralUtility::inList($this->modTSconfig['hideTranslations'] ?? '', $table);
+        $records = $csvExporter->getRecords($table, $this->id, $columnsToRender, $user, $hideTranslations);
+        return $this->csvResponse(
+            $table . '_' . date('dmy-Hi') . '.csv',
+            $records,
+            $headerRow
+        );
+    }
+
+    protected function csvResponse($fileName, array $data, array $headerRow = []): ResponseInterface
+    {
+        $response = $this->responseFactory->createResponse()
+            ->withHeader('Content-Type', 'application/octet-stream')
+            ->withHeader('Content-Disposition', 'attachment; filename=' . $fileName);
+
+        $csvDelimiter = $this->modTSconfig['csvDelimiter'] ?? ',';
+        $csvQuote = $this->modTSconfig['csvQuote'] ?? '"';
+        $result[] = CsvUtility::csvValues($headerRow, $csvDelimiter, $csvQuote);
+        foreach ($data as $csvRow) {
+            $result[] = CsvUtility::csvValues($csvRow, $csvDelimiter, $csvQuote);
+        }
+        $response->getBody()->write(implode(CRLF, $result));
+        return $response;
+    }
+
+    protected function getBackendUserAuthentication(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+}
index 643160b..bac49b4 100644 (file)
@@ -20,7 +20,6 @@ use Psr\Http\Message\ResponseFactoryInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use TYPO3\CMS\Backend\Clipboard\Clipboard;
-use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
 use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
 use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Template\Components\ButtonBar;
@@ -42,11 +41,9 @@ use TYPO3\CMS\Core\Page\PageRenderer;
 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Type\Bitmask\Permission;
 use TYPO3\CMS\Core\TypoScript\TypoScriptService;
-use TYPO3\CMS\Core\Utility\CsvUtility;
 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent;
-use TYPO3\CMS\Recordlist\RecordList\CsvExportRecordList;
 use TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList;
 use TYPO3\CMS\Recordlist\View\RecordSearchBoxComponent;
 
@@ -137,7 +134,6 @@ class RecordListController
         // Clean up settings:
         $MOD_SETTINGS = BackendUtility::getModuleData(['bigControlPanel' => '', 'clipBoard' => ''], (array)($parsedBody['SET'] ?? $queryParams['SET'] ?? []), 'web_list');
         // main
-        $backendUser = $this->getBackendUserAuthentication();
         $lang = $this->getLanguageService();
         // Loading current page record and checking access:
         $pageinfo = BackendUtility::readPageAccess($this->id, $perms_clause);
@@ -172,7 +168,6 @@ class RecordListController
             $MOD_SETTINGS['clipBoard'] = true;
         }
         $clipboard = $this->initializeClipboard($request, (bool)$MOD_SETTINGS['clipBoard']);
-        $csvExport = (bool)($request->getQueryParams()['csv'] ?? false);
         $enableListing = $access || ($this->id === 0 && $search_levels !== 0 && $search_field !== '');
 
         // Initialize the dblist object:
@@ -198,12 +193,6 @@ class RecordListController
             $dblist->setTableDisplayOrder($typoScriptService->convertTypoScriptArrayToPlainArray($this->modTSconfig['tableDisplayOrder.']));
         }
 
-        // Return early if CSV Export is requested
-        if ($enableListing && $csvExport) {
-            $dblist->start($this->id, $table, 0, $search_field, $search_levels);
-            return $this->csvExportAction($dblist, $table);
-        }
-
         $dblist->clipObj = $clipboard;
         // This flag will prevent the clipboard panel in being shown.
         // It is set, if the clickmenu-layer is active AND the extended view is not enabled.
@@ -548,13 +537,6 @@ class RecordListController
                 || (isset($this->modTSconfig['noExportRecordsLinks'])
                     && !$this->modTSconfig['noExportRecordsLinks']))
         ) {
-            // CSV
-            $csvButton = $buttonBar->makeLinkButton()
-                ->setHref($listUrl . '&csv=1')
-                ->setTitle($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.csv'))
-                ->setIcon($this->iconFactory->getIcon('actions-document-export-csv', Icon::SIZE_SMALL))
-                ->setShowLabelText(true);
-            $buttonBar->addButton($csvButton, ButtonBar::BUTTON_POSITION_LEFT, 40);
             // Export
             if (ExtensionManagementUtility::isLoaded('impexp')) {
                 $url = (string)$this->uriBuilder->buildUriFromRoute('tx_impexp_export');
@@ -733,27 +715,6 @@ class RecordListController
         ));
     }
 
-    protected function csvExportAction(DatabaseRecordList $recordList, string $table): ResponseInterface
-    {
-        $user = $this->getBackendUserAuthentication();
-        $csvExporter = GeneralUtility::makeInstance(
-            CsvExportRecordList::class,
-            $recordList,
-            GeneralUtility::makeInstance(TranslationConfigurationProvider::class)
-        );
-        // Ensure the fields chosen by the backend editor are selected / displayed
-        $recordList->setFields = $user->getModuleData('list/displayFields');
-        $columnsToRender = $recordList->getColumnsToRender($table, false);
-        $headerRow = $csvExporter->getHeaderRow($columnsToRender);
-        $hideTranslations = ($this->modTSconfig['hideTranslations'] ?? '') === '*' || GeneralUtility::inList($this->modTSconfig['hideTranslations'] ?? '', $table);
-        $records = $csvExporter->getRecords($table, $this->id, $columnsToRender, $user, $hideTranslations);
-        return $this->csvResponse(
-            $table . '_' . date('dmy-Hi') . '.csv',
-            $records,
-            $headerRow
-        );
-    }
-
     protected function htmlResponse(string $html): ResponseInterface
     {
         $response = $this->responseFactory->createResponse()
@@ -762,22 +723,6 @@ class RecordListController
         return $response;
     }
 
-    protected function csvResponse($fileName, array $data, array $headerRow = []): ResponseInterface
-    {
-        $response = $this->responseFactory->createResponse()
-            ->withHeader('Content-Type', 'application/octet-stream')
-            ->withHeader('Content-Disposition', 'attachment; filename=' . $fileName);
-
-        $csvDelimiter = $this->modTSconfig['csvDelimiter'] ?? ',';
-        $csvQuote = $this->modTSconfig['csvQuote'] ?? '"';
-        $result[] = CsvUtility::csvValues($headerRow, $csvDelimiter, $csvQuote);
-        foreach ($data as $csvRow) {
-            $result[] = CsvUtility::csvValues($csvRow, $csvDelimiter, $csvQuote);
-        }
-        $response->getBody()->write(implode(CRLF, $result));
-        return $response;
-    }
-
     /**
      * @return BackendUserAuthentication
      */
index 0d4003a..d239742 100644 (file)
@@ -648,6 +648,8 @@ class DatabaseRecordList
             }
             // Show the select box
             $tableHeader .= $this->columnSelector($table);
+            // Create the CSV Export button
+            $tableHeader .= $this->createExportButtonForTable($table, $totalItems);
         }
         // Render table rows only if in multi table view or if in single table view
         $rowOutput = '';
@@ -770,6 +772,26 @@ class DatabaseRecordList
         ';
     }
 
+    protected function createExportButtonForTable(string $table, int $totalItems): string
+    {
+        // Do not render the export button for page translations or in case export is disabled
+        if (($this->modTSconfig['noExportRecordsLinks'] ?? false) || $this->showOnlyTranslatedRecords) {
+            return '';
+        }
+
+        $downloadCsvFileLabel = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.csv');
+        $exportButtonLabel = $this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_export.xlf:csvExport');
+        $downloadCsvLabel = sprintf($this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang_export.xlf:' . ($totalItems === 1 ? 'exportRecord' : 'exportRecords')), $totalItems);
+        $exportUrl = $this->uriBuilder->buildUriFromRoute('record_export', ['id' => $this->id, 'table' => $table, 'search_field' => $this->searchString, 'search_levels' => $this->searchLevels]);
+
+        return '<div class="pull-right"><a href="' . htmlspecialchars($exportUrl) . '"'
+            . ' class="btn btn-default btn-sm me-2"'
+            . ' title="' . htmlspecialchars($downloadCsvLabel) . '"'
+            . ' aria-label="' . htmlspecialchars($downloadCsvFileLabel) . '"'
+            . ' download>'
+            . $this->iconFactory->getIcon('actions-document-export-csv', Icon::SIZE_SMALL) . ' ' . htmlspecialchars($exportButtonLabel) . '</a></div>';
+    }
+
     /**
      * Get preview link for pages or tt_content records
      *
index e9fbcd4..d1da7ff 100644 (file)
@@ -8,4 +8,8 @@ return [
         'path' => '/wizard/record/browse',
         'target' => \TYPO3\CMS\Recordlist\Controller\ElementBrowserController::class . '::mainAction'
     ],
+    'record_export' => [
+        'path' => '/records/export',
+        'target' => \TYPO3\CMS\Recordlist\Controller\RecordExportController::class . '::handleRequest'
+    ],
 ];
index 1a469ea..208a16a 100644 (file)
@@ -10,6 +10,9 @@ services:
   TYPO3\CMS\Recordlist\Controller\RecordListController:
     tags: ['backend.controller']
 
+  TYPO3\CMS\Recordlist\Controller\RecordExportController:
+    tags: ['backend.controller']
+
   TYPO3\CMS\Recordlist\Controller\ElementBrowserController:
     tags: ['backend.controller']
 
diff --git a/typo3/sysext/recordlist/Resources/Private/Language/locallang_export.xlf b/typo3/sysext/recordlist/Resources/Private/Language/locallang_export.xlf
new file mode 100644 (file)
index 0000000..13a4474
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
+       <file source-language="en" datatype="plaintext" original="EXT:recordlist/Resources/Private/Language/locallang_export.xlf" date="2012-06-17T10:22:33Z" product-name="recordlist">
+               <header/>
+               <body>
+                       <trans-unit id="exportRecord" resname="exportRecord">
+                               <source>Export %d record</source>
+                       </trans-unit>
+                       <trans-unit id="exportRecords" resname="exportRecords">
+                               <source>Export %d records</source>
+                       </trans-unit>
+                       <trans-unit id="csvExport" resname="csvExport">
+                               <source>CSV Export</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>