Commit 9a406661 authored by Benni Mack's avatar Benni Mack Committed by Andreas Wolf
Browse files

[FEATURE] Integrate typeahead.js for LiveSearch

The change removes ExtJS LiveSearch and introduces typeahead.js
as an AMD module alternative. The ExtDirect connector is thus
removed and a regular AJAX handler is added.

The special live search commands (the special treatments starting with #)
are put in the correct extensions where the DB table is set up.

Resolves: #67580
Releases: master
Change-Id: I3f5473164297b2d9121179ffd019af10caec821a
Reviewed-on: http://review.typo3.org/40419


Reviewed-by: Wouter Wolters's avatarWouter Wolters <typo3@wouterwolters.nl>
Tested-by: Wouter Wolters's avatarWouter Wolters <typo3@wouterwolters.nl>
Reviewed-by: Andreas Wolf's avatarAndreas Wolf <andreas.wolf@typo3.org>
Tested-by: Andreas Wolf's avatarAndreas Wolf <andreas.wolf@typo3.org>
parent 8b1a352c
......@@ -76,6 +76,7 @@ module.exports = function(grunt) {
'placeholders.jquery.min.js': 'Placeholders.js/dist/placeholders.jquery.min.js',
'taboverride.min.js': 'taboverride/build/output/taboverride.min.js',
'bootstrap-slider.min.js': 'seiyria-bootstrap-slider/dist/bootstrap-slider.min.js',
'typeahead.js': 'typeahead.js/dist/typeahead.jquery.min.js',
/**
* copy needed files of scriptaculous
......
......@@ -240,7 +240,8 @@
margin-right: -@topbar-dropdown-padding;
padding: (@topbar-dropdown-padding / 2) @topbar-dropdown-padding;
}
.dropdown-intro {
.dropdown-intro,
.dropdown-info {
color: darken(@topbar-color, 20%);
margin-left: -@topbar-dropdown-padding;
margin-right: -@topbar-dropdown-padding;
......@@ -329,6 +330,8 @@
padding: 0;
margin: 0;
.form-group {
margin-top: 0;
margin-bottom: 0;
&:before {
content: "\f002";
font: normal normal normal 14px/1 FontAwesome;
......@@ -340,10 +343,10 @@
}
}
.form-control {
box-sizing: content-box;
background-color: @tobar-navigation-search-bg;
color: @topbar-navigation-color;
height: @topbar-height - 27px;
height: @topbar-height;
width: 300px;
padding: 14px 30px 13px 35px;
border: none;
border-left: 1px solid lighten(@topbar-navigation-border-color, 10%);
......@@ -354,7 +357,7 @@
}
&:focus {
outline: none;
border-left-color: lighten(@topbar-navigation-border-color, 15%);
border-left-color: lighten(@topbar-navigation-border-color, 25%);
background-color: @tobar-navigation-search-focus-bg;
.box-shadow(none);
}
......@@ -367,81 +370,14 @@
margin-top: -8px;
}
}
}
// Livesearch
.live-search-list {
.typo3-topbar-navigation-items .dropdown-menu();
right: auto;
padding: 0;
.x-toolbar {
padding: 0;
border: none;
background: transparent;
}
.x-combo-list-hd,
.x-combo-list-inner,
.x-combo-list-ft {
border: none;
background: transparent;
color: @topbar-dropdown-color;
padding: @topbar-dropdown-padding;
}
.x-combo-list-hd {
background-color: lighten(@topbar-dropdown-bg,3%);
border-top: 0;
border-bottom: 1px solid @topbar-navigation-border-color;
}
.x-combo-list-ft {
padding-top: 0;
border-bottom: 0;
}
.x-combo-list-inner {
padding-right: 0;
border-top: 1px solid lighten(@topbar-navigation-border-color, 10%);
}
.x-btn {
background: none;
border: none;
color: inherit;
.x-btn-tl,
.x-btn-tc,
.x-btn-tr,
.x-btn-ml,
.x-btn-mr,
.x-btn-bl,
.x-btn-bc,
.x-btn-br {
display: none;
}
button {
.btn();
.btn-sm();
.btn-default();
height: auto!important;
}
}
.search-item-type {
padding: 5px 20px 5px 0;
white-space: nowrap;
.dropdown-menu {
left: auto!important; // Needs to be important to override inline styes of typeahead
width: 350px;
}
.search-item-title {
border-radius: 2px 0 0 2px;
padding: 5px 20px 5px 10px;
&.x-combo-selected {
border: none!important;
background-color: lighten(@topbar-dropdown-bg, 10%);
.dropdown-list-link {
max-width: none;
.typeahead-highlight {
font-weight: normal;
}
}
}
.search-list-help-content {
padding: @topbar-dropdown-padding;
strong {
display: block;
margin-bottom: 0.5em;
}
p {
margin-top: 0.5em;
margin-bottom: 0;
}
}
......@@ -40,6 +40,7 @@
"imagesloaded": "3.1.8",
"Placeholders.js": "4.0.1",
"taboverride": "4.0.2",
"seiyria-bootstrap-slider": "4.8.1"
"seiyria-bootstrap-slider": "4.8.1",
"typeahead.js": "0.11.1"
}
}
......@@ -35,7 +35,7 @@ class HelpToolbarItem implements ToolbarItemInterface {
public function __construct() {
/** @var BackendModuleRepository $backendModuleRepository */
$backendModuleRepository = GeneralUtility::makeInstance(BackendModuleRepository::class);
/** @var \TYPO3\CMS\Backend\Domain\Model\Module\BackendModule $userModuleMenu */
/** @var \TYPO3\CMS\Backend\Domain\Model\Module\BackendModule $helpModuleMenu */
$helpModuleMenu = $backendModuleRepository->findByModuleName('help');
if ($helpModuleMenu && $helpModuleMenu->getChildren()->count() > 0) {
$this->helpModuleMenu = $helpModuleMenu;
......
......@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Backend\Backend\ToolbarItems;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Backend\Domain\Repository\Module\BackendModuleRepository;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\Module\ModuleLoader;
use TYPO3\CMS\Core\Page\PageRenderer;
......@@ -28,25 +29,23 @@ class LiveSearchToolbarItem implements ToolbarItemInterface {
* Constructor
*/
public function __construct() {
$this->getPageRenderer()->addJsFile('sysext/backend/Resources/Public/JavaScript/livesearch.js');
$this->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Backend/LiveSearch');
}
/**
* Checks whether the user has access to this toolbar item
* Checks whether the user has access to this toolbar item,
* only allowed when the list module is available
*
* @return bool TRUE if user has access, FALSE if not
*/
public function checkAccess() {
$access = FALSE;
// Loads the backend modules available for the logged in user.
$loadModules = GeneralUtility::makeInstance(ModuleLoader::class);
$loadModules->observeWorkspaces = TRUE;
$loadModules->load($GLOBALS['TBE_MODULES']);
/** @var BackendModuleRepository $backendModuleRepository */
$backendModuleRepository = GeneralUtility::makeInstance(BackendModuleRepository::class);
/** @var \TYPO3\CMS\Backend\Domain\Model\Module\BackendModule $listModule */
// Live search is heavily dependent on the list module and only available when that module is.
if (is_array($loadModules->modules['web']['sub']['list'])) {
$access = TRUE;
}
return $access;
$listModule = $backendModuleRepository->findByModuleName('web_list');
return $listModule !== NULL;
}
/**
......@@ -56,9 +55,9 @@ class LiveSearchToolbarItem implements ToolbarItemInterface {
*/
public function getItem() {
return '
<form class="typo3-topbar-navigation-search live-search-wrapper" role="search">
<form class="typo3-topbar-navigation-search t3js-topbar-navigation-search live-search-wrapper" role="search">
<div class="form-group">
<input type="text" class="form-control" placeholder="Search" id="live-search-box">
<input type="text" class="form-control t3js-topbar-navigation-search-field" placeholder="Search" id="live-search-box" autocomplete="off">
</div>
</form>
';
......
<?php
namespace TYPO3\CMS\Backend\Search\LiveSearch\ExtDirect;
namespace TYPO3\CMS\Backend\Controller;
/*
* This file is part of the TYPO3 CMS project.
......@@ -14,61 +14,47 @@ namespace TYPO3\CMS\Backend\Search\LiveSearch\ExtDirect;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* ExtDirect Class for handling backend live search.
* Returns the results for any live searches, e.g. in the toolbar
*/
class LiveSearchDataProvider {
class LiveSearchController {
/**
* @var array
*/
protected $searchResults = array(
'pageJump' => '',
'searchItems' => array()
);
/**
* @var \TYPO3\CMS\Backend\Search\LiveSearch\LiveSearch
*/
protected $liveSearch = NULL;
protected $searchResults = array();
/**
* @var \TYPO3\CMS\Backend\Search\LiveSearch\QueryParser
* Processes all AJAX calls and sends back a JSON object
*
* @param array $parameters
* @param \TYPO3\CMS\Core\Http\AjaxRequestHandler $ajaxRequestHandler
*/
protected $queryParser = NULL;
public function liveSearchAction($parameters, \TYPO3\CMS\Core\Http\AjaxRequestHandler $ajaxRequestHandler) {
$queryString = GeneralUtility::_GET('q');
$liveSearch = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Search\LiveSearch\LiveSearch::class);
$queryParser = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Search\LiveSearch\QueryParser::class);
/**
* Initialize the live search
*/
public function __construct() {
$this->liveSearch = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Search\LiveSearch\LiveSearch::class);
$this->queryParser = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Search\LiveSearch\QueryParser::class);
}
/**
* @param stdClass $command
* @return array
*/
public function find($command) {
$this->liveSearch->setStartCount($command->start);
$this->liveSearch->setLimitCount($command->limit);
$this->liveSearch->setQueryString($command->query);
$searchResults = array();
$liveSearch->setQueryString($queryString);
// Jump & edit - find page and retrieve an edit link (this is only for pages
if ($this->queryParser->isValidPageJump($command->query)) {
$this->searchResults['pageJump'] = $this->liveSearch->findPage($command->query);
$commandQuery = $this->queryParser->getCommandForPageJump($command->query);
if ($queryParser->isValidPageJump($queryString)) {
$searchResults[] = array_merge($liveSearch->findPage($queryString), array('type' => 'pageJump'));
$commandQuery = $queryParser->getCommandForPageJump($queryString);
if ($commandQuery) {
$command->query = $commandQuery;
$queryString = $commandQuery;
}
}
// Search through the database and find records who match to the given search string
$resultArray = $this->liveSearch->find($command->query);
$resultArray = $liveSearch->find($queryString);
foreach ($resultArray as $resultFromTable) {
foreach ($resultFromTable as $item) {
$this->searchResults['searchItems'][] = $item;
$searchResults[] = $item;
}
}
return $this->searchResults;
$ajaxRequestHandler->setContent($searchResults);
$ajaxRequestHandler->setContentFormat('json');
}
}
......@@ -212,9 +212,12 @@ class LiveSearch {
$collect[] = array(
'id' => $tableName . ':' . $row['uid'],
'pageId' => $tableName === 'pages' ? $row['uid'] : $row['pid'],
'recordTitle' => $isFirst ? $this->getRecordTitlePrep($this->getTitleOfCurrentRecordType($tableName), self::GROUP_TITLE_MAX_LENGTH) : '',
'table' => array(
'title' => $this->getTitleOfCurrentRecordType($tableName),
'name' => $tableName,
),
'iconHTML' => IconUtility::getSpriteIconForRecord($tableName, $row, array('title' => 'id=' . $row['uid'] . ', pid=' . $row['pid'])),
'title' => $this->getRecordTitlePrep(BackendUtility::getRecordTitle($tableName, $row), self::RECORD_TITLE_MAX_LENGTH),
'title' => BackendUtility::getRecordTitle($tableName, $row),
'editLink' => $this->getEditLink($tableName, $row)
);
$isFirst = FALSE;
......@@ -435,7 +438,6 @@ class LiveSearch {
* @return string Comma separated list of uids
*/
protected function getAvailablePageIds($id, $depth) {
$idList = '';
$tree = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Tree\View\PageTreeView::class);
$tree->init('AND ' . $this->userPermissions);
$tree->makeHTML = 0;
......
......@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Backend\Search\LiveSearch;
/**
* Class for parsing query parameters in backend live search.
* Detects searches for #pages:23 or #content:mycontent
*/
class QueryParser {
......@@ -46,9 +47,7 @@ class QueryParser {
* @return string Command name
*/
protected function extractKeyFromQuery($query) {
$keyAndValue = substr($query, 1);
$key = explode(':', $keyAndValue);
$this->commandKey = $key[0];
list($this->commandKey) = explode(':', substr($query, 1));
}
/**
......@@ -71,7 +70,7 @@ class QueryParser {
public function getTableNameFromCommand($query) {
$tableName = '';
$this->extractKeyFromQuery($query);
if (is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch']) && array_key_exists($this->commandKey, $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'])) {
if (array_key_exists($this->commandKey, $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'])) {
$tableName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch'][$this->commandKey];
}
return $tableName;
......
/*
* 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!
*/
/**
* Global search to deal with everything in the backend that is search-related
*/
define('TYPO3/CMS/Backend/LiveSearch', ['jquery', 'typeaheadjs'], function ($) {
var containerSelector = '.t3js-topbar-navigation-search';
var searchFieldSelector = '.t3js-topbar-navigation-search-field';
var url = TYPO3.settings.ajaxUrls['LiveSearch'] + '&q=';
var cssPrefix = 'typeahead';
var initialize = function() {
var $searchField = $(searchFieldSelector);
var searchCall = function(query, syncResults, asyncResults) {
$.ajax({
url: url + rawurlencode(query.toString()),
cache: false,
success: function(results) {
asyncResults(results);
}
});
};
$searchField.typeahead({
hint: false,
highlight: true,
limit: 10,
minLength: 2,
classNames: {
wrapper: cssPrefix,
input: cssPrefix + '-input',
hint: cssPrefix + '-hint',
menu: cssPrefix + '-menu dropdown-menu',
dataset: 'dropdown-list ' + cssPrefix + '-dataset',
suggestion: cssPrefix + '-suggestion',
empty: cssPrefix + '-empty',
open: cssPrefix + '-open',
cursor: cssPrefix + '-cursor',
highlight: cssPrefix + '-highlight'
}
}, {
name: 'databaseRecords',
source: searchCall,
limit: 1000, // this needs to be very high, limiter is on PHP side
display: function() {
return $searchField.val();
},
templates: {
empty: '<div class="dropdown-info typeahead-search-empty-message">' + TYPO3.LLL.liveSearch.listEmptyText + '</div>'
+ '<div class="search-list-help-content"><strong>' + TYPO3.LLL.liveSearch.helpTitle + '</strong>'
+ '<p>' + TYPO3.LLL.liveSearch.helpDescription + '<br>' + TYPO3.LLL.liveSearch.helpDescriptionPages + '</p>'
+ '</div>'
,
suggestion: function(result) {
return '' +
'<div data-table-name="' + result.table.name + '" data-table-title="' + result.table.title + '">' +
'<a class="dropdown-list-link" href="#" data-pageid="' + result.pageId + '" data-target="' + result.editLink + '">' +
result.iconHTML + ' ' + result.title +
'</a>' +
'</div>';
},
footer: '' +
'<div>' +
'<a href="#" class="btn btn-primary pull-right t3js-live-search-show-all">' +
TYPO3.LLL.liveSearch.showAllResults +
'</a>' +
'</div>'
}
}).bind('typeahead:render', function(e) {
var suggestions = [].slice.call(arguments, 1);
var lastTable = '';
$.each(suggestions, function(){
if (lastTable !== this.table.name) {
lastTable = this.table.name;
var $dataSet = $(containerSelector + ' [data-table-name=' + this.table.name + ']');
$dataSet.first().before('<div class="dropdown-header">' + this.table.title + '</div>');
$dataSet.last().after('<div class="divider"></div>');
}
});
});
// set up the events
$(containerSelector).on('click', '.t3js-live-search-show-all', function() {
TYPO3.ModuleMenu.App.showModule('web_list', 'id=0&search_levels=4&search_field=' + $searchField.val());
$searchField.typeahead('close');
}).on('click', '.typeahead-suggestion a', function() {
jump($(this).data('target'), 'web_list', 'web', $(this).data('pageid'));
$searchField.typeahead('close');
});
$searchField.on('typeahead:change', function() {
$searchField.typeahead('close');
});
};
$(document).ready(function() {
initialize();
});
});
/*
* 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!
*/
Ext.namespace('TYPO3');
TYPO3.BackendLiveSearch = Ext.extend(Ext.form.ComboBox, {
autoSelect: false,
ctCls: 'live-search-results',
dataProvider: null,
searchResultsPid : 0,
displayField: 'title',
emptyText: null,
enableKeyEvents: true,
helpTitle: null,
hideTrigger: true,
itemSelector: 'div.search-item-title',
listAlign : 'tr-br',
listClass: 'live-search-list',
listEmptyText: null,
listWidth: 400,
listHovered: false,
loadingText: null,
minChars: 1,
resizable: false,
title: null,
triggerClass : 'x-form-clear-trigger',
triggerConfig: '<span class="t3-icon fa fa-remove"></span>',
onTriggerClick: function() {
// Empty the form field, give it focus, and collapse the results
this.reset(this);
this.focus();
this.collapse();
},
tpl: new Ext.XTemplate(
'<table border="0" cellspacing="0">',
'<tpl for=".">',
'<tr class="search-item">',
'<td class="search-item-type">{recordTitle}</td>',
'<td class="search-item-content" width="95%">',
'<div class="search-item-title">{iconHTML} {title}</div>',
'</td>',
'</tr>',
'</tpl>',
'</table>'
),
dataReader : new Ext.data.JsonReader({
idProperty : 'type',
root : 'searchItems',
fields : [
{name: 'recordTitle'},
{name: 'pageId'},
{name: 'id'},
{name: 'iconHTML'},
{name: 'title'},
{name: 'editLink'}
]
}),
listeners: {
select : {
scope: this,
fn: function (combo, record, index) {
jump(record.data.editLink, 'web_list', 'web', record.data.pageId);
}
},
focus : {
fn: function() {
if (this.getValue() == this.emptyText) {
this.reset(this);
}
}
},
specialkey : function (field, e) {
if (e.getKey() == e.RETURN || e.getKey() == e.ENTER) {
if (this.dataReader.jsonData.pageJump != '') {
jump(this.dataReader.jsonData.pageJump, 'web_list', 'web');
} else {
TYPO3.ModuleMenu.App.showModule('web_list', this.getSearchResultsUrl(this.getValue()));
}
}
},
keyup : function() {
if ((this.getValue() == this.emptyText) || (this.getValue() == '')) {
this.setHideTrigger(true);
} else {
this.setHideTrigger(false);
}
}
},
/**
* Initializes the component.
*/
initComponent: function() {
this.store = new Ext.data.DirectStore({
directFn: this.dataProvider.find,
reader: this.dataReader
});
TYPO3.BackendLiveSearch.superclass.initComponent.apply(this, arguments);
},
restrictHeight : function(){
this.innerList.dom.style.height = '';
this.innerList.dom.style.width = '';
this.list.beginUpdate();
this.list.setHeight('auto');