[BUGFIX] Suggest wizard in FlexForms for 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', true) . '"
86 data-fieldname="' . htmlspecialchars($fieldname) . '"
87 data-table="' . htmlspecialchars($table) . '"
88 data-field="' . htmlspecialchars($field) . '"
89 data-uid="' . htmlspecialchars($row['uid']) . '"
90 data-pid="' . (int)$row['pid'] . '"
91 data-fieldtype="' . htmlspecialchars($type) . '"
92 data-minchars="' . (int)$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 if (substr($row['uid'], 0, 3) === 'NEW') {
288 // We have to cleanup record information as they are coming from FormEngines DataProvider
289 $pointerFields = GeneralUtility::trimExplode(',', $flexfieldTCAConfig['ds_pointerField']);
290 foreach ($pointerFields as $pointerField) {
291 if (is_array($row[$pointerField])) {
292 $row[$pointerField] = $row[$pointerField][0];
293 }
294 }
295 }
296 $flexformDSArray = BackendUtility::getFlexFormDS($flexfieldTCAConfig, $row, $table, $parts[0]);
297 $flexformDSArray = GeneralUtility::resolveAllSheetsInDS($flexformDSArray);
298 $flexformElement = $parts[count($parts) - 2];
299 foreach ($flexformDSArray as $sheet) {
300 foreach ($sheet as $_ => $dataStructure) {
301 $fieldConfig = $this->getNestedDsFieldConfig($dataStructure, $flexformElement);
302 if (!empty($fieldConfig)) {
303 break(2);
304 }
305 }
306 }
307 // Flexform field name levels are separated with | instead of encapsulation in [];
308 // reverse this here to be compatible with regular field names.
309 $field = str_replace('|', '][', $field);
310 }
311 }
312
313 /**
314 * Returns the configuration for the suggest wizard for the given table. This does multiple overlays from the
315 * TSconfig.
316 *
317 * @param string $queryTable The table to query
318 * @param array $wizardConfig The configuration for the wizard as configured in the data structure
319 * @param array $TSconfig The TSconfig array of the current page
320 * @param string $table The table where the wizard is used
321 * @param string $field The field where the wizard is used
322 * @return array
323 */
324 protected function getConfigurationForTable($queryTable, array $wizardConfig, array $TSconfig, $table, $field)
325 {
326 $config = (array)$wizardConfig['default'];
327
328 if (is_array($wizardConfig[$queryTable])) {
329 ArrayUtility::mergeRecursiveWithOverrule($config, $wizardConfig[$queryTable]);
330 }
331 $globalSuggestTsConfig = $TSconfig['TCEFORM.']['suggest.'];
332 $currentFieldSuggestTsConfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.']['suggest.'];
333
334 // merge the configurations of different "levels" to get the working configuration for this table and
335 // field (i.e., go from the most general to the most special configuration)
336 if (is_array($globalSuggestTsConfig['default.'])) {
337 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig['default.']);
338 }
339
340 if (is_array($globalSuggestTsConfig[$queryTable . '.'])) {
341 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig[$queryTable . '.']);
342 }
343
344 // use $table instead of $queryTable here because we overlay a config
345 // for the input-field here, not for the queried table
346 if (is_array($currentFieldSuggestTsConfig['default.'])) {
347 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig['default.']);
348 }
349
350 if (is_array($currentFieldSuggestTsConfig[$queryTable . '.'])) {
351 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig[$queryTable . '.']);
352 }
353
354 return $config;
355 }
356
357 /**
358 * Creates a list of <li> elements from a list of results returned by the receiver.
359 *
360 * @param array $resultRows
361 * @param int $maxItems
362 * @param string $rowIdSuffix
363 * @return array
364 */
365 protected function createListItemsFromResultRow(array $resultRows, $maxItems)
366 {
367 if (empty($resultRows)) {
368 return array();
369 }
370 $listItems = array();
371
372 // traverse all found records and sort them
373 $rowsSort = array();
374 foreach ($resultRows as $key => $row) {
375 $rowsSort[$key] = $row['text'];
376 }
377 asort($rowsSort);
378 $rowsSort = array_keys($rowsSort);
379
380 // put together the selector entries
381 for ($i = 0; $i < $maxItems; ++$i) {
382 $listItems[] = $resultRows[$rowsSort[$i]];
383 }
384 return $listItems;
385 }
386
387 /**
388 * Checks the given field configuration for the tables that should be used for querying and returns them as an
389 * array.
390 *
391 * @param array $fieldConfig
392 * @return array
393 */
394 protected function getTablesToQueryFromFieldConfiguration(array $fieldConfig)
395 {
396 $queryTables = array();
397
398 if (isset($fieldConfig['allowed'])) {
399 if ($fieldConfig['allowed'] !== '*') {
400 // list of allowed tables
401 $queryTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed']);
402 } else {
403 // all tables are allowed, if the user can access them
404 foreach ($GLOBALS['TCA'] as $tableName => $tableConfig) {
405 if (!$this->isTableHidden($tableConfig) && $this->currentBackendUserMayAccessTable($tableConfig)) {
406 $queryTables[] = $tableName;
407 }
408 }
409 unset($tableName, $tableConfig);
410 }
411 } elseif (isset($fieldConfig['foreign_table'])) {
412 // use the foreign table
413 $queryTables = array($fieldConfig['foreign_table']);
414 }
415
416 return $queryTables;
417 }
418
419 /**
420 * Returns the SQL WHERE clause to use for querying records. This is currently only relevant if a foreign_table
421 * is configured and should be used; it could e.g. be used to limit to a certain subset of records from the
422 * foreign table
423 *
424 * @param array $fieldConfig
425 * @return string
426 */
427 protected function getWhereClause(array $fieldConfig)
428 {
429 if (!isset($fieldConfig['foreign_table'])) {
430 return '';
431 }
432
433 // strip ORDER BY clause
434 return trim(preg_replace('/ORDER[[:space:]]+BY.*/i', '', $fieldConfig['foreign_table_where']));
435 }
436
437 /**
438 * @return LanguageService
439 */
440 protected function getLanguageService()
441 {
442 return $GLOBALS['LANG'];
443 }
444 }