Commit cc0c62e8 authored by Benni Mack's avatar Benni Mack Committed by Christian Kuhn
Browse files

[FEATURE] Improve Usability in Workspaces module

The workspaces module has a better style and some
improvements for editors and administrators:

* Editors now get feedback if an AJAX call shows no results
* Editors + Administrators now switch workspace via a selector,
  very helpful when having more than a couple of workspaces
* Administrators can now edit a workspace record
  directly from the module

The change cleans up a lot of unused code in the main workspaces
module and brings a few UX improvements within the module.

* Dropdowns instead of tabs (good when having a lot of workspaces)
* Language + Depth + Action selection is now rendered via
  Controller+Action instead of waiting for a first AJAX round trip
* Properly using "moduleData" from "uc" to store information
* Solved issues related to language icon rendering
* Removed unused inline settings
* Consistent usage of Persistent JS module accessing BE_Users' UC
* nProgress for showing progress of loading AJAX requests

Next steps in this area:
* Hand in the first payload as JSON to avoid AJAX call on
  initial page load
* Remove leftover inline JavaScript

Resolves: #94819
Releases: master
Change-Id: Ie533656a14af56dad4a4039fcbc9b08bde693500
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/70452


Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Wouter Wolters's avatarWouter Wolters <typo3@wouterwolters.nl>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Wouter Wolters's avatarWouter Wolters <typo3@wouterwolters.nl>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent b906f761
......@@ -13,7 +13,6 @@
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import $ from 'jquery';
import 'nprogress';
import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity';
import 'TYPO3/CMS/Backend/Input/Clearable';
import Workspaces from './Workspaces';
......@@ -21,7 +20,6 @@ import Modal = require('TYPO3/CMS/Backend/Modal');
import Persistent = require('TYPO3/CMS/Backend/Storage/Persistent');
import Tooltip = require('TYPO3/CMS/Backend/Tooltip');
import Utility = require('TYPO3/CMS/Backend/Utility');
import Viewport = require('TYPO3/CMS/Backend/Viewport');
import Wizard = require('TYPO3/CMS/Backend/Wizard');
import SecurityUtility = require('TYPO3/CMS/Core/SecurityUtility');
import windowManager = require('TYPO3/CMS/Backend/WindowManager');
......@@ -36,6 +34,8 @@ enum Identifiers {
chooseSelectionAction = '#workspace-actions-form [name="selection-action"]',
chooseMassAction = '#workspace-actions-form [name="mass-action"]',
container = '#workspace-panel',
contentsContainer = '#workspace-contents',
noContentsContainer = '#workspace-contents-empty',
actionIcons = '#workspace-action-icons',
toggleAll = '.t3js-toggle-all',
previewLinksButton = '.t3js-preview-link',
......@@ -51,7 +51,8 @@ class Backend extends Workspaces {
private settings: { [key: string]: string | number } = {
dir: 'ASC',
id: TYPO3.settings.Workspaces.id,
language: TYPO3.settings.Workspaces.language,
depth: 1,
language: 'all',
limit: 30,
query: '',
sort: 'label_Live',
......@@ -193,22 +194,40 @@ class Backend extends Workspaces {
super();
$((): void => {
let persistedDepth;
this.getElements();
this.registerEvents();
this.notifyWorkspaceSwitchAction();
if (Persistent.isset('this.Module.depth')) {
persistedDepth = Persistent.get('this.Module.depth');
this.elements.$depthSelector.val(persistedDepth);
this.settings.depth = persistedDepth;
} else {
this.settings.depth = TYPO3.settings.Workspaces.depth;
}
this.loadWorkspaceComponents();
// Set the depth from the main element
this.settings.depth = this.elements.$depthSelector.val();
this.settings.language = this.elements.$languageSelector.val();
this.getWorkspaceInfos();
});
}
private notifyWorkspaceSwitchAction(): void {
const mainElement = document.querySelector('main[data-workspace-switch-action]') as HTMLElement;
if (mainElement.dataset.workspaceSwitchAction) {
const workspaceSwitchInformation = JSON.parse(mainElement.dataset.workspaceSwitchAction);
// we need to do this manually, but this should be done better via proper events
top.TYPO3.WorkspacesMenu.performWorkspaceSwitch(workspaceSwitchInformation.id, workspaceSwitchInformation.title);
top.document.dispatchEvent(new CustomEvent('typo3:pagetree:refresh'));
top.TYPO3.ModuleMenu.App.refreshMenu();
}
}
/**
* Checks the integrity of a record
*
* @param {Array} payload
* @return {$}
*/
private checkIntegrity(payload: object): Promise<AjaxResponse> {
return this.sendRemoteRequest(
this.generateRemotePayload('checkIntegrity', payload),
);
}
private getElements(): void {
this.elements.$searchForm = $(Identifiers.searchForm);
this.elements.$searchTextField = $(Identifiers.searchTextField);
......@@ -216,7 +235,9 @@ class Backend extends Workspaces {
this.elements.$depthSelector = $(Identifiers.depthSelector);
this.elements.$languageSelector = $(Identifiers.languageSelector);
this.elements.$container = $(Identifiers.container);
this.elements.$tableBody = this.elements.$container.find('tbody');
this.elements.$contentsContainer = $(Identifiers.contentsContainer);
this.elements.$noContentsContainer = $(Identifiers.noContentsContainer);
this.elements.$tableBody = this.elements.$contentsContainer.find('tbody');
this.elements.$actionIcons = $(Identifiers.actionIcons);
this.elements.$toggleAll = $(Identifiers.toggleAll);
this.elements.$chooseStageAction = $(Identifiers.chooseStageAction);
......@@ -347,7 +368,7 @@ class Backend extends Workspaces {
// Listen for depth changes
this.elements.$depthSelector.on('change', (e: JQueryEventObject): void => {
const depth = (<HTMLSelectElement>e.target).value;
Persistent.set('this.Module.depth', depth);
Persistent.set('moduleData.workspaces.settings.depth', depth);
this.settings.depth = depth;
this.getWorkspaceInfos();
});
......@@ -358,14 +379,14 @@ class Backend extends Workspaces {
// Listen for language changes
this.elements.$languageSelector.on('change', (e: JQueryEventObject): void => {
const $me = $(e.target);
Persistent.set('moduleData.workspaces.settings.language', $me.val());
this.settings.language = $me.val();
this.sendRemoteRequest([
this.generateRemoteActionsPayload('saveLanguageSelection', [$me.val()]),
this.sendRemoteRequest(
this.generateRemotePayload('getWorkspaceInfos', this.settings),
]).then((response: any): void => {
).then(async (response: AjaxResponse): Promise<void> => {
const actionResponse = await response.resolve();
this.elements.$languageSelector.prev().html($me.find(':selected').data('icon'));
this.renderWorkspaceInfos(response[1].result);
this.renderWorkspaceInfos(actionResponse[0].result);
});
});
......@@ -489,60 +510,7 @@ class Backend extends Workspaces {
}
/**
* Loads the workspace components, like available stage actions and items of the workspace
*/
private loadWorkspaceComponents(): void {
this.sendRemoteRequest([
this.generateRemotePayload('getWorkspaceInfos', this.settings),
this.generateRemotePayload('getStageActions', {}),
this.generateRemoteMassActionsPayload('getMassStageActions', {}),
this.generateRemotePayload('getSystemLanguages', {
pageUid: this.elements.$container.data('pageUid'),
}),
]).then(async (response: AjaxResponse): Promise<void> => {
const resolvedResponse = await response.resolve();
this.elements.$depthSelector.prop('disabled', false);
// Records
this.renderWorkspaceInfos(resolvedResponse[0].result);
// Stage actions
const stageActions = resolvedResponse[1].result.data;
let i;
for (i = 0; i < stageActions.length; ++i) {
this.elements.$chooseStageAction.append(
$('<option />').val(stageActions[i].uid).text(stageActions[i].title),
);
}
// Mass actions
const massActions = resolvedResponse[2].result.data;
for (i = 0; i < massActions.length; ++i) {
this.elements.$chooseSelectionAction.append(
$('<option />').val(massActions[i].action).text(massActions[i].title),
);
this.elements.$chooseMassAction.append(
$('<option />').val(massActions[i].action).text(massActions[i].title),
);
}
// Languages
const languages = resolvedResponse[3].result.data;
for (i = 0; i < languages.length; ++i) {
const $option = $('<option />').val(languages[i].uid).text(languages[i].title).data('icon', languages[i].icon);
if (String(languages[i].uid) === String(TYPO3.settings.Workspaces.language)) {
$option.prop('selected', true);
this.elements.$languageSelector.prev().html(languages[i].icon);
}
this.elements.$languageSelector.append($option);
}
this.elements.$languageSelector.prop('disabled', false);
});
}
/**
* Gets the workspace infos
* Gets the workspace infos (= filling the contents).
*
* @return {Promise}
* @protected
......@@ -556,7 +524,7 @@ class Backend extends Workspaces {
}
/**
* Renders fetched workspace informations
* Renders fetched workspace information
*
* @param {Object} result
*/
......@@ -569,6 +537,15 @@ class Backend extends Workspaces {
this.buildPagination(result.total);
// disable the contents area
if (result.total === 0) {
this.elements.$contentsContainer.hide();
this.elements.$noContentsContainer.show();
} else {
this.elements.$contentsContainer.show();
this.elements.$noContentsContainer.hide();
}
for (let i = 0; i < result.data.length; ++i) {
const item = result.data[i];
const $actions = $('<div />', {class: 'btn-group'});
......@@ -1267,6 +1244,7 @@ class Backend extends Workspaces {
private getPreRenderedIcon(identifier: string): JQuery {
return this.elements.$actionIcons.find('[data-identifier="' + identifier + '"]').clone();
}
}
/**
......
......@@ -15,6 +15,7 @@ import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import {SeverityEnum} from 'TYPO3/CMS/Backend/Enum/Severity';
import $ from 'jquery';
import NProgress = require('nprogress');
import Modal = require('TYPO3/CMS/Backend/Modal');
export default class Workspaces {
......@@ -118,18 +119,6 @@ export default class Workspaces {
return $modal;
}
/**
* Checks the integrity of a record
*
* @param {Array} payload
* @return {$}
*/
protected checkIntegrity(payload: object): Promise<AjaxResponse> {
return this.sendRemoteRequest(
this.generateRemotePayload('checkIntegrity', payload),
);
}
/**
* Sends an AJAX request
*
......@@ -137,6 +126,8 @@ export default class Workspaces {
* @return {$}
*/
protected sendRemoteRequest(payload: object): Promise<AjaxResponse> {
NProgress.configure({ parent: '#workspace-content-wrapper', showSpinner: false });
NProgress.start();
return (new AjaxRequest(TYPO3.settings.ajaxUrls.workspace_dispatch)).post(
payload,
{
......@@ -144,7 +135,7 @@ export default class Workspaces {
'Content-Type': 'application/json; charset=utf-8'
}
}
);
).finally(() => NProgress.done());
}
/**
......
.. include:: ../../Includes.txt
============================================
Feature: #94819 - Improved Workspaces module
============================================
See :issue:`94819`
Description
===========
The workspaces module has been improved in usability:
For the initial loading of the module, the AJAX request has
to process less data as all information is already loaded with
the module.
A loading indicator is now visible during AJAX requests to
show editors that there is work in progress.
A dropdown is now used to choose between multiple workspaces,
which is especially useful when having multiple workspaces.
Administrators can edit workspace settings directly
from the module's docheader area.
Impact
======
The overall user experience has been improved and administrators
do not need to use the list module to manage workspaces anymore.
.. index:: Backend, ext:workspaces
\ No newline at end of file
......@@ -209,20 +209,6 @@ class ActionHandler
return [];
}
/**
* Saves the selected language.
*
* @param int|string $language
*/
public function saveLanguageSelection($language)
{
if (MathUtility::canBeInterpretedAsInteger($language) === false && $language !== 'all') {
$language = 'all';
}
$this->getBackendUser()->uc['moduleData']['Workspaces'][$this->getBackendUser()->workspace]['language'] = $language;
$this->getBackendUser()->writeUC();
}
/**
* Gets the dialog window to be displayed before a record can be sent to the next stage.
*
......
......@@ -30,13 +30,6 @@ class MassActionHandler
{
const MAX_RECORDS_TO_PROCESS = 30;
/**
* Path to the locallang file
*
* @var string
*/
private $pathToLocallang = 'LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf';
/**
* @var WorkspaceService
*/
......@@ -47,33 +40,6 @@ class MassActionHandler
$this->workspaceService = GeneralUtility::makeInstance(WorkspaceService::class);
}
/**
* Get list of available mass workspace actions.
*
* @return array $data
*/
public function getMassStageActions()
{
$actions = [];
$currentWorkspace = $this->getCurrentWorkspace();
$backendUser = $this->getBackendUser();
$massActionsEnabled = (bool)($backendUser->getTSConfig()['options.']['workspaces.']['enableMassActions'] ?? true);
if ($massActionsEnabled) {
$publishAccess = $backendUser->workspacePublishAccess($currentWorkspace);
if ($publishAccess && !(($backendUser->workspaceRec['publish_access'] ?? 0) & 1)) {
$actions[] = ['action' => 'publish', 'title' => $this->getLanguageService()->sL($this->pathToLocallang . ':label_doaction_publish')];
}
if ($currentWorkspace !== WorkspaceService::LIVE_WORKSPACE_ID) {
$actions[] = ['action' => 'discard', 'title' => $this->getLanguageService()->sL($this->pathToLocallang . ':label_doaction_discard')];
}
}
$result = [
'total' => count($actions),
'data' => $actions
];
return $result;
}
/**
* Publishes the current workspace.
*
......@@ -247,7 +213,7 @@ class MassActionHandler
*
* @return int The current workspace ID
*/
protected function getCurrentWorkspace()
protected function getCurrentWorkspace(): int
{
return $this->workspaceService->getCurrentWorkspace();
}
......
......@@ -114,21 +114,6 @@ class RemoteServer
return $data;
}
/**
* Get List of available workspace actions
*
* @return array $data
*/
public function getStageActions()
{
$stages = $this->stagesService->getStagesForWSUser();
$data = [
'total' => count($stages),
'data' => $stages
];
return $data;
}
/**
* Fetch further information to current selected workspace record.
*
......@@ -458,39 +443,6 @@ class RemoteServer
return $sysLogReturnArray;
}
/**
* Gets all available system languages.
*
* @param \stdClass $parameters
* @return array
*/
public function getSystemLanguages(\stdClass $parameters)
{
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
$systemLanguages = [
[
'uid' => 'all',
'title' => $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:language.allLanguages'),
'icon' => $iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render()
]
];
foreach ($this->gridDataService->getSystemLanguages($parameters->pageUid ?? 0) as $id => $systemLanguage) {
if ($id < 0) {
continue;
}
$systemLanguages[] = [
'uid' => $id,
'title' => htmlspecialchars($systemLanguage['title']),
'icon' => $iconFactory->getIcon($systemLanguage['flagIcon'], Icon::SIZE_SMALL)->render()
];
}
$result = [
'total' => count($systemLanguages),
'data' => $systemLanguages
];
return $result;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
......
......@@ -17,7 +17,9 @@ namespace TYPO3\CMS\Workspaces\Controller;
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\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
......@@ -30,6 +32,7 @@ use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
use TYPO3\CMS\Fluid\View\StandaloneView;
use TYPO3\CMS\Workspaces\Service\StagesService;
use TYPO3\CMS\Workspaces\Service\WorkspaceService;
/**
......@@ -53,6 +56,7 @@ class ReviewController
protected $pageId;
protected WorkspaceService $workspaceService;
protected StagesService $stagesService;
protected IconFactory $iconFactory;
protected PageRenderer $pageRenderer;
protected UriBuilder $uriBuilder;
......@@ -60,12 +64,14 @@ class ReviewController
public function __construct(
WorkspaceService $workspaceService,
StagesService $stagesService,
IconFactory $iconFactory,
PageRenderer $pageRenderer,
UriBuilder $uriBuilder,
ModuleTemplateFactory $moduleTemplateFactory
) {
$this->workspaceService = $workspaceService;
$this->stagesService = $stagesService;
$this->iconFactory = $iconFactory;
$this->pageRenderer = $pageRenderer;
$this->uriBuilder = $uriBuilder;
......@@ -86,27 +92,7 @@ class ReviewController
'error' => $this->iconFactory->getIcon('status-dialog-error', Icon::SIZE_SMALL)->render()
];
$this->pageRenderer->addInlineSetting('Workspaces', 'icons', $icons);
$this->pageRenderer->addInlineSetting('Workspaces', 'id', $this->pageId);
$this->pageRenderer->addInlineSetting('Workspaces', 'depth', $this->pageId === 0 ? 999 : 1);
$this->pageRenderer->addInlineSetting('Workspaces', 'language', $this->getLanguageSelection());
$lang = $this->getLanguageService();
$this->pageRenderer->addInlineLanguageLabelArray([
'title' => $lang->getLL('title'),
'path' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.path'),
'table' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.table'),
'depth' => $lang->sL('LLL:EXT:beuser/Resources/Private/Language/locallang_mod_permission.xlf:Depth'),
'depth_0' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'),
'depth_1' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_1'),
'depth_2' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_2'),
'depth_3' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_3'),
'depth_4' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_4'),
'depth_infi' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_infi')
]);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:workspaces/Resources/Private/Language/locallang.xlf');
$states = $this->getBackendUser()->uc['moduleData']['Workspaces']['States'] ?? [];
$this->pageRenderer->addInlineSetting('Workspaces', 'States', $states);
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Workspaces/Backend');
$this->pageRenderer->addInlineSetting('FormEngine', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit'));
$this->pageRenderer->addInlineSetting('RecordHistory', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_history'));
......@@ -146,23 +132,19 @@ class ReviewController
$pageTitle = BackendUtility::getRecordTitle('pages', $pageRecord);
}
}
$wsList = $this->workspaceService->getAvailableWorkspaces();
$customWorkspaceExists = $this->customWorkspaceExists($wsList);
$availableWorkspaces = $this->workspaceService->getAvailableWorkspaces();
$customWorkspaceExists = $this->customWorkspaceExists($availableWorkspaces);
$activeWorkspace = (int)$backendUser->workspace;
$activeWorkspaceTitle = WorkspaceService::getWorkspaceTitle($activeWorkspace);
$performWorkspaceSwitch = false;
if ((int)($queryParams['workspace'] ?? 0) > 0) {
if (isset($queryParams['workspace'])) {
$switchWs = (int)$queryParams['workspace'];
if (array_key_exists($switchWs, $wsList) && $activeWorkspace !== $switchWs) {
if (array_key_exists($switchWs, $availableWorkspaces) && $activeWorkspace !== $switchWs) {
$activeWorkspace = $switchWs;
$backendUser->setWorkspace($activeWorkspace);
$performWorkspaceSwitch = true;
BackendUtility::setUpdateSignal('updatePageTree');
$activeWorkspaceTitle = WorkspaceService::getWorkspaceTitle($activeWorkspace);
$this->view->assign('workspaceSwitched', GeneralUtility::jsonEncodeForHtmlAttribute(['id' => $activeWorkspace, 'title' => $activeWorkspaceTitle]));
}
}
$this->pageRenderer->addInlineSetting('Workspaces', 'isLiveWorkspace', (int)$backendUser->workspace === 0);
$this->pageRenderer->addInlineSetting('Workspaces', 'workspaceTabs', $this->prepareWorkspaceTabs($wsList, $activeWorkspace));
$this->pageRenderer->addInlineSetting('Workspaces', 'activeWorkspaceId', $activeWorkspace);
$workspaceIsAccessible = $backendUser->workspace !== WorkspaceService::LIVE_WORKSPACE_ID;
$this->moduleTemplate->setTitle(
......@@ -177,10 +159,13 @@ class ReviewController
'showLegend' => $workspaceIsAccessible,
'pageUid' => $this->pageId,
'pageTitle' => $pageTitle,
'performWorkspaceSwitch' => $performWorkspaceSwitch,
'workspaceList' => $this->prepareWorkspaceTabs($wsList, $activeWorkspace),
'activeWorkspaceUid' => $activeWorkspace,
'activeWorkspaceTitle' => $activeWorkspaceTitle,
'availableLanguages' => $this->getSystemLanguages($this->pageId),
'availableStages' => $this->stagesService->getStagesForWSUser(),
'stageActions' => $this->getStageActions(),
'selectedLanguage' => $this->getLanguageSelection(),
'selectedDepth' => $this->getDepthSelection(),
]);
$buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
......@@ -193,16 +178,63 @@ class ReviewController
->setIcon($this->iconFactory->getIcon('actions-version-workspaces-preview-link', Icon::SIZE_SMALL));
$buttonBar->addButton($showButton);
}
if ($backendUser->isAdmin() && $activeWorkspace) {
$editWorkspaceRecordUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'sys_workspace' => [
$activeWorkspace => 'edit'
]
],
'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('web_WorkspacesWorkspaces', ['id' => $this->pageId]),
]);
$editSettingsButton = $buttonBar->makeLinkButton()
->setHref($editWorkspaceRecordUrl)
->setShowLabelText(true)
->setTitle($this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang.xlf:button.editWorkspaceSettings'))
->setIcon($this->iconFactory->getIcon('actions-cog-alt', Icon::SIZE_SMALL));
$buttonBar->addButton(
$editSettingsButton,
ButtonBar::BUTTON_POSITION_LEFT,
90
);
}