Commit cdc6b71f authored by Oliver Bartsch's avatar Oliver Bartsch
Browse files

[TASK] Unify code in FileList

Currently, the Backend module File > FileList
has two main views:

* The list view
* The search result view

Both are handled via an Extbase controller, even
though no pure Extbase-related feature (validation,
type-converting, persistence) is used.

Instead, the list view is generated by a PHP-based
code "FileList" (similar to DatabaseRecordList),
where as the search result view is built on top of
Fluid. The latter approach hinders flexibility,
and also leads to inconsistencies, due to the nature of
using two different approaches.

Most notable:

- The search result view looks different than the
  list view, also no additional icons are possible.

- The search result view has a clipboard, but does
  not allow to expand.

- The search result view does not respect the listing
  limit, nor is the pagination displayed.

- The search result view changes the module headline
  to "Search: <word>", removing the context on
  which folder the search was performed.

However, the search result view works with the
SearchDemand object which is a clean way to
put the query requirements into the File List
to render the found files.

Therefore, this patch merges both Search Result Fluid
view and PHP-based view back into one, more flexible
and consistent, view. This also means, FileListController
is not longer registered as an Extbase backend module
and some internal ViewHelpers and classes were removed.

The main benefits:
* Exchanging features between DatabaseRecordList and FileList
  are easier to achieve
* Hooks for additional icons also work in the search result view
* Numeric Clipboards also work in the search result view
* Buttons in each found file row look the same now
* The current folder is now also displayed in search result view
* Bookmarking also works properly now and additionally
  also takes a possible searchTerm into account
* The search results are now paginated, respecting the
  listing limit
* Invalid folders do not longer lead to an exception
  in the search result view

Resolves: #87974
Resolves: #89867
Resolves: #92247
Resolves: #92747
Resolves: #93185
Releases: master
Change-Id: I259fe7f63ef8ea69f6bdf0c787330f4338d4bd5a
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69718

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Jochen's avatarJochen <rothjochen@gmail.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Jochen's avatarJochen <rothjochen@gmail.com>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
parent 14d509cc
......@@ -19,11 +19,22 @@ import broadcastService = require('TYPO3/CMS/Backend/BroadcastService');
import Tooltip = require('TYPO3/CMS/Backend/Tooltip');
import RegularEvent = require('TYPO3/CMS/Core/Event/RegularEvent');
enum Selectors {
fileListFormSelector = 'form[name="fileListForm"]',
commandSelector = 'input[name="cmd"]',
searchFieldSelector = 'input[name="searchTerm"]'
}
/**
* Module: TYPO3/CMS/Filelist/Filelist
* @exports TYPO3/CMS/Filelist/Filelist
*/
class Filelist {
private fileListForm: HTMLFormElement = document.querySelector(Selectors.fileListFormSelector);
private command: HTMLInputElement = this.fileListForm.querySelector(Selectors.commandSelector)
private searchField: HTMLInputElement = this.fileListForm.querySelector(Selectors.searchFieldSelector);
private activeSearch: boolean = (this.searchField.value !== '');
protected static openInfoPopup(type: string, identifier: string): void {
InfoWindow.showItem(type, identifier);
}
......@@ -68,12 +79,6 @@ class Filelist {
broadcastService.post(message);
}
private static submitClipboardFormWithCommand(cmd: string): void {
const form = document.querySelector('form[name="dblistForm"]') as HTMLFormElement
(form.querySelector('input[name="cmd"]') as HTMLInputElement).value = cmd;
form.submit();
}
constructor() {
Filelist.processTriggers();
DocumentService.ready().then((): void => {
......@@ -113,16 +118,28 @@ class Filelist {
if (clipboardCmd !== null) {
new RegularEvent('filelist:clipboard:cmd', (event: ModalResponseEvent, target: HTMLElement): void => {
if (event.detail.result) {
Filelist.submitClipboardFormWithCommand(event.detail.payload);
this.submitClipboardFormWithCommand(event.detail.payload);
}
}).bindTo(clipboardCmd);
}
new RegularEvent('click', (event: ModalResponseEvent, target: HTMLElement): void => {
const cmd = target.dataset.filelistClipboardCmd;
Filelist.submitClipboardFormWithCommand(cmd);
this.submitClipboardFormWithCommand(cmd);
}).delegateTo(document, '[data-filelist-clipboard-cmd]:not([data-filelist-clipboard-cmd=""])');
});
// Respond to browser related clearable event
new RegularEvent('search', (): void => {
if (this.searchField.value === '' && this.activeSearch) {
this.fileListForm.submit();
}
}).bindTo(this.searchField);
}
private submitClipboardFormWithCommand(cmd: string): void {
this.command.value = cmd;
this.fileListForm.submit();
}
}
......
/*
* 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 'TYPO3/CMS/Backend/Input/Clearable';
import DocumentService = require('TYPO3/CMS/Core/DocumentService');
/**
* Module: TYPO3/CMS/Filelist/RenameFile
* Modal to pick the required conflict strategy for colliding filenames
* @exports TYPO3/CMS/Filelist/RenameFile
*/
class FileSearch {
constructor() {
DocumentService.ready().then((): void => {
let searchField: HTMLInputElement;
if ((searchField = document.querySelector('input[name="tx_filelist_file_filelistlist[searchWord]"]')) !== null) {
const searchResultShown = '' !== searchField.value;
// make search field clearable
searchField.clearable({
onClear: (input: HTMLInputElement): void => {
if (searchResultShown) {
input.closest('form').submit();
}
},
});
}
});
}
}
export = new FileSearch();
......@@ -43,7 +43,7 @@ class FileMetaDataCest
$I->switchToContentFrame();
$I->canSee('fileadmin');
$I->fillField('tx_filelist_file_filelistlist[searchWord]', 'bus');
$I->fillField('input[name="searchTerm"]', 'bus');
$I->click('button[type="submit"]');
$I->waitForElementVisible('table.table-striped');
......
<?php
/*
* 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\Filelist;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\AbstractFile;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Class FileFacade
*
* This class is meant to be a wrapper for Resource\File objects, which do not
* provide necessary methods needed in the views of the filelist extension. It
* is a first approach to get rid of the FileList class that mixes up PHP,
* HTML and JavaScript.
* @internal this is a concrete TYPO3 hook implementation and solely used for EXT:filelist and not part of TYPO3's Core API.
*/
class FileFacade
{
/**
* Cache to count the number of references for each file
*
* @var array
*/
protected static $referenceCounts = [];
/**
* @var \TYPO3\CMS\Core\Resource\FileInterface
*/
protected $resource;
/**
* @var \TYPO3\CMS\Core\Imaging\IconFactory
*/
protected $iconFactory;
/**
* @param \TYPO3\CMS\Core\Resource\FileInterface $resource
* @internal Do not use outside of EXT:filelist!
*/
public function __construct(FileInterface $resource)
{
$this->resource = $resource;
$this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
}
/**
* @return \TYPO3\CMS\Core\Resource\FileInterface
*/
public function getResource(): FileInterface
{
return $this->resource;
}
/**
* @return bool
*/
public function getIsEditable(): bool
{
return $this->getIsWritable()
&& $this->resource instanceof AbstractFile
&& $this->resource->isTextFile();
}
/**
* @return bool
*/
public function getIsMetadataEditable(): bool
{
return $this->resource->isIndexed() && $this->getIsWritable() && $this->getBackendUser()->check('tables_modify', 'sys_file_metadata');
}
/**
* @return int
*/
public function getMetadataUid(): int
{
$uid = 0;
$method = 'getMetadata';
if (is_callable([$this->resource, $method])) {
$metadata = $this->resource->$method();
if (isset($metadata['uid'])) {
$uid = (int)$metadata['uid'];
}
}
return $uid;
}
/**
* @return string
*/
public function getName(): string
{
return $this->resource->getName();
}
/**
* @return string
*/
public function getPath(): string
{
$method = 'getReadablePath';
if (is_callable([$this->resource->getParentFolder(), $method])) {
return $this->resource->getParentFolder()->$method();
}
return '';
}
/**
* @return string|null
*/
public function getPublicUrl()
{
return PathUtility::getAbsoluteWebPath($this->resource->getPublicUrl() ?? '');
}
/**
* @return string
*/
public function getExtension(): string
{
return strtoupper($this->resource->getExtension());
}
/**
* @return string
*/
public function getIdentifier(): string
{
return $this->resource->getStorage()->getUid() . ':' . $this->resource->getIdentifier();
}
/**
* @return string
*/
public function getLastModified(): string
{
return BackendUtility::date($this->resource->getModificationTime());
}
/**
* @return string
*/
public function getSize(): string
{
return GeneralUtility::formatSize($this->resource->getSize(), htmlspecialchars($this->getLanguageService()->getLL('byteSizeUnits')));
}
/**
* @return bool
*/
public function getIsReadable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('read');
}
return false;
}
/**
* @return bool
*/
public function getIsWritable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('write');
}
return false;
}
/**
* @return bool
*/
public function getIsReplaceable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('replace');
}
return false;
}
/**
* @return bool
*/
public function getIsRenamable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('rename');
}
return false;
}
/**
* @return bool
*/
public function isCopyable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('copy');
}
return false;
}
/**
* @return bool
*/
public function isCuttable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('move');
}
return false;
}
/**
* @return bool
*/
public function getIsDeletable()
{
$method = 'checkActionPermission';
if (is_callable([$this->resource, $method])) {
return $this->resource->$method('delete');
}
return false;
}
/**
* @return bool
*/
public function isSelected()
{
$fullIdentifier = $this->getIdentifier();
$md5 = GeneralUtility::shortMD5($fullIdentifier);
/** @var Clipboard $clipboard */
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$clipboard->initializeClipboard();
$isSel = $clipboard->isSelected('_FILE', $md5);
if ($isSel) {
return $isSel;
}
return false;
}
/**
* @return bool
*/
public function getIsImage()
{
return $this->resource instanceof AbstractFile && $this->resource->isImage();
}
/**
* Fetch, cache and return the number of references of a file
*
* @return int
*/
public function getReferenceCount(): int
{
$uid = (int)$this->resource->getProperty('uid');
if ($uid <= 0) {
return 0;
}
if (!isset(static::$referenceCounts[$uid])) {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex');
$count = $queryBuilder->count('*')
->from('sys_refindex')
->where(
$queryBuilder->expr()->eq(
'ref_table',
$queryBuilder->createNamedParameter('sys_file', \PDO::PARAM_STR)
),
$queryBuilder->expr()->eq(
'ref_uid',
$queryBuilder->createNamedParameter($this->resource->getProperty('uid'), \PDO::PARAM_INT)
),
$queryBuilder->expr()->neq(
'tablename',
$queryBuilder->createNamedParameter('sys_file_metadata', \PDO::PARAM_STR)
)
)
->execute()
->fetchColumn();
static::$referenceCounts[$uid] = $count;
}
return static::$referenceCounts[$uid];
}
/**
* @param string $method
* @param array $arguments
*
* @return mixed
*/
public function __call($method, $arguments)
{
if (is_callable([$this->resource, $method])) {
$this->resource->$method(...$arguments);
}
return null;
}
/**
* @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
*/
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* @return \TYPO3\CMS\Core\Localization\LanguageService
*/
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
......@@ -26,12 +26,15 @@ use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\AbstractFile;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\InaccessibleFolder;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\Search\FileSearchDemand;
use TYPO3\CMS\Core\Resource\Utility\ListUtility;
use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -183,6 +186,8 @@ class FileList
*/
protected $uriBuilder;
protected string $searchTerm = '';
public function __construct()
{
// Setting the maximum length of the filenames to the user's settings or minimum 30 (= $this->fixedL)
......@@ -199,6 +204,7 @@ class FileList
$this->clipObj->fileMode = true;
$this->clipObj->initializeClipboard();
$this->resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$this->getLanguageService()->includeLLFile('EXT:filelist/Resources/Private/Language/locallang_mod_file_list.xlf');
$this->getLanguageService()->includeLLFile('EXT:core/Resources/Private/Language/locallang_common.xlf');
$this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->spaceIcon = '<span class="btn btn-default disabled">' . $this->iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render() . '</span>';
......@@ -256,42 +262,75 @@ class FileList
/**
* Returns a table with directories and files listed.
*
* @param FileSearchDemand|null $searchDemand
* @return string HTML-table
*/
public function getTable()
public function getTable(?FileSearchDemand $searchDemand = null): string
{
// @todo use folder methods directly when they support filters
$storage = $this->folderObject->getStorage();
$storage->resetFileAndFolderNameFiltersToDefault();
if ($searchDemand !== null) {
// Search currently only works for files
$folders = [];
// Find files by the given search demand
$files = iterator_to_array($this->folderObject->searchFiles($searchDemand));
// @todo Currently files, which got deleted in the file system, are still found.
// Therefore we have to ask their parent folder if it still contains the file.
$files = array_filter($files, static function (FileInterface $file): bool {
try {
if ($file->getParentFolder()->hasFile($file->getName())) {
return true;
}
} catch (ResourceDoesNotExistException $e) {
// Nothing to do, file does not longer exist in folder
}
return false;
});
// @todo We have to manually slice the search result, since it may
// contain invalid files, which were manually filtered out above.
// This should be fixed, so we can use the $firstResult and $maxResults
// properties of the search demand directly.
$this->totalItems = count($files);
$filesNum = $this->firstElementNumber + $this->iLimit > $this->totalItems
? $this->totalItems - $this->firstElementNumber
: $this->iLimit;
$files = array_slice($files, $this->firstElementNumber, $filesNum);
// Add special "Path" field for the search result
array_unshift($this->fieldArray, '_PATH_');
// Add search term so it can be added to return urls
$this->searchTerm = $searchDemand->getSearchTerm() ?? '';
} else {
// @todo use folder methods directly when they support filters
$storage = $this->folderObject->getStorage();
$storage->resetFileAndFolderNameFiltersToDefault();
// Only render the contents of a browsable storage
if (!$this->folderObject->getStorage()->isBrowsable()) {
return '';
}
try {
$foldersCount = $storage->countFoldersInFolder($this->folderObject);
$filesCount = $storage->countFilesInFolder($this->folderObject);
} catch (InsufficientFolderAccessPermissionsException $e) {
$foldersCount = 0;
$filesCount = 0;
}
// Only render the contents of a browsable storage
if (!$this->folderObject->getStorage()->isBrowsable()) {
return '';
}
try {
$foldersCount = $storage->countFoldersInFolder($this->folderObject);
$filesCount = $storage->countFilesInFolder($this->folderObject);
} catch (InsufficientFolderAccessPermissionsException $e) {
$foldersCount = 0;
$filesCount = 0;
}
if ($foldersCount <= $this->firstElementNumber) {
$foldersFrom = false;
$foldersNum = false;
} else {