[!!!][FEATURE] Introduce PSR-7-based Routing for Backend AJAX Requests
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Form / Wizard / SuggestWizard.php
1 <?php
2 namespace TYPO3\CMS\Backend\Form\Wizard;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use Psr\Http\Message\ResponseInterface;
18 use Psr\Http\Message\ServerRequestInterface;
19 use TYPO3\CMS\Backend\Utility\BackendUtility;
20 use TYPO3\CMS\Core\Imaging\Icon;
21 use TYPO3\CMS\Core\Imaging\IconFactory;
22 use TYPO3\CMS\Core\Utility\ArrayUtility;
23 use TYPO3\CMS\Core\Utility\GeneralUtility;
24 use TYPO3\CMS\Core\Utility\MathUtility;
25 use TYPO3\CMS\Lang\LanguageService;
26
27 /**
28 * Wizard for rendering an AJAX selector for records
29 */
30 class SuggestWizard {
31
32 /**
33 * Renders an ajax-enabled text field. Also adds required JS
34 *
35 * @param string $fieldname The fieldname in the form
36 * @param string $table The table we render this selector for
37 * @param string $field The field we render this selector for
38 * @param array $row The row which is currently edited
39 * @param array $config The TSconfig of the field
40 * @return string The HTML code for the selector
41 */
42 public function renderSuggestSelector($fieldname, $table, $field, array $row, array $config) {
43 /** @var $iconFactory IconFactory */
44 $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
45 $languageService = $this->getLanguageService();
46 $isFlexFormField = $GLOBALS['TCA'][$table]['columns'][$field]['config']['type'] === 'flex';
47 if ($isFlexFormField) {
48 $fieldPattern = 'data[' . $table . '][' . $row['uid'] . '][';
49 $flexformField = str_replace($fieldPattern, '', $fieldname);
50 $flexformField = substr($flexformField, 0, -1);
51 $field = str_replace(array(']['), '|', $flexformField);
52 }
53
54 // Get minimumCharacters from TCA
55 $minChars = 0;
56 if (isset($config['fieldConf']['config']['wizards']['suggest']['default']['minimumCharacters'])) {
57 $minChars = (int)$config['fieldConf']['config']['wizards']['suggest']['default']['minimumCharacters'];
58 }
59 // Overwrite it with minimumCharacters from TSConfig (TCEFORM) if given
60 if (isset($config['fieldTSConfig']['suggest.']['default.']['minimumCharacters'])) {
61 $minChars = (int)$config['fieldTSConfig']['suggest.']['default.']['minimumCharacters'];
62 }
63 $minChars = $minChars > 0 ? $minChars : 2;
64
65 // fetch the TCA field type to hand it over to the JS class
66 $type = '';
67 if (isset($config['fieldConf']['config']['type'])) {
68 $type = $config['fieldConf']['config']['type'];
69 }
70
71 $jsRow = '';
72 if ($isFlexFormField || !MathUtility::canBeInterpretedAsInteger($row['uid'])) {
73 // Ff we have a new record, we hand that row over to JS.
74 // This way we can properly retrieve the configuration of our wizard
75 // if it is shown in a flexform
76 $jsRow = serialize($row);
77 }
78
79 $selector = '
80 <div class="autocomplete t3-form-suggest-container">
81 <div class="input-group">
82 <span class="input-group-addon">' . $iconFactory->getIcon('actions-search', Icon::SIZE_SMALL) . '</span>
83 <input type="search" class="t3-form-suggest form-control"
84 placeholder="' . $languageService->sL('LLL:EXT:lang/locallang_core.xlf:labels.findRecord') . '"
85 data-fieldname="' . $fieldname . '"
86 data-table="' . $table . '"
87 data-field="' . $field . '"
88 data-uid="' . $row['uid'] . '"
89 data-pid="' . $row['pid'] . '"
90 data-fieldtype="' . $type . '"
91 data-minchars="' . $minChars .'"
92 data-recorddata="' . htmlspecialchars($jsRow) .'"
93 />
94 </div>
95 </div>';
96
97 return $selector;
98 }
99
100 /**
101 * Search a data structure array recursively -- including within nested
102 * (repeating) elements -- for a particular field config.
103 *
104 * @param array $dataStructure The data structure
105 * @param string $fieldName The field name
106 * @return array
107 */
108 protected function getNestedDsFieldConfig(array $dataStructure, $fieldName) {
109 $fieldConfig = array();
110 $elements = $dataStructure['ROOT']['el'] ? $dataStructure['ROOT']['el'] : $dataStructure['el'];
111 if (is_array($elements)) {
112 foreach ($elements as $k => $ds) {
113 if ($k === $fieldName) {
114 $fieldConfig = $ds['TCEforms']['config'];
115 break;
116 } elseif (isset($ds['el'][$fieldName]['TCEforms']['config'])) {
117 $fieldConfig = $ds['el'][$fieldName]['TCEforms']['config'];
118 break;
119 } else {
120 $fieldConfig = $this->getNestedDsFieldConfig($ds, $fieldName);
121 }
122 }
123 }
124 return $fieldConfig;
125 }
126
127 /**
128 * Ajax handler for the "suggest" feature in FormEngine.
129 *
130 * @param ServerRequestInterface $request
131 * @param ResponseInterface $response
132 * @return ResponseInterface
133 */
134 public function searchAction(ServerRequestInterface $request, ResponseInterface $response) {
135 $parsedBody = $request->getParsedBody();
136 $queryParams = $request->getQueryParams();
137
138 // Get parameters from $_GET/$_POST
139 $search = isset($parsedBody['value']) ? $parsedBody['value'] : $queryParams['value'];
140 $table = isset($parsedBody['table']) ? $parsedBody['table'] : $queryParams['table'];
141 $field = isset($parsedBody['field']) ? $parsedBody['field'] : $queryParams['field'];
142 $uid = (int)(isset($parsedBody['uid']) ? $parsedBody['uid'] : $queryParams['uid']);
143 $pageId = (int)(isset($parsedBody['pid']) ? $parsedBody['pid'] : $queryParams['pid']);
144 $newRecordRow = isset($parsedBody['newRecordRow']) ? $parsedBody['newRecordRow'] : $queryParams['newRecordRow'];
145 // If the $uid is numeric, we have an already existing element, so get the
146 // TSconfig of the page itself or the element container (for non-page elements)
147 // otherwise it's a new element, so use given id of parent page (i.e., don't modify it here)
148 if (is_numeric($uid)) {
149 $row = BackendUtility::getRecord($table, $uid);
150 if ($table === 'pages') {
151 $pageId = $uid;
152 } else {
153 $pageId = $row['pid'];
154 }
155 } else {
156 $row = unserialize($newRecordRow);
157 }
158 $TSconfig = BackendUtility::getPagesTSconfig($pageId);
159 $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
160 $this->overrideFieldNameAndConfigurationForFlexform($table, $field, $row, $fieldConfig);
161
162 $wizardConfig = $fieldConfig['wizards']['suggest'];
163
164 $queryTables = $this->getTablesToQueryFromFieldConfiguration($fieldConfig);
165 $whereClause = $this->getWhereClause($fieldConfig);
166
167 $resultRows = array();
168
169 // fetch the records for each query table. A query table is a table from which records are allowed to
170 // be added to the TCEForm selector, originally fetched from the "allowed" config option in the TCA
171 foreach ($queryTables as $queryTable) {
172 // if the table does not exist, skip it
173 if (!is_array($GLOBALS['TCA'][$queryTable]) || empty($GLOBALS['TCA'][$queryTable])) {
174 continue;
175 }
176
177 $config = $this->getConfigurationForTable($queryTable, $wizardConfig, $TSconfig, $table, $field);
178
179 // process addWhere
180 if (!isset($config['addWhere']) && $whereClause) {
181 $config['addWhere'] = $whereClause;
182 }
183 if (isset($config['addWhere'])) {
184 $replacement = array(
185 '###THIS_UID###' => (int)$uid,
186 '###CURRENT_PID###' => (int)$pageId
187 );
188 if (isset($TSconfig['TCEFORM.'][$table . '.'][$field . '.'])) {
189 $fieldTSconfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.'];
190 if (isset($fieldTSconfig['PAGE_TSCONFIG_ID'])) {
191 $replacement['###PAGE_TSCONFIG_ID###'] = (int)$fieldTSconfig['PAGE_TSCONFIG_ID'];
192 }
193 if (isset($fieldTSconfig['PAGE_TSCONFIG_IDLIST'])) {
194 $replacement['###PAGE_TSCONFIG_IDLIST###'] = $GLOBALS['TYPO3_DB']->cleanIntList($fieldTSconfig['PAGE_TSCONFIG_IDLIST']);
195 }
196 if (isset($fieldTSconfig['PAGE_TSCONFIG_STR'])) {
197 $replacement['###PAGE_TSCONFIG_STR###'] = $GLOBALS['TYPO3_DB']->quoteStr($fieldTSconfig['PAGE_TSCONFIG_STR'], $fieldConfig['foreign_table']);
198 }
199 }
200 $config['addWhere'] = strtr(' ' . $config['addWhere'], $replacement);
201 }
202
203 // instantiate the class that should fetch the records for this $queryTable
204 $receiverClassName = $config['receiverClass'];
205 if (!class_exists($receiverClassName)) {
206 $receiverClassName = SuggestWizardDefaultReceiver::class;
207 }
208 $receiverObj = GeneralUtility::makeInstance($receiverClassName, $queryTable, $config);
209 $params = array('value' => $search);
210 $rows = $receiverObj->queryTable($params);
211 if (empty($rows)) {
212 continue;
213 }
214 $resultRows = $rows + $resultRows;
215 unset($rows);
216 }
217
218 // Limit the number of items in the result list
219 $maxItems = isset($config['maxItemsInResultList']) ? $config['maxItemsInResultList'] : 10;
220 $maxItems = min(count($resultRows), $maxItems);
221
222 $listItems = $this->createListItemsFromResultRow($resultRows, $maxItems);
223
224 $response->getBody()->write(json_encode($listItems));
225 return $response;
226 }
227
228 /**
229 * Returns TRUE if a table has been marked as hidden in the configuration
230 *
231 * @param array $tableConfig
232 * @return bool
233 */
234 protected function isTableHidden(array $tableConfig) {
235 return !$tableConfig['ctrl']['hideTable'];
236 }
237
238 /**
239 * Checks if the current backend user is allowed to access the given table, based on the ctrl-section of the
240 * table's configuration array (TCA) entry.
241 *
242 * @param array $tableConfig
243 * @return bool
244 */
245 protected function currentBackendUserMayAccessTable(array $tableConfig) {
246 if ($GLOBALS['BE_USER']->isAdmin()) {
247 return TRUE;
248 }
249
250 // If the user is no admin, they may not access admin-only tables
251 if ($tableConfig['ctrl']['adminOnly']) {
252 return FALSE;
253 }
254
255 // allow access to root level pages if security restrictions should be bypassed
256 return !$tableConfig['ctrl']['rootLevel'] || $tableConfig['ctrl']['security']['ignoreRootLevelRestriction'];
257 }
258
259 /**
260 * Checks if the query comes from a Flexform element and if yes, resolves the field configuration from the Flexform
261 * data structure.
262 *
263 * @param string $table
264 * @param string &$field The field identifier, either a simple table field or a Flexform field path separated with |
265 * @param array $row The row we're dealing with; optional (only required for Flexform records)
266 * @param array|NULL &$fieldConfig
267 */
268 protected function overrideFieldNameAndConfigurationForFlexform($table, &$field, array $row, &$fieldConfig) {
269 // check if field is a flexform reference
270 if (strpos($field, '|') === FALSE) {
271 $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
272 } else {
273 $parts = explode('|', $field);
274
275 if ($GLOBALS['TCA'][$table]['columns'][$parts[0]]['config']['type'] !== 'flex') {
276 return;
277 }
278
279 $flexfieldTCAConfig = $GLOBALS['TCA'][$table]['columns'][$parts[0]]['config'];
280 // @todo: should be done via data preparation, resolveAllSheetsInDS() can be deprecated then
281 $flexformDSArray = BackendUtility::getFlexFormDS($flexfieldTCAConfig, $row, $table);
282 $flexformDSArray = GeneralUtility::resolveAllSheetsInDS($flexformDSArray);
283 $flexformElement = $parts[count($parts) - 2];
284 $continue = TRUE;
285 foreach ($flexformDSArray as $sheet) {
286 foreach ($sheet as $_ => $dataStructure) {
287 $fieldConfig = $this->getNestedDsFieldConfig($dataStructure, $flexformElement);
288 if (!empty($fieldConfig)) {
289 $continue = FALSE;
290 break;
291 }
292 }
293 if (!$continue) {
294 break;
295 }
296 }
297 // Flexform field name levels are separated with | instead of encapsulation in [];
298 // reverse this here to be compatible with regular field names.
299 $field = str_replace('|', '][', $field);
300 }
301 }
302
303 /**
304 * Returns the configuration for the suggest wizard for the given table. This does multiple overlays from the
305 * TSconfig.
306 *
307 * @param string $queryTable The table to query
308 * @param array $wizardConfig The configuration for the wizard as configured in the data structure
309 * @param array $TSconfig The TSconfig array of the current page
310 * @param string $table The table where the wizard is used
311 * @param string $field The field where the wizard is used
312 * @return array
313 */
314 protected function getConfigurationForTable($queryTable, array $wizardConfig, array $TSconfig, $table, $field) {
315 $config = (array)$wizardConfig['default'];
316
317 if (is_array($wizardConfig[$queryTable])) {
318 ArrayUtility::mergeRecursiveWithOverrule($config, $wizardConfig[$queryTable]);
319 }
320 $globalSuggestTsConfig = $TSconfig['TCEFORM.']['suggest.'];
321 $currentFieldSuggestTsConfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.']['suggest.'];
322
323 // merge the configurations of different "levels" to get the working configuration for this table and
324 // field (i.e., go from the most general to the most special configuration)
325 if (is_array($globalSuggestTsConfig['default.'])) {
326 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig['default.']);
327 }
328
329 if (is_array($globalSuggestTsConfig[$queryTable . '.'])) {
330 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig[$queryTable . '.']);
331 }
332
333 // use $table instead of $queryTable here because we overlay a config
334 // for the input-field here, not for the queried table
335 if (is_array($currentFieldSuggestTsConfig['default.'])) {
336 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig['default.']);
337 }
338
339 if (is_array($currentFieldSuggestTsConfig[$queryTable . '.'])) {
340 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig[$queryTable . '.']);
341 }
342
343 return $config;
344 }
345
346 /**
347 * Creates a list of <li> elements from a list of results returned by the receiver.
348 *
349 * @param array $resultRows
350 * @param int $maxItems
351 * @param string $rowIdSuffix
352 * @return array
353 */
354 protected function createListItemsFromResultRow(array $resultRows, $maxItems) {
355 if (empty($resultRows)) {
356 return array();
357 }
358 $listItems = array();
359
360 // traverse all found records and sort them
361 $rowsSort = array();
362 foreach ($resultRows as $key => $row) {
363 $rowsSort[$key] = $row['text'];
364 }
365 asort($rowsSort);
366 $rowsSort = array_keys($rowsSort);
367
368 // put together the selector entries
369 for ($i = 0; $i < $maxItems; ++$i) {
370 $listItems[] = $resultRows[$rowsSort[$i]];
371 }
372 return $listItems;
373 }
374
375 /**
376 * Checks the given field configuration for the tables that should be used for querying and returns them as an
377 * array.
378 *
379 * @param array $fieldConfig
380 * @return array
381 */
382 protected function getTablesToQueryFromFieldConfiguration(array $fieldConfig) {
383 $queryTables = array();
384
385 if (isset($fieldConfig['allowed'])) {
386 if ($fieldConfig['allowed'] !== '*') {
387 // list of allowed tables
388 $queryTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed']);
389 } else {
390 // all tables are allowed, if the user can access them
391 foreach ($GLOBALS['TCA'] as $tableName => $tableConfig) {
392 if (!$this->isTableHidden($tableConfig) && $this->currentBackendUserMayAccessTable($tableConfig)) {
393 $queryTables[] = $tableName;
394 }
395 }
396 unset($tableName, $tableConfig);
397 }
398 } elseif (isset($fieldConfig['foreign_table'])) {
399 // use the foreign table
400 $queryTables = array($fieldConfig['foreign_table']);
401 }
402
403 return $queryTables;
404 }
405
406 /**
407 * Returns the SQL WHERE clause to use for querying records. This is currently only relevant if a foreign_table
408 * is configured and should be used; it could e.g. be used to limit to a certain subset of records from the
409 * foreign table
410 *
411 * @param array $fieldConfig
412 * @return string
413 */
414 protected function getWhereClause(array $fieldConfig) {
415 if (!isset($fieldConfig['foreign_table'])) {
416 return '';
417 }
418
419 // strip ORDER BY clause
420 return trim(preg_replace('/ORDER[[:space:]]+BY.*/i', '', $fieldConfig['foreign_table_where']));
421 }
422
423 /**
424 * @return LanguageService
425 */
426 protected function getLanguageService() {
427 return $GLOBALS['LANG'];
428 }
429
430 }