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