[FEATURE] Add inline AJAX validation for TCA type slug 93/57993/21
authorBenni Mack <benni@typo3.org>
Wed, 22 Aug 2018 21:09:19 +0000 (23:09 +0200)
committerBenni Mack <benni@typo3.org>
Fri, 31 Aug 2018 14:20:10 +0000 (16:20 +0200)
The TCA type slug field is "disabled" / "readonly" by default but
actually has a toggle button (like InputLinkField) to enable that field.

For new records it works like this:
- A title is entered, then the slug field gets prefilled "as-you-type"
  and the editor will see the URL directly. There is a check if the
- If a slug is manually entered:
- It is validated by "isUniqueInSite" to see if that slug is still
   free.
- If the slug is already taken, a proposal is shown below the input
   field to use the proposal for the slug. In any case, when saving,
   the same validation process kicks in anyways.

Existing records do not change their slug by changing the page title,
but only if the slug field gets modified directly.

So for existing records, the following use-cases exist, when a slug
gets manually modified:
- If the page slug is already in use, a proposal for another
  available slug is shown.
- If the page has subpages, a warning will be shown that all
  subpages need to be manually modified (not implemented yet)
- If the page slug will be changed on save, a message is shown that you
  should create a redirect (not implemented yet)

Resolves: #85931
Releases: master
Change-Id: Iabb5f02d43463b3a2bb70197cc8c9585bce1d32d
Reviewed-on: https://review.typo3.org/57993
Reviewed-by: Frans Saris <franssaris@gmail.com>
Tested-by: Frans Saris <franssaris@gmail.com>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
typo3/sysext/backend/Classes/Controller/FormSlugAjaxController.php [new file with mode: 0644]
typo3/sysext/backend/Classes/Form/Element/InputSlugElement.php
typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php
typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/SlugElement.js [new file with mode: 0644]
typo3/sysext/core/Configuration/TCA/pages.php
typo3/sysext/core/Documentation/Changelog/master/Feature-84729-NewTCATypeSlug.rst
typo3/sysext/core/Resources/Private/Language/locallang_core.xlf

diff --git a/typo3/sysext/backend/Classes/Controller/FormSlugAjaxController.php b/typo3/sysext/backend/Classes/Controller/FormSlugAjaxController.php
new file mode 100644 (file)
index 0000000..f523042
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Backend\Controller;
+
+/*
+ * 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!
+ */
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\DataHandling\SlugHelper;
+use TYPO3\CMS\Core\Http\JsonResponse;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Handle FormEngine AJAX calls for Slug validation and sanitization
+ */
+class FormSlugAjaxController extends AbstractFormEngineAjaxController
+{
+
+    /**
+     * Validates a given slug against the site and give a suggestion when it's already in use
+     *
+     * For new records this will look like this:
+     * - If "slug" field is empty, take the other fields, and generate the slug based on the sent fields.
+     *      - JS: adapt the "placeholder" value only, as on save the field will be filled with the value via DataHandler
+     * - If "slug" field is not empty (= "unlocked" and manually typed in)
+     *  - sanitize the slug
+     *      - If 'uniqueInSite' is set check if it's unique for the site
+     *        - If not unique propose another slug and return this with the flag hasConflicts = true
+     *      - If 'uniqueInPid' is set check if it's unique for the pid
+     *        - If not unique propose another slug and return this with the flag hasConflicts = true
+     *
+     * For existing records:
+     *  - sanitize the slug
+     *      - If 'uniqueInSite' is set check if it's unique for the site
+     *        - If not unique propose another slug and return this with the flag hasConflicts = true
+     *      - If 'uniqueInPid' is set check if it's unique for the pid
+     *        - If not unique propose another slug and return this with the flag hasConflicts = true
+     *      - If the slug has changed from the existing database record (@todo)
+     *          - Show a message that the old URL will stop working (possibly add a redirect via checkbox)
+     *          - If the page has subpages, show a warning that the subpages WILL NOT BE MODIFIED and keep the OLD url
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function suggestAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $this->checkRequest($request);
+
+        $queryParameters = $request->getParsedBody() ?? [];
+        $values = $queryParameters['values'];
+        $autoGeneration = $queryParameters['autoGeneration'] === 'true' ? true : false;
+        $tableName = $queryParameters['tableName'];
+        $pid = (int)$queryParameters['pageId'];
+        $recordId = (int)$queryParameters['recordId'];
+        $languageId = (int)$queryParameters['language'];
+        $fieldName = $queryParameters['fieldName'];
+
+        $fieldConfig = $GLOBALS['TCA'][$tableName]['columns'][$fieldName]['config'] ?? [];
+        if (empty($fieldConfig)) {
+            throw new \RuntimeException(
+                'No valid field configuration for table ' . $tableName . ' field name ' . $fieldName . ' found.',
+                1535379534
+            );
+        }
+
+        $evalInfo = !empty($fieldConfig['eval']) ? GeneralUtility::trimExplode(',', $fieldConfig['eval'], true) : [];
+        $hasToBeUniqueInSite = in_array('uniqueInSite', $evalInfo, true);
+        $hasToBeUniqueInPid = in_array('uniqueInPid', $evalInfo, true);
+
+        $hasConflict = false;
+
+        $slug = GeneralUtility::makeInstance(SlugHelper::class, $tableName, $fieldName, $fieldConfig);
+        if ($autoGeneration) {
+            // New page - Feed incoming values to generator
+            $recordData = $values;
+            $recordData['pid'] = $pid;
+            $recordData[$GLOBALS['TCA'][$tableName]['ctrl']['languageField']] = $languageId;
+            $proposal = $slug->generate($recordData, $pid);
+        } else {
+            // Existing record - Fetch full record and only validate against the new "slug" field.
+            $proposal = $slug->sanitize($values['manual']);
+        }
+
+        if ($hasToBeUniqueInSite && !$slug->isUniqueInSite($proposal, $recordId, $pid, $languageId)) {
+            $hasConflict = true;
+            $proposal = $slug->buildSlugForUniqueInSite($proposal, $recordId, $pid, $languageId);
+        }
+        if ($hasToBeUniqueInPid && !$slug->isUniqueInPid($proposal, $recordId, $pid, $languageId)) {
+            $hasConflict = true;
+            $proposal = $slug->buildSlugForUniqueInPid($proposal, $recordId, $pid, $languageId);
+        }
+
+        return new JsonResponse([
+            'hasConflicts' => !$autoGeneration && $hasConflict,
+            'manual' => $values['manual'] ?? '',
+            'proposal' => $proposal,
+        ]);
+    }
+
+    /**
+     * @param ServerRequestInterface $request
+     * @return bool
+     */
+    protected function checkRequest(ServerRequestInterface $request): bool
+    {
+        $queryParameters = $request->getParsedBody() ?? [];
+        $expectedHash = GeneralUtility::hmac(
+            implode(
+                '',
+                [
+                    $queryParameters['tableName'],
+                    $queryParameters['pageId'],
+                    $queryParameters['recordId'],
+                    $queryParameters['language'],
+                    $queryParameters['fieldName'],
+                    $queryParameters['command'],
+                ]
+            ),
+            __CLASS__
+        );
+        if (!hash_equals($expectedHash, $queryParameters['signature'])) {
+            throw new \InvalidArgumentException(
+                'HMAC could not be verified',
+                1535137045
+            );
+        }
+        return true;
+    }
+}
index 384ffd4..fc49de3 100644 (file)
@@ -15,6 +15,8 @@ namespace TYPO3\CMS\Backend\Form\Element;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Backend\Controller\FormSlugAjaxController;
+use TYPO3\CMS\Core\Imaging\Icon;
 use TYPO3\CMS\Core\Localization\LanguageService;
 use TYPO3\CMS\Core\Site\Entity\SiteInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -74,22 +76,6 @@ class InputSlugElement extends AbstractFormElement
         // Convert UTF-8 characters back (that is important, see Slug class when sanitizing)
         $itemValue = rawurldecode($itemValue);
 
-        $idAttribute = StringUtility::getUniqueId('formengine-input-');
-        $attributes = [
-            'value' => '',
-            'id' => $idAttribute,
-            'class' => 'form-control',
-            'disabled' => 'disabled',
-            'placeholder' => '/',
-            'data-formengine-validation-rules' => $this->getValidationDataAsJsonString($config),
-            'data-formengine-input-params' => json_encode([
-                'field' => $parameterArray['itemFormElName'],
-                'evalList' => implode(',', $evalList),
-                'is_in' => trim($config['is_in'] ?? '')
-            ]),
-            'data-formengine-input-name' => $parameterArray['itemFormElName'],
-        ];
-
         $fieldInformationResult = $this->renderFieldInformation();
         $fieldInformationHtml = $fieldInformationResult['html'];
         $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
@@ -101,33 +87,100 @@ class InputSlugElement extends AbstractFormElement
         $fieldWizardResult = $this->renderFieldWizard();
         $fieldWizardHtml = $fieldWizardResult['html'];
         $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
+        $toggleButtonTitle = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.toggleSlugExplanation');
 
+        $thisSlugId = 't3js-form-field-slug-id' . StringUtility::getUniqueId();
         $mainFieldHtml = [];
         $mainFieldHtml[] = '<div class="formengine-field-item t3js-formengine-field-item">';
-        $mainFieldHtml[] = $fieldInformationHtml;
-        $mainFieldHtml[] = '<div class="form-control-wrap" style="max-width: ' . $width . 'px">';
-        $mainFieldHtml[] =  '<div class="form-wizards-wrap">';
-        $mainFieldHtml[] =      '<div class="form-wizards-element">';
-        $mainFieldHtml[] =          '<div class="input-group">' . ($baseUrl ? '<span class="input-group-addon">' . htmlspecialchars($baseUrl) . '</span>' : '') . '<input type="text"' . GeneralUtility::implodeAttributes($attributes, true) . ' /></div>';
-        $mainFieldHtml[] =          '<input type="hidden" name="' . $parameterArray['itemFormElName'] . '" value="' . htmlspecialchars($itemValue) . '" />';
-        $mainFieldHtml[] =      '</div>';
+        $mainFieldHtml[] =  $fieldInformationHtml;
+        $mainFieldHtml[] =  '<div class="form-control-wrap" style="max-width: ' . $width . 'px" id="' . htmlspecialchars($thisSlugId) . '">';
+        $mainFieldHtml[] =      '<div class="form-wizards-wrap">';
+        $mainFieldHtml[] =          '<div class="form-wizards-element">';
+        $mainFieldHtml[] =              '<div class="input-group">';
+        $mainFieldHtml[] =                  ($baseUrl ? '<span class="input-group-addon">' . htmlspecialchars($baseUrl) . '</span>' : '');
+        // We deal with 3 fields here: a readonly field for current / default values, an input
+        // field to manipulate the value, and the final hidden field used to send the value
+        $mainFieldHtml[] =                  '<input';
+        $mainFieldHtml[] =                      ' class="form-control t3js-form-field-slug-readonly"';
+        $mainFieldHtml[] =                      ' data-toggle="tooltip"';
+        $mainFieldHtml[] =                      ' data-title="' . htmlspecialchars($itemValue) . '"';
+        $mainFieldHtml[] =                      ' value="' . htmlspecialchars($itemValue) . '"';
+        $mainFieldHtml[] =                      ' readonly';
+        $mainFieldHtml[] =                  ' />';
+        $mainFieldHtml[] =                  '<input type="text"';
+        $mainFieldHtml[] =                      ' id="' . htmlspecialchars(StringUtility::getUniqueId('formengine-input-')) . '"';
+        $mainFieldHtml[] =                      ' class="form-control t3js-form-field-slug-input hidden"';
+        $mainFieldHtml[] =                      ' placeholder="' . htmlspecialchars($row['slug'] ?? '/') . '"';
+        $mainFieldHtml[] =                      ' data-formengine-validation-rules="' . htmlspecialchars($this->getValidationDataAsJsonString($config)) . '"';
+        $mainFieldHtml[] =                      ' data-formengine-input-params="' . htmlspecialchars(json_encode(['field' => $parameterArray['itemFormElName'], 'evalList' => implode(',', $evalList)])) . '"';
+        $mainFieldHtml[] =                      ' data-formengine-input-name="' . htmlspecialchars($parameterArray['itemFormElName']) . '"';
+        $mainFieldHtml[] =                  ' />';
+        $mainFieldHtml[] =                  '<span class="input-group-btn">';
+        $mainFieldHtml[] =                      '<button class="btn btn-default t3js-form-field-slug-toggle" type="button" title="' . htmlspecialchars($toggleButtonTitle) . '">';
+        $mainFieldHtml[] =                          $this->iconFactory->getIcon('actions-version-workspaces-preview-link', Icon::SIZE_SMALL)->render();
+        $mainFieldHtml[] =                      '</button>';
+        $mainFieldHtml[] =                  '</span>';
+        $mainFieldHtml[] =                  '<input type="hidden"';
+        $mainFieldHtml[] =                      ' class="t3js-form-field-slug-hidden"';
+        $mainFieldHtml[] =                      ' name="' . htmlspecialchars($parameterArray['itemFormElName']) . '"';
+        $mainFieldHtml[] =                      ' value="' . htmlspecialchars($itemValue) . '"';
+        $mainFieldHtml[] =                  ' />';
+        $mainFieldHtml[] =              '</div>';
+        $mainFieldHtml[] =          '</div>';
         if (!empty($fieldControlHtml)) {
-            $mainFieldHtml[] =  '<div class="form-wizards-items-aside">';
-            $mainFieldHtml[] =      '<div class="btn-group">';
-            $mainFieldHtml[] =          $fieldControlHtml;
+            $mainFieldHtml[] =      '<div class="form-wizards-items-aside">';
+            $mainFieldHtml[] =          '<div class="btn-group">';
+            $mainFieldHtml[] =              $fieldControlHtml;
+            $mainFieldHtml[] =          '</div>';
             $mainFieldHtml[] =      '</div>';
-            $mainFieldHtml[] =  '</div>';
-        }
-        if (!empty($fieldWizardHtml)) {
-            $mainFieldHtml[] =  '<div class="form-wizards-items-bottom">';
-            $mainFieldHtml[] =      $fieldWizardHtml;
-            $mainFieldHtml[] =  '</div>';
         }
+        $mainFieldHtml[] =          '<div class="form-wizards-items-bottom">';
+        $mainFieldHtml[] =              '<span class="t3js-form-proposal-accepted hidden label label-success">Congrats, this page will look like ' . htmlspecialchars($baseUrl) . '<span>/abc/</span></span>';
+        $mainFieldHtml[] =              '<span class="t3js-form-proposal-different hidden label label-warning">Hmm, that is taken, how about ' . htmlspecialchars($baseUrl) . '<span>/abc/</span></span>';
+        $mainFieldHtml[] =              $fieldWizardHtml;
+        $mainFieldHtml[] =          '</div>';
+        $mainFieldHtml[] =      '</div>';
         $mainFieldHtml[] =  '</div>';
         $mainFieldHtml[] = '</div>';
-        $mainFieldHtml[] = '</div>';
 
         $resultArray['html'] = implode(LF, $mainFieldHtml);
+
+        list($commonElementPrefix) = GeneralUtility::revExplode('[', $parameterArray['itemFormElName'], 2);
+        $validInputNamesToListenTo = [];
+        foreach ($config['generatorOptions']['fields'] ?? [] as $listenerFieldName) {
+            $validInputNamesToListenTo[$listenerFieldName] = $commonElementPrefix . '[' . htmlspecialchars($listenerFieldName) . ']';
+        }
+        $signature = GeneralUtility::hmac(
+            implode(
+                '',
+                [
+                    $table,
+                    $this->data['vanillaUid'] < 0 ? abs($this->data['vanillaUid']) : $this->data['effectivePid'],
+                    $row['uid'],
+                    $languageId,
+                    $this->data['fieldName'],
+                    $this->data['command'],
+                ]
+            ),
+            FormSlugAjaxController::class
+        );
+        $optionsForModule = [
+            'pageId' => $this->data['vanillaUid'] < 0 ? abs($this->data['vanillaUid']) : $this->data['effectivePid'],
+            'recordId' => $row['uid'],
+            'tableName' => $table,
+            'fieldName' => $this->data['fieldName'],
+            'config' => $config,
+            'listenerFieldNames' => $validInputNamesToListenTo,
+            'language' => $languageId,
+            'originalValue' => $itemValue,
+            'signature' => $signature,
+            'command' => $this->data['command']
+        ];
+        $resultArray['requireJsModules'][] = ['TYPO3/CMS/Backend/FormEngine/Element/SlugElement' => '
+            function(SlugElement) {
+                SlugElement.initialize(' . GeneralUtility::quoteJSvalue('#' . $thisSlugId) . ', ' . json_encode($optionsForModule) . ');
+            }'
+        ];
         return $resultArray;
     }
 
index d55fc23..00f2aff 100644 (file)
@@ -59,6 +59,12 @@ return [
         'target' => Controller\SiteInlineAjaxController::class . '::newInlineChildAction'
     ],
 
+    // Validate slug input
+    'record_slug_suggest' => [
+        'path' => '/record/slug/suggest',
+        'target' => Controller\FormSlugAjaxController::class . '::suggestAction'
+    ],
+
     // Site configuration inline open existing "record" route
     'site_configuration_inline_details' => [
         'path' => '/siteconfiguration/inline/details',
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/SlugElement.js b/typo3/sysext/backend/Resources/Public/JavaScript/FormEngine/Element/SlugElement.js
new file mode 100644 (file)
index 0000000..7636398
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * 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!
+ */
+
+/**
+ * Module: TYPO3/CMS/Backend/FormEngine/Element/SlugElement
+ * Logic for a TCA type "slug"
+ *
+ * For new records, changes on the other fields of the record (typically the record title) are listened
+ * on as well and the response is put in as "placeholder" into the input field.
+ *
+ * For new and existing records, the toggle switch will allow editors to modify the slug
+ *  - for new records, we only need to see if that is already in use or not (uniqueInSite), if it is taken, show a message.
+ *  - for existing records, we also check for conflicts, and check if we have subpges, or if we want to add a redirect (todo)
+ */
+define(['jquery'], function ($) {
+
+  /**
+   *
+   * @type {{}}
+   * @exports TYPO3/CMS/Backend/FormEngine/Element/SlugElement
+   */
+  var SlugElement = {
+    options: {},
+    manuallyChanged: false, // This flag is set to true as soon as user submitted data to the input field
+    $fullElement: null,
+    $readOnlyField: null,
+    $inputField: null,
+    $hiddenField: null
+  };
+
+  /**
+   * Initializes the SlugElement
+   *
+   * @param {String} selector
+   * @param {Object} options
+   */
+  SlugElement.initialize = function (selector, options) {
+    var self = this;
+    var toggleButtonClass = '.t3js-form-field-slug-toggle';
+    var inputFieldClass = '.t3js-form-field-slug-input';
+    var readOnlyFieldClass = '.t3js-form-field-slug-readonly';
+    var hiddenFieldClass = '.t3js-form-field-slug-hidden';
+
+    this.options = options;
+    this.$fullElement = $(selector);
+    this.$inputField = this.$fullElement.find(inputFieldClass);
+    this.$readOnlyField = this.$fullElement.find(readOnlyFieldClass);
+    this.$hiddenField = this.$fullElement.find(hiddenFieldClass);
+
+    var fieldsToListenOn = this.options.listenerFieldNames || {};
+
+    if (options.command === 'new') {
+      // Listen on 'listenerFieldNames' for new pages. This is typically the 'title' field
+      // of a page to create slugs from the title when title is set / changed.
+
+      $.each(fieldsToListenOn, function (fieldName, field) {
+        $(document).on('keyup', '[data-formengine-input-name="' + field + '"]', function (e) {
+          if (!self.manuallyChanged) {
+            // manuallyChanged = true stops slug generation as soon as editor set a slug manually
+            SlugElement.sendSlugProposal(true);
+          }
+        });
+      });
+    }
+
+    if (options.command === 'edit' && this.$hiddenField.val() === '') {
+      // If we're editing a page and the slug is currently empty for whatever reason,
+      // auto-generate the slug once.
+      // @todo: This currently happens if a page is localized via page / list module
+      // @todo: "Make new translation of this page" - DataHandler does not create slug in this case
+      // @todo: "justLocalized" This should probably be fixed in the DataHandler
+      SlugElement.sendSlugProposal(true);
+      // And also listen on the listener fields so slug is modified in this case, too
+      $.each(fieldsToListenOn, function (fieldName, field) {
+        $(document).on('keyup', '[data-formengine-input-name="' + field + '"]', function (e) {
+          if (!self.manuallyChanged) {
+            // manuallyChanged = true stops slug generation as soon as editor set a slug manually
+            SlugElement.sendSlugProposal(true);
+          }
+        });
+      });
+    }
+
+    // Scenario for new pages: Usually, slug is created from the page title. However, if user toggles the
+    // input field and feeds an own slug, and then changes title again, the slug should stay. manuallyChanged
+    // is used to track this.
+    $(this.$inputField).on('keyup', function (e) {
+      self.manuallyChanged = true;
+      SlugElement.sendSlugProposal(false);
+    });
+
+    // Clicking the toggle button toggles the read only field and the input field.
+    // Also set the value of either the read only or the input field to the hidden field.
+    $(document).on('click', toggleButtonClass, function (e) {
+      e.preventDefault();
+      var showReadOnlyField = self.$readOnlyField.hasClass('hidden');
+      self.$readOnlyField.toggleClass('hidden', !showReadOnlyField);
+      self.$inputField.toggleClass('hidden', showReadOnlyField);
+      if (showReadOnlyField) {
+        self.manuallyChanged = false;
+        self.$hiddenField.val(self.$readOnlyField.val());
+        self.$fullElement.find('.t3js-form-proposal-accepted').addClass('hidden');
+        self.$fullElement.find('.t3js-form-proposal-different').addClass('hidden');
+      } else {
+        self.$hiddenField.val(self.$inputField.val());
+      }
+    });
+  };
+
+  SlugElement.sendSlugProposal = function (autoGeneration) {
+    var input = {};
+    if (autoGeneration) {
+      var fieldsToListenOn = SlugElement.options.listenerFieldNames || {};
+      $.each(fieldsToListenOn, function (fieldName, field) {
+        input[fieldName] = $('[data-formengine-input-name="' + field + '"]').val();
+      });
+    } else {
+      input['manual'] = SlugElement.$inputField.val();
+    }
+    $.post(
+      TYPO3.settings.ajaxUrls['record_slug_suggest'],
+      {
+        values: input,
+        autoGeneration: autoGeneration,
+        tableName: SlugElement.options.tableName,
+        pageId: SlugElement.options.pageId,
+        recordId: SlugElement.options.recordId,
+        language: SlugElement.options.language,
+        fieldName: SlugElement.options.fieldName,
+        command: SlugElement.options.command,
+        signature: SlugElement.options.signature
+      }, function (response) {
+        if (response.hasConflicts) {
+          SlugElement.$fullElement.find('.t3js-form-proposal-accepted').addClass('hidden');
+          SlugElement.$fullElement.find('.t3js-form-proposal-different').removeClass('hidden').find('span').text(response.proposal);
+        } else {
+          SlugElement.$fullElement.find('.t3js-form-proposal-accepted').removeClass('hidden').find('span').text(response.proposal);
+          SlugElement.$fullElement.find('.t3js-form-proposal-different').addClass('hidden');
+        }
+        if (autoGeneration) {
+          SlugElement.$readOnlyField.val(response.proposal);
+          SlugElement.$hiddenField.val(response.proposal);
+        } else {
+          SlugElement.$hiddenField.val(response.proposal);
+        }
+      },
+      'json'
+    );
+  };
+
+  return SlugElement;
+});
index 508f7fa..fa6303e 100644 (file)
@@ -157,6 +157,7 @@ return [
             'displayCond' => 'USER:' . \TYPO3\CMS\Core\Compatibility\PseudoSiteTcaDisplayCondition::class . '->isInPseudoSite:pages:false',
             'config' => [
                 'type' => 'slug',
+                'size' => 50,
                 'generatorOptions' => [
                     'fields' => ['title'],
                     'fieldSeparator' => '/',
index 2d72e58..2f478b2 100644 (file)
@@ -29,12 +29,13 @@ If a TCA table contains a field called "slug", it needs to be filled for every e
 be shown and edited via regular Backend Forms, and is also evaluated during persistence via DataHandler.
 
 The default behaviour of a slug is as follows:
-- A slug only contains characters which are allowed within URLs. Spaces, commas and other special characters
- are converted to a fallback character.
-- A slug is always lower-cased.
-- A slug is unicode-aware.
+* A slug only contains characters which are allowed within URLs. Spaces, commas and other special characters are
+  converted to a fallback character.
+* A slug is always lower-cased.
+* A slug is unicode-aware.
+
+The following options apply to the new TCA type::
 
-The following options apply to the new TCA type:
        'type' => 'slug',
        'config' => [
                'generatorOptions' => [
@@ -46,7 +47,7 @@ The following options apply to the new TCA type:
                'eval' => 'uniqueInSite'
        ]
 
-In addition the new 'eval' option 'uniqueInSite' to evaluate if a record is unique in a page tree (specific to a
+In addition the new `eval` option `uniqueInSite` to evaluate if a record is unique in a page tree (specific to a
 language).
 
 The new slug TCA type allows for two `eval` options `uniqueInSite` or `uniqueInPid` (useful for third-party
@@ -56,6 +57,9 @@ recommended not to do so.
 It is possible to build a default value from the rootline (very helpful for pages, or categorized slugs),
 but also to just generate a "speaking" segment from e.g. a news title.
 
-Sanitization and Validation configuration options apply when persisting a record via DataHandler.
+Sanitation and Validation configuration options apply when persisting a record via DataHandler.
+
+In the backend forms a validation happens by an AJAX call, which immediately check any input by the user and receive
+a new proposal in case the slug is already used.
 
 .. index:: TCA, ext:core
index 0bb4b4a..8871428 100644 (file)
@@ -988,6 +988,9 @@ Do you want to refresh it now?</source>
                        <trans-unit id="buttons.toggleLinkExplanation">
                                <source>Toggle link explanation</source>
                        </trans-unit>
+                       <trans-unit id="buttons.toggleSlugExplanation">
+                               <source>Toggle manual URL segment</source>
+                       </trans-unit>
                        <trans-unit id="cm.copy">
                                <source>Copy</source>
                        </trans-unit>