Commit 16c5cae9 authored by Oliver Bartsch's avatar Oliver Bartsch Committed by Benni Mack
Browse files

[FEATURE] Improve show columns selection functionality

In #94218, the show columns selection was already improved
by relocating it to each tables' header, making it always
available (not only in single-table view).

Since the used dropdown leads to confusion and visibility
drawbacks for records, having long labels, the selector
now lives in a modal. Besides the columns to select,
the new modal contains some selector options, such as
"select all" or "toggle selection". Those options are
fixed at the top, making them always visible, even for
records with a lot of columns.

Furthermore the checkboxes now use a more suitable
"check" icon, the columns are sorted lexically and
also management fields (e.g. `sorting`) are now
displayed with a human-readable label. This label
is now also used in the table header.

Besides the UX, this patch also brings some technical
improvements. The whole column selection logic is moved
into a dedicated controller, which may allows reuse in
the future (e.g. for the filelist module). Also the
JavaScript part is extracted from the Recordlist JS-module
into a dedicated web component.

Finally some cleanup of the existing code is done, making
it more readable and more efficient.

Resolves: #94474
Releases: master
Change-Id: I0f2f8711ee4b40c9e29e633b9fe790dcacae8bb5
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69691

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Jochen's avatarJochen <rothjochen@gmail.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Jochen's avatarJochen <rothjochen@gmail.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent 0e01a866
......@@ -554,3 +554,26 @@ $form-toggle-checked-bg-image: url("data:image/svg+xml, <svg xmlns='http://www.w
}
}
}
/**
* A fixed form actions menu for the modal-body
*/
.sticky-form-actions {
position: fixed;
z-index: 1;
margin-left: -1rem; // revert modal-body padding
margin-top: -1rem; // revert modal-body padding
padding: 0.625rem 1rem; // modal-header / modal-footer like padding
background: $white;
a,
button {
margin: 0.25rem 0.25rem 0.25rem 0;
}
}
.modal-size-medium {
.sticky-form-actions {
width: 800px;
}
}
......@@ -19,16 +19,6 @@
.pagination {
display: inline-flex;
}
.field-selector-dropdown {
overflow-y: auto;
max-height: 6 * (($line-height-base * $font-size-base) + 4px + (2 * $padding-base-horizontal) + 1px);
}
.field-selector-header {
background-color: $dropdown-bg;
z-index: 2;
}
}
div.typo3-newRecordLink,
......
/*
* 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';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import Notification = require('TYPO3/CMS/Backend/Notification');
enum Selectors {
columnSelectors = '.t3js-record-column-selector',
columnSelectorActionsSelector = '.t3js-record-column-selector-actions'
}
enum SelectorActions {
toggle = 'select-toggle',
all = 'select-all',
none = 'select-none'
}
/**
* Module: TYPO3/CMS/Recordlist/ColumnSelectorButton
*
* @example
* <typo3-recordlist-column-selector-button
* url="/url/to/column/selector/form"
* target="/url/to/go/after/column/selection"
* title="Show columns"
* ok="Update"
* close="Cancel"
* close="Error"
* >
* <button>Show columns/button>
* </typo3-recordlist-column-selector-button>
*/
@customElement('typo3-recordlist-column-selector-button')
class ColumnSelectorButton extends LitElement {
@property({type: String}) url: string;
@property({type: String}) target: string;
@property({type: String}) title: string = 'Show columns';
@property({type: String}) ok: string = lll('button.ok') || 'Update';
@property({type: String}) close: string = lll('button.close') || 'Close';
@property({type: String}) error: string = 'Could not update columns';
private static toggleSelectors(
columnSelectors: NodeListOf<HTMLInputElement>,
selectAll: HTMLButtonElement,
selectNone: HTMLButtonElement
) {
selectAll.classList.add('disabled')
for (let i=0; i < columnSelectors.length; i++) {
if (!columnSelectors[i].disabled && !columnSelectors[i].checked) {
selectAll.classList.remove('disabled')
break;
}
}
selectNone.classList.add('disabled')
for (let i=0; i < columnSelectors.length; i++) {
if (!columnSelectors[i].disabled && columnSelectors[i].checked) {
selectNone.classList.remove('disabled')
break;
}
}
}
public constructor() {
super();
this.addEventListener('click', (e: Event): void => {
e.preventDefault();
this.showColumnSelectorModal();
});
}
protected render(): TemplateResult {
return html`<slot></slot>`;
}
private showColumnSelectorModal(): void {
if (!this.url || !this.target) {
// Don't render modal in case no url or target is given
return;
}
Modal.advanced({
content: this.url,
title: this.title,
severity: SeverityEnum.notice,
size: Modal.sizes.medium,
type: Modal.types.ajax,
buttons: [
{
text: this.close,
active: true,
btnClass: 'btn-default',
name: 'cancel',
trigger: (): void => Modal.dismiss(),
},
{
text: this.ok,
btnClass: 'btn-' + Severity.getCssClass(SeverityEnum.info),
name: 'update',
trigger: (): void => this.proccessSelection(Modal.currentModal[0])
}
],
ajaxCallback: (): void => this.handleModalContentLoaded(Modal.currentModal[0])
});
}
private proccessSelection(currentModal: HTMLElement): void {
const form: HTMLFormElement = currentModal.querySelector('form') as HTMLFormElement;
if (form === null) {
this.abortSelection();
return;
}
(new AjaxRequest(TYPO3.settings.ajaxUrls.record_show_columns))
.post('', {body: new FormData(form)})
.then(async (response: AjaxResponse): Promise<any> => {
const data = await response.resolve();
if (data.success === true) {
// @todo This does not jump to the anchor (#t3-table-some_table) after the reload!!!
this.ownerDocument.location.href = this.target
this.ownerDocument.location.reload(true);
} else {
Notification.error(data.message || 'No update was performed');
}
Modal.dismiss();
})
.catch(() => {
this.abortSelection();
})
}
private handleModalContentLoaded(currentModal: HTMLElement): void {
const form: HTMLFormElement = currentModal.querySelector('form') as HTMLFormElement;
if (form === null) {
// Early return if modal content does not include a form
return;
}
// Prevent the form from being submitted as the form data will be send via an ajax request
form.addEventListener('submit', (e: Event): void => {e.preventDefault()});
const columnSelectors: NodeListOf<HTMLInputElement> = currentModal.querySelectorAll(Selectors.columnSelectors);
const columnSelectorActions: HTMLDivElement = currentModal.querySelector(Selectors.columnSelectorActionsSelector);
const selectAll: HTMLButtonElement = columnSelectorActions.querySelector('button[data-action="' + SelectorActions.all + '"]');
const selectNone: HTMLButtonElement = columnSelectorActions.querySelector('button[data-action="' + SelectorActions.none + '"]');
if (selectAll === null || selectNone === null || !columnSelectors.length) {
// Return in case required elements do not exist in the modal content
return;
}
// Initialize select-all / select-none buttons
ColumnSelectorButton.toggleSelectors(columnSelectors, selectAll, selectNone);
// Add event listener for each column selector to toggle the selector actions after change
columnSelectors.forEach((column: HTMLInputElement) => {
column.addEventListener('change', (): void => {
ColumnSelectorButton.toggleSelectors(columnSelectors, selectAll, selectNone);
});
});
// Add event listener for selector actions
columnSelectorActions.addEventListener('click', (e: Event): void => {
e.preventDefault();
const target: HTMLElement = e.target as HTMLElement;
if (target.nodeName !== 'BUTTON' || !target.dataset.action) {
// Return if we don't deal with a valid action (Either no button or no action defined)
return;
}
// Perform requested action
switch (target.dataset.action) {
case SelectorActions.toggle:
columnSelectors.forEach((column: HTMLInputElement) => {
if (!column.disabled) {
column.checked = !column.checked;
}
});
break;
case SelectorActions.all:
columnSelectors.forEach((column: HTMLInputElement) => {
if (!column.disabled) {
column.checked = true;
}
});
break;
case SelectorActions.none:
columnSelectors.forEach((column: HTMLInputElement) => {
if (!column.disabled) {
column.checked = false;
}
});
break;
default:
// Unknown action
Notification.warning('Unknown selector action');
}
// After performing the action always toggle selectors
ColumnSelectorButton.toggleSelectors(columnSelectors, selectAll, selectNone);
});
}
private abortSelection(): void {
Notification.error(this.error);
Modal.dismiss();
}
}
......@@ -61,9 +61,6 @@ class Recordlist {
Tooltip.initialize('.table-fit a[title]');
this.registerPaginationEvents();
});
DocumentService.ready().then((): void => {
this.registerColumnSelectorEvents();
});
new RegularEvent('typo3:datahandler:process', this.handleDataHandlerResult.bind(this)).bindTo(document);
}
......@@ -242,24 +239,6 @@ class Recordlist {
});
});
}
/**
* Show columns dropdown: If "Toggle all" is changed, then all other checkboxes are flipped
*/
private registerColumnSelectorEvents = (): void => {
// Fill out initially if the other checkboxes are set.
document.querySelectorAll('.recordlist-select-allcolumns').forEach((allFieldsCheckbox: HTMLInputElement) => {
allFieldsCheckbox.addEventListener('change', (e: InputEvent) => {
allFieldsCheckbox.closest('form').querySelectorAll('.recordlist-select-column').forEach((checkbox: HTMLInputElement) => {
if (!checkbox.disabled) {
checkbox.checked = !checkbox.checked;
}
});
});
});
}
}
export = new Recordlist();
.. include:: ../../Includes.txt
================================================================
Feature: #94474 - Improved show columns selection in record list
================================================================
See :issue:`94474`
Description
===========
Since :issue:`94218`, the column selector in the record list,
formerly known as "field selector", is available for each
individual record type in its table header. When accessing
the selector, a dropdown opened, displaying all available
columns.
This was already a huge improvement, as the selection was
now directly bound to the corresponding table and was
always available, not only in the "single-table view".
However, there were still some drawbacks, especially the fact
that the dropdown solution could lead to confusion in case a
record contains a couple of columns with long labels. Therefore,
the column selection has been improved and is now not longer
opened in a dropdown, but lives in a clear and large enough modal.
In the new modal, besides the columns to select, there are three
new options available:
* Option to select all columns
* Option to unselect all columns
* Option to toggle (invert) the current selection
Those options are also fixed at the top, so they are always
visible, even for records with a lot of columns, e.g. `pages`.
Management fields, such as `uid` or `cr_date` are now displayed
with human-readable labels, making them more useful for editors.
Especially because those labels are not only used in the selector,
but are now also displayed in the record list table header.
Furthermore, the columns are now sorted lexically, while always
enabled columns, such as the record title, are always at the top
and all columns, not having a label, are added at the end of the list.
The checkboxes are improved in their size and appearance. Instead of
the usual "check" icon, an "eye" icon is used, making the intention
clear.
Impact
======
The column selector of each table in the record list now opens
a modal with improved selection functionality and an overall
improved UX.
.. index:: Backend, ext:recordlist
......@@ -127,6 +127,33 @@ Do you want to continue WITHOUT saving?</source>
<trans-unit id="labels._REF_" resname="labels._REF_">
<source>Ref</source>
</trans-unit>
<trans-unit id="labels.uid" resname="labels.uid">
<source>Unique ID</source>
</trans-unit>
<trans-unit id="labels.pid" resname="labels.pid">
<source>Parent page ID</source>
</trans-unit>
<trans-unit id="labels.tstamp" resname="labels.tstamp">
<source>Last changed</source>
</trans-unit>
<trans-unit id="labels.crdate" resname="labels.crdate">
<source>Creation date</source>
</trans-unit>
<trans-unit id="labels.cruser_id" resname="labels.cruser_id">
<source>Creator</source>
</trans-unit>
<trans-unit id="labels.sorting" resname="labels.sorting">
<source>Sorting</source>
</trans-unit>
<trans-unit id="labels.t3ver_state" resname="labels.t3ver_state">
<source>Workspace status</source>
</trans-unit>
<trans-unit id="labels.t3ver_wsid" resname="labels.t3ver_wsid">
<source>Workspace ID</source>
</trans-unit>
<trans-unit id="labels.t3ver_oid" resname="labels.t3ver_oid">
<source>Live record ID</source>
</trans-unit>
<trans-unit id="labels.setFields" resname="labels.setFields">
<source>Set fields</source>
</trans-unit>
......@@ -283,6 +310,9 @@ Do you want to continue WITHOUT saving?</source>
<trans-unit id="labels.toggleall" resname="labels.toggleall">
<source>Toggle all</source>
</trans-unit>
<trans-unit id="labels.toggleSelection" resname="labels.toggleSelection">
<source>Toggle selection</source>
</trans-unit>
<trans-unit id="labels.selected" resname="labels.selected">
<source>Selected Items</source>
</trans-unit>
......
......@@ -149,6 +149,8 @@ class TableListViewHelper extends AbstractBackendViewHelper
}
$dblist->start($storagePid, $tableName, (int)GeneralUtility::_GP('pointer'), $filter, $levels, $recordsPerPage);
$dblist->dontShowClipControlPanels = true;
// Column selector is disabled since fields are defined by the "fieldList" argument
$dblist->displayColumnSelector = false;
$dblist->setFields = [$tableName => $fieldList];
$dblist->noControlPanels = !$enableControlPanels;
$dblist->sortField = $sortField;
......
......@@ -46,6 +46,7 @@ class DatabaseBrowser extends AbstractElementBrowser implements ElementBrowserIn
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Recordlist/BrowseDatabase');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Tree/PageBrowser');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Recordlist/RecordExportButton');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Recordlist/ColumnSelectorButton');
}
protected function initVariables()
......@@ -196,7 +197,7 @@ class DatabaseBrowser extends AbstractElementBrowser implements ElementBrowserIn
$searchLevels
);
$dbList->setDispFields($this->getRequest()->getParsedBody()['displayFields'] ?? null);
$dbList->setDispFields();
$tableList = $dbList->generateList();
$out .= $this->renderSearchBox($dbList, $searchWord, $searchLevels);
......
<?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\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
use TYPO3\CMS\Recordlist\RecordList\DatabaseRecordList;
use TYPO3Fluid\Fluid\View\ViewInterface;
/**
* Controller for handling the display column selection for 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 ColumnSelectorController
{
private const PSEUDO_FIELDS = ['_REF_', '_PATH_'];
protected ResponseFactoryInterface $responseFactory;
public function __construct(ResponseFactoryInterface $responseFactory)
{
$this->responseFactory = $responseFactory;
}
/**
* Update the columns to be displayed for the given table
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function updateVisibleColumnsAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$table = (string)($parsedBody['table'] ?? '');
$selectedColumns = $parsedBody['selectedColumns'] ?? [];
if ($table === '' || !is_array($selectedColumns) || $selectedColumns === []) {
return $this->jsonResponse([
'success' => false,
'message' => htmlspecialchars(
$this->getLanguageService()->sL('LLL:EXT:recordlist/Resources/Private/Language/locallang.xlf:updateColumnView.nothingUpdated')
)
]);
}
$backendUser = $this->getBackendUserAuthentication();
$displayFields = $backendUser->getModuleData('list/displayFields');
$displayFields[$table] = $selectedColumns;
$backendUser->pushModuleData('list/displayFields', $displayFields);
return $this->jsonResponse(['success' => true]);
}
/**
* Generate the show columns selector form
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function showColumnsSelectorAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$table = (string)($queryParams['table'] ?? '');
if ($table === '') {
throw new \RuntimeException('No table was given for selecting columns', 1625169125);
}
$view = GeneralUtility::makeInstance(StandaloneView::class);
$view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName(
'EXT:recordlist/Resources/Private/Templates/ColumnSelector.html'
));
$view->assignMultiple([
'table' => $table,
'columns' => $this->getColumns($table, (int)($parsedBody['id'] ?? 0))
]);
return $this->htmlResponse($view);
}
/**
* Retrieve all columns for the table, which can be selected
*
* @param string $table
* @param int $pageId
* @return array
*/
protected function getColumns(string $table, int $pageId): array
{
$tsConfig = BackendUtility::getPagesTSconfig($pageId) ?? [];
// Current fields selection
$displayFields = $this->getBackendUserAuthentication()->getModuleData('list/displayFields')[$table] ?? [];
// Request fields from table and add pseudo fields
$fields = array_merge(
GeneralUtility::makeInstance(DatabaseRecordList::class)->makeFieldList($table, false, true),
self::PSEUDO_FIELDS
);
$columns = $specialColumns = $disabledColumns = [];
foreach ($fields as $fieldName) {
// Hide field if disabled
if ($tsConfig['TCEFORM.'][$table . '.'][$fieldName . '.']['disabled'] ?? false) {
continue;
}