Commit 2110556d authored by Oliver Bartsch's avatar Oliver Bartsch Committed by Benni Mack
Browse files

[FEATURE] Make recordlist export configurable

This adds a new modal to the recordlist export functionality,
allowing users to configure export settings such as format,
filename, selected columns / all columns and raw values /
processed values. Also some options, depending on the
chosen format, are configurable (e.g. delimiter for CSV).

To make the format selection useful and to further improve
the exisiting export functionality, JSON is added as another
possible export format.

Resolves: #94411
Releases: master
Change-Id: I224a93b6bdd855018aedfdae1375b16339f9f583
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69550

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 0f20cf7c
/*
* 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 {html, TemplateResult, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators';
import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity';
import Severity = require('TYPO3/CMS/Backend/Severity');
import Modal = require('TYPO3/CMS/Backend/Modal');
import {lll} from 'TYPO3/CMS/Core/lit-helper';
enum Selectors {
formatSelector = '.t3js-record-export-format-selector',
formatOptions = '.t3js-record-export-format-option'
}
/**
* Module: TYPO3/CMS/Recordlist/RecordExportButton
*
* @example
* <typo3-recordlist-record-export-button url="/url/to/configuration/form" title="Export records" ok="Export" close="Cancel">
* <button>Export records/button>
* </typo3-recordlist-record-export-button>
*/
@customElement('typo3-recordlist-record-export-button')
class RecordExportButton extends LitElement {
@property({type: String}) url: string;
@property({type: String}) title: string;
@property({type: String}) ok: string;
@property({type: String}) close: string;
public constructor() {
super();
this.addEventListener('click', (e: Event): void => {
e.preventDefault();
this.showExportConfigurationModal();
});
}
protected render(): TemplateResult {
return html`<slot></slot>`;
}
private showExportConfigurationModal(): void {
if (!this.url) {
// Don't render modal in case no url is given
return;
}
Modal.advanced({
content: this.url,
title: this.title || 'Export record',
severity: SeverityEnum.notice,
size: Modal.sizes.small,
type: Modal.types.ajax,
buttons: [
{
text: this.close || lll('button.close') || 'Close',
active: true,
btnClass: 'btn-default',
name: 'cancel',
trigger: (): void => Modal.dismiss(),
},
{
text: this.ok || lll('button.ok') || 'Export',
btnClass: 'btn-' + Severity.getCssClass(SeverityEnum.info),
name: 'export',
trigger: (): void => {
const form: HTMLFormElement = Modal.currentModal[0].querySelector('form');
form && form.submit();
Modal.dismiss();
}
}
],
ajaxCallback: (): void => {
const formatSelect: HTMLSelectElement = Modal.currentModal[0].querySelector(Selectors.formatSelector);
const formatOptions: NodeListOf<HTMLDivElement> = Modal.currentModal[0].querySelectorAll(Selectors.formatOptions);
if (formatSelect === null || !formatOptions.length) {
// Return in case elements do not exist in the ajax loaded modal content
return;
}
formatSelect.addEventListener('change', (e: Event): void => {
const selectetFormat: string = (<HTMLSelectElement>e.target).value;
formatOptions.forEach((option: HTMLDivElement) => {
if (option.dataset.formatname !== selectetFormat) {
option.classList.add('hide');
} else {
option.classList.remove('hide');
}
});
});
}
});
}
}
.. include:: ../../Includes.txt
============================================
Feature: #94411 - Recordlist export settings
============================================
See :issue:`94411`
Description
===========
In :issue:`94366`, the record export functionality in the recordlist
module was improved. Since then, the export could be triggered via a
button in each tables' header and no longer just in the single table view.
The export however did still not allow to adjust any settings, such as
the definition of a custom filename. Furthermore, only `csv` was available
as possible export format.
Therefore, and to further improve the already existing record export
functionality, the export button in the tables' header does not longer
trigger the export directly, but opens a modal with various adjustable
export settings such as:
* Selection of columns to export: All columns or selected columns
* Selection of the record values format: Either raw database values or processed (resolved) values
* Definition of a custom filename
* Selection of the export format (e.g. `csv`)
Also export format specific options are available, e.g. selection of
the delimiter for `csv` exports.
In case your installation already defines related TSconfig options
(e.g. :typoscript:`mod.web_list.csvDelimiter`), they will be added
as default value to the configuration modal.
Besides introducing those settings, also `json` is now available as
an alternative export format, including a format specific option,
which allows to define additional meta information to be included in
the export.
Impact
======
It's now possible to configure the export of records in the
recordlist. Furthermore, the new format option `json` is available.
.. index:: Backend, ext:recordlist
<?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\Core\Tests\Acceptance\Backend\RecordList;
use TYPO3\CMS\Core\Tests\Acceptance\Support\BackendTester;
use TYPO3\CMS\Core\Tests\Acceptance\Support\Helper\ModalDialog;
use TYPO3\CMS\Core\Tests\Acceptance\Support\Helper\PageTree;
/**
* Cases concerning the record export functionality
*/
class RecordExportCest
{
/**
* @param BackendTester $I
*/
public function _before(BackendTester $I)
{
$I->useExistingSession('admin');
}
/**
* @param BackendTester $I
* @param PageTree $pageTree
* @param ModalDialog $modalDialog
*/
public function recordsCanBeExported(BackendTester $I, PageTree $pageTree, ModalDialog $modalDialog): void
{
$I->wantToTest('whether records can be exported in the recordlist');
$I->amGoingTo('export a record');
$I->click('List');
$I->waitForElementNotVisible('#nprogress');
$pageTree->openPath(['styleguide TCA demo']);
$I->wait(0.2);
$I->switchToContentFrame();
$I->canSee('Export');
$I->click('typo3-recordlist-record-export-button button');
$modalDialog->canSeeDialog();
$I->canSee('Export Page:', ModalDialog::$openedModalSelector . ' .modal-title');
$I->fillField(ModalDialog::$openedModalSelector . ' input[name="filename"]', 'test-export');
$I->canSee('CSV options', ModalDialog::$openedModalSelector . ' .modal-body h5');
$I->selectOption(ModalDialog::$openedModalSelector . ' select[name="format"]', 'json');
$I->dontSee('CSV options', ModalDialog::$openedModalSelector . ' .modal-body h5');
$I->see('JSON options', ModalDialog::$openedModalSelector . ' .modal-body h5');
$I->selectOption(ModalDialog::$openedModalSelector . ' select[name="json[meta]"]', 'full');
$I->click('button[name="export"]', ModalDialog::$openedModalButtonContainerSelector);
$I->waitForElementNotVisible(ModalDialog::$openedModalSelector, 30);
}
}
......@@ -126,6 +126,7 @@ class TableListViewHelper extends AbstractBackendViewHelper
$enableControlPanels = $this->arguments['enableControlPanels'];
$this->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Recordlist/Recordlist');
$this->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Recordlist/RecordExportButton');
$this->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Backend/ActionDispatcher');
if ($enableControlPanels === true) {
$this->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Backend/ContextMenu');
......
......@@ -45,6 +45,7 @@ class DatabaseBrowser extends AbstractElementBrowser implements ElementBrowserIn
parent::initialize();
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Recordlist/BrowseDatabase');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Tree/PageBrowser');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Recordlist/RecordExportButton');
}
protected function initVariables()
......
<?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
{
}
......@@ -21,13 +21,16 @@ 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\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
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\Fluid\View\StandaloneView;
use TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList;
use TYPO3\CMS\Recordlist\RecordList\ExportRecordList;
/**
* Controller for handling exports of records, typically executed from the list module.
......@@ -36,97 +39,335 @@ use TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList;
*/
class RecordExportController
{
private const EXPORT_FORMATS = [
'csv' => [
'options' => [
'delimiter' => [
'comma' => ',',
'semicolon' => ';',
'pipe' => '|'
],
'quote' => [
'doublequote' => '"',
'singlequote' => '\'',
'space' => ' '
]
],
'defaults' => [
'delimiter' => ',',
'quote' => '"'
]
],
'json' => [
'options' => [
'meta' => [
'full' => 'full',
'prefix' => 'prefix',
'none' => 'none'
]
],
'defaults' => [
'meta' => 'prefix'
]
]
];
protected int $id = 0;
protected string $table = '';
protected string $format = '';
protected string $filename = '';
protected array $modTSconfig = [];
protected ResponseFactoryInterface $responseFactory;
protected UriBuilder $uriBuilder;
public function __construct(ResponseFactoryInterface $responseFactory)
public function __construct(ResponseFactoryInterface $responseFactory, UriBuilder $uriBuilder)
{
$this->responseFactory = $responseFactory;
$this->uriBuilder = $uriBuilder;
}
/**
* Handle record export request
* Handle record export request by evaluating the provided arguments,
* checking access, initializing the record list, fetching records and
* finally calling the requested export format action (e.g. csv).
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
public function handleExportRequest(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$backendUser = $this->getBackendUserAuthentication();
$parsedBody = $request->getParsedBody();
$table = (string)($queryParams['table'] ?? '');
if ($table === '') {
throw new InvalidTableException('No table was given for exporting records', 1623941276);
$this->table = (string)($parsedBody['table'] ?? '');
if ($this->table === '') {
throw new \RuntimeException('No table was given for exporting records', 1623941276);
}
$this->format = (string)($parsedBody['format'] ?? '');
if ($this->format === '' || !isset(self::EXPORT_FORMATS[$this->format])) {
throw new \RuntimeException('No or an invalid export format given', 1624562166);
}
$this->id = (int)($queryParams['id'] ?? 0);
$search_field = (string)($queryParams['search_field'] ?? '');
$search_levels = (int)($queryParams['search_levels'] ?? 0);
$this->filename = $this->generateFilename((string)($parsedBody['filename'] ?? ''));
$this->id = (int)($parsedBody['id'] ?? 0);
// Loading module configuration
$this->modTSconfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['web_list.'] ?? [];
// Loading current page record and checking access
$backendUser = $this->getBackendUserAuthentication();
$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) {
$searchString = (string)($parsedBody['searchString'] ?? '');
$searchLevels = (int)($parsedBody['searchLevels'] ?? 0);
if (!is_array($pageinfo) && !($this->id === 0 && $searchString !== '' && $searchLevels !== 0)) {
throw new AccessDeniedException('Insufficient permissions for accessing this export', 1623941361);
}
// Initialize database record list
$recordList = GeneralUtility::makeInstance(DatabaseRecordList::class);
$recordList->modTSconfig = $this->modTSconfig;
$recordList->setFields[$this->table] = ($parsedBody['allColumns'] ?? false)
? $recordList->makeFieldList($this->table, false, true)
: $backendUser->getModuleData('list/displayFields')[$this->table] ?? [];
$recordList->setLanguagesAllowedForUser($this->getSiteLanguages($request));
$recordList->start($this->id, $table, 0, $search_field, $search_levels);
$recordList->start($this->id, $this->table, 0, $searchString, $searchLevels);
$columnsToRender = $recordList->getColumnsToRender($this->table, false);
$hideTranslations = ($this->modTSconfig['hideTranslations'] ?? '') === '*'
|| GeneralUtility::inList($this->modTSconfig['hideTranslations'] ?? '', $this->table);
// Initialize the exporter
$exporter = GeneralUtility::makeInstance(
ExportRecordList::class,
$recordList,
GeneralUtility::makeInstance(TranslationConfigurationProvider::class)
);
// Fetch and process the header row and the records
$headerRow = $exporter->getHeaderRow($columnsToRender);
$records = $exporter->getRecords(
$this->table,
$this->id,
$columnsToRender,
$this->getBackendUserAuthentication(),
$hideTranslations,
(bool)($parsedBody['rawValues'] ?? false)
);
$exportAction = $this->format . 'ExportAction';
return $this->{$exportAction}($request, $headerRow, $records);
}
/**
* Generate settings form for the export request
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function exportSettingsAction(ServerRequestInterface $request): ResponseInterface
{
$exportArguments = $request->getQueryParams();
$this->table = (string)($exportArguments['table'] ?? '');
if ($this->table === '') {
throw new \RuntimeException('No table was given for exporting records', 1624551586);
}
$this->id = (int)($exportArguments['id'] ?? 0);
$this->modTSconfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['web_list.'] ?? [];
$view = GeneralUtility::makeInstance(StandaloneView::class);
$view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName(
'EXT:recordlist/Resources/Private/Templates/RecordExportSettings.html'
));
$view->assignMultiple([
'formUrl' => $this->uriBuilder->buildUriFromRoute('record_export'),
'table' => $this->table,
'exportArguments' => $exportArguments,
'formats' => array_keys(self::EXPORT_FORMATS),
'formatOptions' => $this->getFormatOptionsWithResolvedDefaults(),
]);
$response = $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8');
$response->getBody()->write($view->render());
return $response;
}
/**
* Generating an export in CSV format
*
* @param ServerRequestInterface $request
* @param array $headerRow
* @param array $records
* @return ResponseInterface
*/
protected function csvExportAction(
ServerRequestInterface $request,
array $headerRow,
array $records
): ResponseInterface {
// Fetch csv related format options
$csvDelimiter = $this->getFormatOption($request, 'delimiter');
$csvQuote = $this->getFormatOption($request, 'quote');
// Create result
$result[] = CsvUtility::csvValues($headerRow, $csvDelimiter, $csvQuote);
foreach ($records as $record) {
$result[] = CsvUtility::csvValues($record, $csvDelimiter, $csvQuote);
}
return $this->generateExportResponse(implode(CRLF, $result));
}
/**
* Generating an export in JSON format
*
* @param ServerRequestInterface $request
* @param array $headerRow
* @param array $records
*
* @return ResponseInterface
*/
protected function jsonExportAction(
ServerRequestInterface $request,
array $headerRow,
array $records
): ResponseInterface {
// Fetch and evaluate json related format option
switch ($this->getFormatOption($request, 'meta')) {
case 'prefix':
$result = [$this->table . ':' . $this->id => $records];
break;
case 'full':
$user = $this->getBackendUserAuthentication();
$parsedBody = $request->getParsedBody();
$result = [
'meta' => [
'table' => $this->table,
'page' => $this->id,
'timestamp' => GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'),
'user' => $user->user[$user->username_column] ?? '',
'site' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ?? '',
'options' => [
'columns' => array_values($headerRow),
'values' => ($parsedBody['rawvalues'] ?? false) ? 'raw' : 'processed'
]
],
'records' => $records
];
$searchString = (string)($parsedBody['searchString'] ?? '');
$searchLevels = (int)($parsedBody['searchLevels'] ?? 0);
if ($searchString !== '' || $searchLevels !== 0) {
$result['meta']['search'] = [
'searchTerm' => $searchString,
'searchLevels' => $searchLevels
];
}
break;
case 'none':
default:
$result = $records;
break;
}
// 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);
return $this->generateExportResponse(json_encode($result) ?: '');
}
/**
* Get site languages, available for the current backend user
*
* @param ServerRequestInterface $request
* @return array
*/
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
/**
* Return an evaluated and processed custom filename or a
* default, if non or an invalid custom filename was provided.
*
* @param string $filename
* @return string
*/