From d3dabc460323d525ef711d3ba3e67f0bbd597ad7 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Mon, 28 Jun 2021 13:24:26 +0100
Subject: [PATCH] Issue #2897638 by Spokje, claudiu.cristea, istavros,
 PaulDinelle, drclaw, Giuseppe87, KapilV, Lendude: Views exposed sort
 identifiers are not configurable

---
 .../config/optional/views.view.comment.yml    |  1 +
 .../optional/views.view.comments_recent.yml   |  4 ++
 .../config/optional/views.view.watchdog.yml   |  1 +
 .../config/optional/views.view.media.yml      |  1 +
 .../install/views.view.media_library.yml      |  3 ++
 .../config/optional/views.view.archive.yml    |  1 +
 .../optional/views.view.content_recent.yml    |  1 +
 .../config/optional/views.view.frontpage.yml  |  2 +
 .../optional/views.view.taxonomy_term.yml     |  2 +
 .../optional/views.view.user_admin_people.yml |  1 +
 .../config/optional/views.view.who_s_new.yml  |  1 +
 .../optional/views.view.who_s_online.yml      |  1 +
 .../config/schema/views.data_types.schema.yml |  3 ++
 .../exposed_form/ExposedFormPluginBase.php    | 34 +++++++-------
 .../src/Plugin/views/sort/SortPluginBase.php  | 47 ++++++++++++++++++-
 core/modules/views/src/ViewsConfigUpdater.php | 37 +++++++++++++++
 .../src/Functional/Plugin/ExposedFormTest.php | 17 ++++++-
 .../Update/ViewsSortIdentifiersUpdateTest.php | 42 +++++++++++++++++
 .../src/Kernel/Plugin/DisplayKernelTest.php   | 13 ++++-
 core/modules/views/views.post_update.php      | 12 +++++
 .../views_ui/css/views_ui.admin.theme.css     |  2 +
 .../src/Functional/ExposedFormUITest.php      | 37 +++++++++++++--
 .../install/views.view.articles_aside.yml     |  2 +
 .../install/views.view.featured_articles.yml  |  2 +
 .../config/install/views.view.frontpage.yml   |  3 ++
 .../install/views.view.promoted_items.yml     |  1 +
 .../install/views.view.recipe_collections.yml |  1 +
 .../config/install/views.view.recipes.yml     |  2 +
 .../install/views.view.taxonomy_term.yml      |  2 +
 .../config/optional/views.view.media.yml      |  1 +
 core/themes/claro/css/components/views-ui.css |  3 ++
 .../claro/css/components/views-ui.pcss.css    |  3 ++
 .../claro/css/theme/views_ui.admin.theme.css  |  2 +
 .../css/theme/views_ui.admin.theme.pcss.css   |  2 +
 core/themes/seven/css/components/views-ui.css |  3 ++
 .../css/views_ui/views_ui.admin.theme.css     |  2 +
 .../css/views_ui/views_ui.admin.theme.css     |  2 +
 37 files changed, 268 insertions(+), 26 deletions(-)
 create mode 100644 core/modules/views/tests/src/Functional/Update/ViewsSortIdentifiersUpdateTest.php

diff --git a/core/modules/comment/config/optional/views.view.comment.yml b/core/modules/comment/config/optional/views.view.comment.yml
index 36ab4560168b..45d566636fed 100644
--- a/core/modules/comment/config/optional/views.view.comment.yml
+++ b/core/modules/comment/config/optional/views.view.comment.yml
@@ -806,6 +806,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: changed
           granularity: second
           entity_type: comment
           entity_field: changed
diff --git a/core/modules/comment/config/optional/views.view.comments_recent.yml b/core/modules/comment/config/optional/views.view.comments_recent.yml
index 0387a60737de..587040dad863 100644
--- a/core/modules/comment/config/optional/views.view.comments_recent.yml
+++ b/core/modules/comment/config/optional/views.view.comments_recent.yml
@@ -206,6 +206,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           plugin_id: date
           entity_type: comment
           entity_field: created
@@ -218,6 +219,9 @@ display:
           admin_label: ''
           order: DESC
           exposed: false
+          expose:
+            label: ''
+            field_identifier: cid
           plugin_id: field
           entity_type: comment
           entity_field: cid
diff --git a/core/modules/dblog/config/optional/views.view.watchdog.yml b/core/modules/dblog/config/optional/views.view.watchdog.yml
index e6542aa9d44c..b3da477847b6 100644
--- a/core/modules/dblog/config/optional/views.view.watchdog.yml
+++ b/core/modules/dblog/config/optional/views.view.watchdog.yml
@@ -647,6 +647,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: wid
           plugin_id: standard
       title: 'Recent log messages'
       header: {  }
diff --git a/core/modules/media/config/optional/views.view.media.yml b/core/modules/media/config/optional/views.view.media.yml
index 17518dc84a50..df4df46c72c9 100644
--- a/core/modules/media/config/optional/views.view.media.yml
+++ b/core/modules/media/config/optional/views.view.media.yml
@@ -845,6 +845,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
       title: Media
       header: {  }
diff --git a/core/modules/media_library/config/install/views.view.media_library.yml b/core/modules/media_library/config/install/views.view.media_library.yml
index 35fd413df8d0..1bd04639ef33 100644
--- a/core/modules/media_library/config/install/views.view.media_library.yml
+++ b/core/modules/media_library/config/install/views.view.media_library.yml
@@ -418,6 +418,7 @@ display:
           exposed: true
           expose:
             label: 'Newest first'
+            field_identifier: created
           granularity: second
           entity_type: media
           entity_field: created
@@ -433,6 +434,7 @@ display:
           exposed: true
           expose:
             label: 'Name (A-Z)'
+            field_identifier: name
           entity_type: media
           entity_field: name
           plugin_id: standard
@@ -447,6 +449,7 @@ display:
           exposed: true
           expose:
             label: 'Name (Z-A)'
+            field_identifier: name_1
           entity_type: media
           entity_field: name
           plugin_id: standard
diff --git a/core/modules/node/config/optional/views.view.archive.yml b/core/modules/node/config/optional/views.view.archive.yml
index b8e55476d14f..9ac4c063b226 100644
--- a/core/modules/node/config/optional/views.view.archive.yml
+++ b/core/modules/node/config/optional/views.view.archive.yml
@@ -77,6 +77,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
           entity_type: node
           entity_field: created
diff --git a/core/modules/node/config/optional/views.view.content_recent.yml b/core/modules/node/config/optional/views.view.content_recent.yml
index 44de40b828f6..ec80480d247c 100644
--- a/core/modules/node/config/optional/views.view.content_recent.yml
+++ b/core/modules/node/config/optional/views.view.content_recent.yml
@@ -254,6 +254,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: changed
           granularity: second
           entity_type: node
           entity_field: changed
diff --git a/core/modules/node/config/optional/views.view.frontpage.yml b/core/modules/node/config/optional/views.view.frontpage.yml
index efdae12c1860..3b0bd8941297 100644
--- a/core/modules/node/config/optional/views.view.frontpage.yml
+++ b/core/modules/node/config/optional/views.view.frontpage.yml
@@ -203,6 +203,7 @@ display:
           admin_label: ''
           expose:
             label: ''
+            field_identifier: sticky
           exposed: false
           field: sticky
           group_type: group
@@ -225,6 +226,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
           entity_type: node
           entity_field: created
diff --git a/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml b/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml
index 895019632e4e..0fa147dda59f 100644
--- a/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml
+++ b/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml
@@ -77,6 +77,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: sticky
         created:
           id: created
           table: taxonomy_index
@@ -89,6 +90,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
       arguments:
         tid:
diff --git a/core/modules/user/config/optional/views.view.user_admin_people.yml b/core/modules/user/config/optional/views.view.user_admin_people.yml
index 46a22eba1f37..7a6de5a7f3a0 100644
--- a/core/modules/user/config/optional/views.view.user_admin_people.yml
+++ b/core/modules/user/config/optional/views.view.user_admin_people.yml
@@ -846,6 +846,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
           plugin_id: date
           entity_type: user
diff --git a/core/modules/user/config/optional/views.view.who_s_new.yml b/core/modules/user/config/optional/views.view.who_s_new.yml
index 2898850ce67a..1054052d3aa7 100644
--- a/core/modules/user/config/optional/views.view.who_s_new.yml
+++ b/core/modules/user/config/optional/views.view.who_s_new.yml
@@ -156,6 +156,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
           plugin_id: date
           entity_type: user
diff --git a/core/modules/user/config/optional/views.view.who_s_online.yml b/core/modules/user/config/optional/views.view.who_s_online.yml
index 2229485cd970..2b40ccf43d7e 100644
--- a/core/modules/user/config/optional/views.view.who_s_online.yml
+++ b/core/modules/user/config/optional/views.view.who_s_online.yml
@@ -165,6 +165,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: access
           granularity: second
           plugin_id: date
           entity_type: user
diff --git a/core/modules/views/config/schema/views.data_types.schema.yml b/core/modules/views/config/schema/views.data_types.schema.yml
index 708e12f6d8ec..f9f73973f2da 100644
--- a/core/modules/views/config/schema/views.data_types.schema.yml
+++ b/core/modules/views/config/schema/views.data_types.schema.yml
@@ -281,6 +281,9 @@ views_sort_expose:
     label:
       type: label
       label: 'Label'
+    field_identifier:
+      type: string
+      label: 'Field identifier'
 
 views_area:
   type: views_handler
diff --git a/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php b/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php
index cfb593d6e663..75daffccb054 100644
--- a/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php
+++ b/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php
@@ -157,19 +157,17 @@ public function query() {
     if (!empty($sort_by)) {
       // Make sure the original order of sorts is preserved
       // (e.g. a sticky sort is often first)
-      if (isset($view->sort[$sort_by])) {
-        $view->query->orderby = [];
-        foreach ($view->sort as $key => $sort) {
-          if (!$sort->isExposed()) {
-            $sort->query();
-          }
-          elseif ($key == $sort_by) {
-            if (isset($exposed_data['sort_order']) && in_array($exposed_data['sort_order'], ['ASC', 'DESC'])) {
-              $sort->options['order'] = $exposed_data['sort_order'];
-            }
-            $sort->setRelationship();
-            $sort->query();
+      $view->query->orderby = [];
+      foreach ($view->sort as $key => $sort) {
+        if (!$sort->isExposed()) {
+          $sort->query();
+        }
+        elseif (!empty($sort->options['expose']['field_identifier']) && $sort->options['expose']['field_identifier'] === $sort_by) {
+          if (isset($exposed_data['sort_order']) && in_array($exposed_data['sort_order'], ['ASC', 'DESC'], TRUE)) {
+            $sort->options['order'] = $exposed_data['sort_order'];
           }
+          $sort->setRelationship();
+          $sort->query();
         }
       }
     }
@@ -205,16 +203,18 @@ public function exposedFormAlter(&$form, FormStateInterface $form_state) {
 
     // Check if there is exposed sorts for this view
     $exposed_sorts = [];
+    $exposed_sorts_options = [];
     foreach ($this->view->sort as $id => $handler) {
-      if ($handler->canExpose() && $handler->isExposed()) {
-        $exposed_sorts[$id] = $handler->options['expose']['label'];
+      if ($handler->canExpose() && $handler->isExposed() && !empty($handler->options['expose']['field_identifier'])) {
+        $exposed_sorts[$handler->options['expose']['field_identifier']] = $id;
+        $exposed_sorts_options[$handler->options['expose']['field_identifier']] = $handler->options['expose']['label'];
       }
     }
 
     if (count($exposed_sorts)) {
       $form['sort_by'] = [
         '#type' => 'select',
-        '#options' => $exposed_sorts,
+        '#options' => $exposed_sorts_options,
         '#title' => $this->options['exposed_sorts_label'],
       ];
       $sort_order = [
@@ -222,8 +222,8 @@ public function exposedFormAlter(&$form, FormStateInterface $form_state) {
         'DESC' => $this->options['sort_desc_label'],
       ];
       $user_input = $form_state->getUserInput();
-      if (isset($user_input['sort_by']) && isset($this->view->sort[$user_input['sort_by']])) {
-        $default_sort_order = $this->view->sort[$user_input['sort_by']]->options['order'];
+      if (isset($user_input['sort_by']) && isset($exposed_sorts[$user_input['sort_by']]) && isset($this->view->sort[$exposed_sorts[$user_input['sort_by']]])) {
+        $default_sort_order = $this->view->sort[$exposed_sorts[$user_input['sort_by']]]->options['order'];
       }
       else {
         $first_sort = reset($this->view->sort);
diff --git a/core/modules/views/src/Plugin/views/sort/SortPluginBase.php b/core/modules/views/src/Plugin/views/sort/SortPluginBase.php
index cf036fc810f3..fcac77e013e3 100644
--- a/core/modules/views/src/Plugin/views/sort/SortPluginBase.php
+++ b/core/modules/views/src/Plugin/views/sort/SortPluginBase.php
@@ -49,6 +49,7 @@ protected function defineOptions() {
     $options['expose'] = [
       'contains' => [
         'label' => ['default' => ''],
+        'field_identifier' => ['default' => ''],
       ],
     ];
     return $options;
@@ -208,7 +209,50 @@ public function buildExposeForm(&$form, FormStateInterface $form_state) {
       '#required' => TRUE,
       '#size' => 40,
       '#weight' => -1,
-   ];
+    ];
+
+    $form['expose']['field_identifier'] = [
+      '#type' => 'textfield',
+      '#default_value' => $this->options['expose']['field_identifier'],
+      '#title' => $this->t('Sort field identifier'),
+      '#required' => TRUE,
+      '#size' => 40,
+      '#description' => $this->t("This will appear in the URL after the ?, as value of 'sort_by' parameter, to identify this sort field. Cannot be blank. Only letters, digits and the dot ('.'), hyphen ('-'), underscore ('_'), and tilde ('~') characters are allowed."),
+    ];
+  }
+
+  /**
+   * Validate the options form.
+   */
+  public function validateExposeForm($form, FormStateInterface $form_state) {
+    $field_identifier = $form_state->getValue([
+      'options',
+      'expose',
+      'field_identifier',
+    ]);
+    if (!preg_match('/^[a-zA-z][a-zA-Z0-9_~.\-]*$/', $field_identifier)) {
+      $form_state->setErrorByName('expose][field_identifier', $this->t('This identifier has illegal characters.'));
+      return;
+    }
+
+    // Validate that the sort field identifier is unique within the sort
+    // handlers. Note that the sort field identifier is different that other
+    // identifiers because it is used as a query string value of the 'sort_by'
+    // parameter, while the others are used as query string parameter keys.
+    // Therefore we can have a sort field identifier be the same as an exposed
+    // filter identifier. This prevents us from using
+    // DisplayPluginInterface::isIdentifierUnique() to test for uniqueness.
+    // @see \Drupal\views\Plugin\views\display\DisplayPluginInterface::isIdentifierUnique()
+    foreach ($this->view->display_handler->getHandlers('sort') as $key => $handler) {
+      if ($handler->canExpose() && $handler->isExposed()) {
+        if ($form_state->get('id') !== $key && isset($handler->options['expose']['field_identifier']) && $field_identifier === $handler->options['expose']['field_identifier']) {
+          $form_state->setErrorByName('expose][field_identifier', $this->t('This identifier is already used by %label sort handler.', [
+            '%label' => $handler->adminLabel(TRUE),
+          ]));
+          return;
+        }
+      }
+    }
   }
 
   /**
@@ -226,6 +270,7 @@ public static function trustedCallbacks() {
   public function defaultExposeOptions() {
     $this->options['expose'] = [
       'label' => $this->definition['title'],
+      'field_identifier' => $this->options['id'],
     ];
   }
 
diff --git a/core/modules/views/src/ViewsConfigUpdater.php b/core/modules/views/src/ViewsConfigUpdater.php
index 88442d46ddc1..dfb89e338adb 100644
--- a/core/modules/views/src/ViewsConfigUpdater.php
+++ b/core/modules/views/src/ViewsConfigUpdater.php
@@ -138,6 +138,9 @@ public function updateAll(ViewEntityInterface $view) {
       if ($this->processMultivalueBaseFieldHandler($handler, $handler_type, $key, $display_id, $view)) {
         $changed = TRUE;
       }
+      if ($this->processSortFieldIdentifierUpdateHandler($handler, $handler_type)) {
+        $changed = TRUE;
+      }
       return $changed;
     });
   }
@@ -477,4 +480,38 @@ protected function mapOperatorFromSingleToMultiple($single_operator) {
     }
   }
 
+  /**
+   * Updates the sort handlers by adding default sort field identifiers.
+   *
+   * @param \Drupal\views\ViewEntityInterface $view
+   *   The View to update.
+   *
+   * @return bool
+   *   Whether the view was updated.
+   */
+  public function needsSortFieldIdentifierUpdate(ViewEntityInterface $view): bool {
+    return $this->processDisplayHandlers($view, TRUE, function (array &$handler, string $handler_type): bool {
+      return $this->processSortFieldIdentifierUpdateHandler($handler, $handler_type);
+    });
+  }
+
+  /**
+   * Processes sort handlers by adding the sort identifier.
+   *
+   * @param array $handler
+   *   A display handler.
+   * @param string $handler_type
+   *   The handler type.
+   *
+   * @return bool
+   *   Whether the handler was updated.
+   */
+  protected function processSortFieldIdentifierUpdateHandler(array &$handler, string $handler_type): bool {
+    if ($handler_type === 'sort' && !isset($handler['expose']['field_identifier'])) {
+      $handler['expose']['field_identifier'] = $handler['id'];
+      return TRUE;
+    }
+    return FALSE;
+  }
+
 }
diff --git a/core/modules/views/tests/src/Functional/Plugin/ExposedFormTest.php b/core/modules/views/tests/src/Functional/Plugin/ExposedFormTest.php
index 676d976afbca..42f5ae084caf 100644
--- a/core/modules/views/tests/src/Functional/Plugin/ExposedFormTest.php
+++ b/core/modules/views/tests/src/Functional/Plugin/ExposedFormTest.php
@@ -368,18 +368,24 @@ public function testExposedSortAndItemsPerPage() {
     $this->assertCacheContexts($contexts);
     $this->assertIds(range(40, 16, 1));
 
-    // Change the label to something with special characters.
     $view = Views::getView('test_exposed_form_sort_items_per_page');
     $view->setDisplay();
     $sorts = $view->display_handler->getOption('sorts');
+    // Change the label to something with special characters.
     $sorts['id']['expose']['label'] = $expected_label = "<script>alert('unsafe&dangerous');</script>";
+    // Use a custom sort field identifier.
+    $sorts['id']['expose']['field_identifier'] = $field_identifier = $this->randomMachineName() . '-_.~';
     $view->display_handler->setOption('sorts', $sorts);
     $view->save();
 
+    // Test label escaping.
     $this->drupalGet('test_exposed_form_sort_items_per_page');
     $options = $this->assertSession()->selectExists('edit-sort-by')->findAll('css', 'option');
     $this->assertCount(1, $options);
-    $this->assertSession()->optionExists('edit-sort-by', $expected_label);
+    // Check option existence by option label.
+    $this->assertSession()->optionExists('Sort by', $expected_label);
+    // Check option existence by option value.
+    $this->assertSession()->optionExists('Sort by', $field_identifier);
     $escape_1 = Html::escape($expected_label);
     $escape_2 = Html::escape($escape_1);
     // Make sure we see the single-escaped string in the raw output.
@@ -388,6 +394,13 @@ public function testExposedSortAndItemsPerPage() {
     $this->assertNoRaw($escape_2);
     // And not the raw label, either.
     $this->assertNoRaw($expected_label);
+
+    // Check that the custom field identifier is used in the URL query string.
+    $this->submitForm(['sort_order' => 'DESC'], 'Apply');
+    $this->assertCacheContexts($contexts);
+    $this->assertIds(range(50, 41));
+    $url = $this->getSession()->getCurrentUrl();
+    $this->assertStringContainsString('sort_by=' . urlencode($field_identifier), $url);
   }
 
   /**
diff --git a/core/modules/views/tests/src/Functional/Update/ViewsSortIdentifiersUpdateTest.php b/core/modules/views/tests/src/Functional/Update/ViewsSortIdentifiersUpdateTest.php
new file mode 100644
index 000000000000..e7b0cbdcd30c
--- /dev/null
+++ b/core/modules/views/tests/src/Functional/Update/ViewsSortIdentifiersUpdateTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\views\Functional\Update;
+
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+
+/**
+ * Tests the views_post_update_sort_identifier() post update.
+ *
+ * @group views
+ * @group legacy
+ */
+class ViewsSortIdentifiersUpdateTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles() {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.8.0.bare.standard.php.gz',
+    ];
+  }
+
+  /**
+   * Tests views_post_update_sort_identifier().
+   *
+   * @see views_post_update_sort_identifier()
+   */
+  public function testSortIdentifierPostUpdate(): void {
+    $config_factory = \Drupal::configFactory();
+    $view = $config_factory->get('views.view.comments_recent');
+    $trail = 'display.default.display_options.sorts.created';
+    $this->assertArrayNotHasKey('field_identifier', $view->get("{$trail}.expose"));
+
+    $this->runUpdates();
+
+    $view = $config_factory->get('views.view.comments_recent');
+    $sort_handler = $view->get($trail);
+    $this->assertSame($sort_handler['id'], $sort_handler['expose']['field_identifier']);
+  }
+
+}
diff --git a/core/modules/views/tests/src/Kernel/Plugin/DisplayKernelTest.php b/core/modules/views/tests/src/Kernel/Plugin/DisplayKernelTest.php
index a2eb682d4cdd..1fe28612385b 100644
--- a/core/modules/views/tests/src/Kernel/Plugin/DisplayKernelTest.php
+++ b/core/modules/views/tests/src/Kernel/Plugin/DisplayKernelTest.php
@@ -130,7 +130,10 @@ public function testisIdentifierUnique() {
         'table' => 'views_test_data',
         'plugin_id' => 'standard',
         'order' => 'asc',
-        'expose' => ['label' => 'id'],
+        'expose' => [
+          'label' => 'Id',
+          'field_identifier' => 'name',
+        ],
         'exposed' => TRUE,
       ],
     ];
@@ -156,10 +159,16 @@ public function testisIdentifierUnique() {
     ];
     $view->display_handler->setOption('sorts', $sorts);
     $view->display_handler->setOption('filters', $filters);
-    $view->save();
 
     $this->assertTrue($view->display_handler->isIdentifierUnique('some_id', 'some_id'));
     $this->assertFalse($view->display_handler->isIdentifierUnique('some_id', 'id'));
+
+    // Check that an exposed filter is able to use the same identifier as an
+    // exposed sort.
+    $sorts['name']['expose']['field_identifier'] = 'id';
+    $view->display_handler->handlers = [];
+    $view->display_handler->setOption('sorts', $sorts);
+    $this->assertTrue($view->display_handler->isIdentifierUnique('id', 'id'));
   }
 
 }
diff --git a/core/modules/views/views.post_update.php b/core/modules/views/views.post_update.php
index eab54c113620..cd925a9b845c 100644
--- a/core/modules/views/views.post_update.php
+++ b/core/modules/views/views.post_update.php
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Config\Entity\ConfigEntityUpdater;
+use Drupal\views\ViewEntityInterface;
 use Drupal\views\ViewsConfigUpdater;
 
 /**
@@ -75,3 +76,14 @@ function views_post_update_remove_sorting_global_text_field() {
 function views_post_update_title_translations() {
   \Drupal::service('router.builder')->setRebuildNeeded();
 }
+
+/**
+ * Add the identifier option to all sort handler configurations.
+ */
+function views_post_update_sort_identifier(?array &$sandbox = NULL): void {
+  /** @var \Drupal\views\ViewsConfigUpdater $view_config_updater */
+  $view_config_updater = \Drupal::classResolver(ViewsConfigUpdater::class);
+  \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool {
+    return $view_config_updater->needsSortFieldIdentifierUpdate($view);
+  });
+}
diff --git a/core/modules/views_ui/css/views_ui.admin.theme.css b/core/modules/views_ui/css/views_ui.admin.theme.css
index 060afc91eee3..565132721c6c 100644
--- a/core/modules/views_ui/css/views_ui.admin.theme.css
+++ b/core/modules/views_ui/css/views_ui.admin.theme.css
@@ -682,6 +682,7 @@ td.group-title {
 }
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
@@ -689,6 +690,7 @@ td.group-title {
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 18px;
   margin-left: 0;
diff --git a/core/modules/views_ui/tests/src/Functional/ExposedFormUITest.php b/core/modules/views_ui/tests/src/Functional/ExposedFormUITest.php
index 873125b952e9..62b8ba89da13 100644
--- a/core/modules/views_ui/tests/src/Functional/ExposedFormUITest.php
+++ b/core/modules/views_ui/tests/src/Functional/ExposedFormUITest.php
@@ -105,6 +105,7 @@ public function testExposedAdminUi() {
     $this->drupalGet('admin/structure/views/nojs/handler/test_exposed_admin_ui/default/sort/created');
     $this->helperButtonHasLabel('edit-options-expose-button-button', 'Expose sort');
     $this->assertSession()->fieldNotExists('edit-options-expose-label');
+    $this->assertSession()->fieldNotExists('Sort field identifier');
 
     // Un-expose the filter.
     $this->drupalGet('admin/structure/views/nojs/handler/test_exposed_admin_ui/default/filter/type');
@@ -123,6 +124,7 @@ public function testExposedAdminUi() {
     // Check the label of the expose button.
     $this->helperButtonHasLabel('edit-options-expose-button-button', 'Hide sort');
     $this->assertSession()->fieldValueEquals('edit-options-expose-label', 'Authored on');
+    $this->assertSession()->fieldValueEquals('Sort field identifier', 'created');
 
     // Test adding a new exposed sort criteria.
     $view_id = $this->randomView()['id'];
@@ -135,15 +137,42 @@ public function testExposedAdminUi() {
     $this->submitForm([], 'Expose sort');
     $this->assertSession()->fieldValueEquals('options[order]', 'DESC');
     $this->assertSession()->fieldValueEquals('options[expose][label]', 'Authored on');
-    // Change the label and save the view.
-    $edit = ['options[expose][label]' => $this->randomString()];
+    $this->assertSession()->fieldValueEquals('Sort field identifier', 'created');
+
+    // Change the label and try with an empty identifier.
+    $edit = [
+      'options[expose][label]' => $this->randomString(),
+      'options[expose][field_identifier]' => '',
+    ];
+    $this->submitForm($edit, 'Apply');
+    $this->assertSession()->pageTextContains('Sort field identifier field is required.');
+
+    // Try with an invalid identifier.
+    $edit['options[expose][field_identifier]'] = 'abc&! ###08.';
+    $this->submitForm($edit, 'Apply');
+    $this->assertSession()->pageTextContains('This identifier has illegal characters.');
+
+    // Use a valid identifier.
+    $edit['options[expose][field_identifier]'] = $this->randomMachineName() . '_-~.';
     $this->submitForm($edit, 'Apply');
     $this->submitForm([], 'Save');
+
     // Check that the values were saved.
     $display = View::load($view_id)->getDisplay('default');
     $this->assertTrue($display['display_options']['sorts']['created']['exposed']);
-    $this->assertEquals(['label' => $edit['options[expose][label]']], $display['display_options']['sorts']['created']['expose']);
-    $this->assertEquals('DESC', $display['display_options']['sorts']['created']['order']);
+    $this->assertSame([
+      'label' => $edit['options[expose][label]'],
+      'field_identifier' => $edit['options[expose][field_identifier]'],
+    ], $display['display_options']['sorts']['created']['expose']);
+    $this->assertSame('DESC', $display['display_options']['sorts']['created']['order']);
+
+    // Test the identifier uniqueness.
+    $this->drupalGet("admin/structure/views/nojs/handler/{$view_id}/default/sort/created_1");
+    $this->submitForm([], 'Expose sort');
+    $this->submitForm([
+      'options[expose][field_identifier]' => $edit['options[expose][field_identifier]'],
+    ], 'Apply');
+    $this->assertSession()->pageTextContains('This identifier is already used by Content: Authored on sort handler.');
   }
 
   /**
diff --git a/core/profiles/demo_umami/config/install/views.view.articles_aside.yml b/core/profiles/demo_umami/config/install/views.view.articles_aside.yml
index 7a9f37d8bb5e..b1821bc6e239 100644
--- a/core/profiles/demo_umami/config/install/views.view.articles_aside.yml
+++ b/core/profiles/demo_umami/config/install/views.view.articles_aside.yml
@@ -189,6 +189,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
         nid:
           id: nid
@@ -201,6 +202,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: nid
           entity_type: node
           entity_field: nid
           plugin_id: standard
diff --git a/core/profiles/demo_umami/config/install/views.view.featured_articles.yml b/core/profiles/demo_umami/config/install/views.view.featured_articles.yml
index e643082cd3a0..a547a78769f9 100644
--- a/core/profiles/demo_umami/config/install/views.view.featured_articles.yml
+++ b/core/profiles/demo_umami/config/install/views.view.featured_articles.yml
@@ -202,6 +202,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
         nid:
           id: nid
@@ -214,6 +215,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: nid
           entity_type: node
           entity_field: nid
           plugin_id: standard
diff --git a/core/profiles/demo_umami/config/install/views.view.frontpage.yml b/core/profiles/demo_umami/config/install/views.view.frontpage.yml
index af7dd1167c02..f614159078d9 100644
--- a/core/profiles/demo_umami/config/install/views.view.frontpage.yml
+++ b/core/profiles/demo_umami/config/install/views.view.frontpage.yml
@@ -229,6 +229,7 @@ display:
           admin_label: ''
           expose:
             label: ''
+            field_identifier: sticky
           exposed: false
           field: sticky
           group_type: group
@@ -251,6 +252,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
           entity_type: node
           entity_field: created
@@ -265,6 +267,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: nid
           entity_type: node
           entity_field: nid
           plugin_id: standard
diff --git a/core/profiles/demo_umami/config/install/views.view.promoted_items.yml b/core/profiles/demo_umami/config/install/views.view.promoted_items.yml
index 2dbaf939619e..7d1262f62c2a 100644
--- a/core/profiles/demo_umami/config/install/views.view.promoted_items.yml
+++ b/core/profiles/demo_umami/config/install/views.view.promoted_items.yml
@@ -220,6 +220,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
       title: 'Promoted Items Double'
       header: {  }
diff --git a/core/profiles/demo_umami/config/install/views.view.recipe_collections.yml b/core/profiles/demo_umami/config/install/views.view.recipe_collections.yml
index 1952fc3c4174..9113af5ef799 100644
--- a/core/profiles/demo_umami/config/install/views.view.recipe_collections.yml
+++ b/core/profiles/demo_umami/config/install/views.view.recipe_collections.yml
@@ -178,6 +178,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: name
           entity_type: taxonomy_term
           entity_field: name
           plugin_id: standard
diff --git a/core/profiles/demo_umami/config/install/views.view.recipes.yml b/core/profiles/demo_umami/config/install/views.view.recipes.yml
index e3cdb98cb1c1..80af602c9a05 100644
--- a/core/profiles/demo_umami/config/install/views.view.recipes.yml
+++ b/core/profiles/demo_umami/config/install/views.view.recipes.yml
@@ -202,6 +202,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
         nid:
           id: nid
@@ -214,6 +215,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: nid
           entity_type: node
           entity_field: nid
           plugin_id: standard
diff --git a/core/profiles/demo_umami/config/install/views.view.taxonomy_term.yml b/core/profiles/demo_umami/config/install/views.view.taxonomy_term.yml
index 79b65de101d9..7b8355922d3a 100644
--- a/core/profiles/demo_umami/config/install/views.view.taxonomy_term.yml
+++ b/core/profiles/demo_umami/config/install/views.view.taxonomy_term.yml
@@ -77,6 +77,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: sticky
         created:
           id: created
           table: taxonomy_index
@@ -89,6 +90,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
       arguments:
         tid:
diff --git a/core/profiles/demo_umami/config/optional/views.view.media.yml b/core/profiles/demo_umami/config/optional/views.view.media.yml
index 17518dc84a50..df4df46c72c9 100644
--- a/core/profiles/demo_umami/config/optional/views.view.media.yml
+++ b/core/profiles/demo_umami/config/optional/views.view.media.yml
@@ -845,6 +845,7 @@ display:
           exposed: false
           expose:
             label: ''
+            field_identifier: created
           granularity: second
       title: Media
       header: {  }
diff --git a/core/themes/claro/css/components/views-ui.css b/core/themes/claro/css/components/views-ui.css
index 56fda12d7f48..6fc7a1737587 100644
--- a/core/themes/claro/css/components/views-ui.css
+++ b/core/themes/claro/css/components/views-ui.css
@@ -127,12 +127,14 @@ details.fieldset-no-legend {
 
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-left: 1.5em; /* LTR */
 }
 
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 1.5em;
   margin-left: 0;
@@ -144,6 +146,7 @@ details.fieldset-no-legend {
 .views-admin-dependent .form-item .form-item,
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 0.375rem;
   margin-bottom: 0.375rem;
diff --git a/core/themes/claro/css/components/views-ui.pcss.css b/core/themes/claro/css/components/views-ui.pcss.css
index 84709902fe7b..9e8a723ab059 100644
--- a/core/themes/claro/css/components/views-ui.pcss.css
+++ b/core/themes/claro/css/components/views-ui.pcss.css
@@ -110,11 +110,13 @@ details.fieldset-no-legend {
  */
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-left: 1.5em; /* LTR */
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 1.5em;
   margin-left: 0;
@@ -126,6 +128,7 @@ details.fieldset-no-legend {
 .views-admin-dependent .form-item .form-item,
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
diff --git a/core/themes/claro/css/theme/views_ui.admin.theme.css b/core/themes/claro/css/theme/views_ui.admin.theme.css
index b3f21bb4e187..76fd7b7de661 100644
--- a/core/themes/claro/css/theme/views_ui.admin.theme.css
+++ b/core/themes/claro/css/theme/views_ui.admin.theme.css
@@ -663,6 +663,7 @@ td.group-title {
 
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 0.375rem;
   margin-bottom: 0.375rem;
@@ -671,6 +672,7 @@ td.group-title {
 
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 1.125rem;
   margin-left: 0;
diff --git a/core/themes/claro/css/theme/views_ui.admin.theme.pcss.css b/core/themes/claro/css/theme/views_ui.admin.theme.pcss.css
index 3b25581b811c..3c0ff632501d 100644
--- a/core/themes/claro/css/theme/views_ui.admin.theme.pcss.css
+++ b/core/themes/claro/css/theme/views_ui.admin.theme.pcss.css
@@ -546,6 +546,7 @@ td.group-title {
 }
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
@@ -553,6 +554,7 @@ td.group-title {
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 18px;
   margin-left: 0;
diff --git a/core/themes/seven/css/components/views-ui.css b/core/themes/seven/css/components/views-ui.css
index bb79f4d1619b..912c8257b4cd 100644
--- a/core/themes/seven/css/components/views-ui.css
+++ b/core/themes/seven/css/components/views-ui.css
@@ -64,11 +64,13 @@ details.fieldset-no-legend {
  */
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-left: 1.5em; /* LTR */
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 1.5em;
   margin-left: 0;
@@ -80,6 +82,7 @@ details.fieldset-no-legend {
 .views-admin-dependent .form-item .form-item,
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
diff --git a/core/themes/stable/css/views_ui/views_ui.admin.theme.css b/core/themes/stable/css/views_ui/views_ui.admin.theme.css
index 32de3f97c5f7..39fc644fea43 100644
--- a/core/themes/stable/css/views_ui/views_ui.admin.theme.css
+++ b/core/themes/stable/css/views_ui/views_ui.admin.theme.css
@@ -682,6 +682,7 @@ td.group-title {
 }
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
@@ -689,6 +690,7 @@ td.group-title {
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 18px;
   margin-left: 0;
diff --git a/core/themes/stable9/css/views_ui/views_ui.admin.theme.css b/core/themes/stable9/css/views_ui/views_ui.admin.theme.css
index dd988dfe99d2..344e974012c6 100644
--- a/core/themes/stable9/css/views_ui/views_ui.admin.theme.css
+++ b/core/themes/stable9/css/views_ui/views_ui.admin.theme.css
@@ -682,6 +682,7 @@ td.group-title {
 }
 .form-item-options-expose-required,
 .form-item-options-expose-label,
+.form-item-options-expose-field-identifier,
 .form-item-options-expose-description {
   margin-top: 6px;
   margin-bottom: 6px;
@@ -689,6 +690,7 @@ td.group-title {
 }
 [dir="rtl"] .form-item-options-expose-required,
 [dir="rtl"] .form-item-options-expose-label,
+[dir="rtl"] .form-item-options-expose-field-identifier,
 [dir="rtl"] .form-item-options-expose-description {
   margin-right: 18px;
   margin-left: 0;
-- 
GitLab