Skip to content
Snippets Groups Projects
Verified Commit ded815fb authored by Lauri Timmanee's avatar Lauri Timmanee
Browse files

Issue #3370113 by Utkarsh_33, lauriii, omkar.podey, tedbow, bnjmnm, ckrina,...

Issue #3370113 by Utkarsh_33, lauriii, omkar.podey, tedbow, bnjmnm, ckrina, smustgrave, longwave, hooroomoo, srishtiiee, yoroy: Make it easier to enter multiple values to fields allowing unlimited values
parent eaca861b
No related branches found
No related tags found
42 merge requests!54479.5.x SF update,!5014Issue #3071143: Table Render Array Example Is Incorrect,!4868Issue #1428520: Improve menu parent link selection,!3878Removed unused condition head title for views,!38582585169-10.1.x,!3818Issue #2140179: $entity->original gets stale between updates,!3742Issue #3328429: Create item list field formatter for displaying ordered and unordered lists,!3731Claro: role=button on status report items,!3668Resolve #3347842 "Deprecate the trusted",!3651Issue #3347736: Create new SDC component for Olivero (header-search),!3546refactored dialog.pcss file,!3531Issue #3336994: StringFormatter always displays links to entity even if the user in context does not have access,!3502Issue #3335308: Confusing behavior with FormState::setFormState and FormState::setMethod,!3452Issue #3332701: Refactor Claro's tablesort-indicator stylesheet,!3451Issue #2410579: Allows setting the current language programmatically.,!3355Issue #3209129: Scrolling problems when adding a block via layout builder,!3226Issue #2987537: Custom menu link entity type should not declare "bundle" entity key,!3154Fixes #2987987 - CSRF token validation broken on routes with optional parameters.,!3147Issue #3328457: Replace most substr($a, $i) where $i is negative with str_ends_with(),!3146Issue #3328456: Replace substr($a, 0, $i) with str_starts_with(),!3133core/modules/system/css/components/hidden.module.css,!31312878513-10.1.x,!2964Issue #2865710 : Dependencies from only one instance of a widget are used in display modes,!2812Issue #3312049: [Followup] Fix Drupal.Commenting.FunctionComment.MissingReturnType returns for NULL,!2614Issue #2981326: Replace non-test usages of \Drupal::logger() with IoC injection,!2378Issue #2875033: Optimize joins and table selection in SQL entity query implementation,!2334Issue #3228209: Add hasRole() method to AccountInterface,!2062Issue #3246454: Add weekly granularity to views date sort,!1591Issue #3199697: Add JSON:API Translation experimental module,!1255Issue #3238922: Refactor (if feasible) uses of the jQuery serialize function to use vanillaJS,!1105Issue #3025039: New non translatable field on translatable content throws error,!1073issue #3191727: Focus states on mobile second level navigation items fixed,!10223132456: Fix issue where views instances are emptied before an ajax request is complete,!877Issue #2708101: Default value for link text is not saved,!844Resolve #3036010 "Updaters",!673Issue #3214208: FinishResponseSubscriber could create duplicate headers,!617Issue #3043725: Provide a Entity Handler for user cancelation,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493,!485Sets the autocomplete attribute for username/password input field on login form.,!30Issue #3182188: Updates composer usage to point at ./vendor/bin/composer
...@@ -953,3 +953,11 @@ js-cookie: ...@@ -953,3 +953,11 @@ js-cookie:
js: js:
assets/vendor/js-cookie/js.cookie.min.js: {} assets/vendor/js-cookie/js.cookie.min.js: {}
deprecated: The %library_id% asset library is deprecated in Drupal 10.1.0 and will be removed in Drupal 11.0.0. There is no replacement. See https://www.drupal.org/node/3322720 deprecated: The %library_id% asset library is deprecated in Drupal 10.1.0 and will be removed in Drupal 11.0.0. There is no replacement. See https://www.drupal.org/node/3322720
drupal.fieldListKeyboardNavigation:
version: VERSION
js:
misc/field-list-keyboard-navigation.js: {}
dependencies:
- core/drupal
- core/tabbable
/**
* @file
* Attaches behaviors for Drupal's field list keyboard navigation.
*/
(function (Drupal, { isFocusable }) {
/**
* Attaches the focus shifting functionality.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches behaviors.
*/
Drupal.behaviors.fieldListKeyboardNavigation = {
attach() {
once(
'keyboardNavigation',
'input[type="text"], input[type="number"]',
document.querySelector('[data-field-list-table]'),
).forEach((element) =>
element.addEventListener('keypress', (event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const currentElement = event.target;
// Function to find the next focusable element.
const findNextFocusableElement = (element) => {
const currentRow = element.closest('tr');
const inputElements = currentRow.querySelectorAll(
'input[type="text"], input[type="number"]',
);
const afterIndex = [...inputElements].indexOf(element) + 1;
// eslint-disable-next-line no-restricted-syntax
for (const inputElement of [...inputElements].slice(afterIndex)) {
if (isFocusable(inputElement)) {
return inputElement;
}
}
const nextRow = currentRow.nextElementSibling;
if (nextRow) {
return findNextFocusableElement(nextRow);
}
return null;
};
const nextFocusableElement = findNextFocusableElement(currentElement);
// If a focusable element is found, move focus there.
if (nextFocusableElement) {
nextFocusableElement.focus();
// Move cursor to the end of the input.
const value = nextFocusableElement.value;
nextFocusableElement.value = '';
nextFocusableElement.value = value;
return;
}
// If no focusable element is found, add another item to the list.
event.target
.closest('[data-field-list-table]')
.parentNode.querySelector('[data-field-list-button]')
.dispatchEvent(new Event('mousedown'));
}),
);
},
};
})(Drupal, window.tabbable);
...@@ -207,7 +207,21 @@ ...@@ -207,7 +207,21 @@
'<span class="admin-link"><button type="button" class="link" aria-label="' '<span class="admin-link"><button type="button" class="link" aria-label="'
.concat(Drupal.t('Edit machine name'), '">') .concat(Drupal.t('Edit machine name'), '">')
.concat(Drupal.t('Edit'), '</button></span>'), .concat(Drupal.t('Edit'), '</button></span>'),
).on('click', eventData, clickEditHandler); )
.on('click', eventData, clickEditHandler)
.on('keyup', (e) => {
// Avoid propagating a keyup event from the machine name input.
if (e.key === 'Enter' || eventData.code === 'Space') {
e.preventDefault();
e.stopImmediatePropagation();
e.target.click();
}
})
.on('keydown', (e) => {
if (e.key === 'Enter' || eventData.code === 'Space') {
e.preventDefault();
}
});
$suffix.append($link); $suffix.append($link);
// Preview the machine name in realtime when the human-readable name // Preview the machine name in realtime when the human-readable name
......
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\FocusFirstCommand;
use Drupal\Core\Ajax\InsertCommand;
use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemBase; use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\FormStateInterface;
...@@ -118,6 +121,7 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state ...@@ -118,6 +121,7 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state
], ],
'#attributes' => [ '#attributes' => [
'id' => 'allowed-values-order', 'id' => 'allowed-values-order',
'data-field-list-table' => TRUE,
], ],
'#tabledrag' => [ '#tabledrag' => [
[ [
...@@ -126,6 +130,9 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state ...@@ -126,6 +130,9 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state
'group' => 'weight', 'group' => 'weight',
], ],
], ],
'#attached' => [
'library' => ['core/drupal.fieldListKeyboardNavigation'],
],
]; ];
$max = $form_state->get('items_count'); $max = $form_state->get('items_count');
...@@ -196,12 +203,14 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state ...@@ -196,12 +203,14 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state
} }
} }
$element['allowed_values']['table']['#max_delta'] = $max; $element['allowed_values']['table']['#max_delta'] = $max;
$element['allowed_values']['add_more_allowed_values'] = [ $element['allowed_values']['add_more_allowed_values'] = [
'#type' => 'submit', '#type' => 'submit',
'#name' => 'add_more_allowed_values', '#name' => 'add_more_allowed_values',
'#value' => $this->t('Add another item'), '#value' => $this->t('Add another item'),
'#attributes' => ['class' => ['field-add-more-submit']], '#attributes' => [
'class' => ['field-add-more-submit'],
'data-field-list-button' => TRUE,
],
// Allow users to add another row without requiring existing rows to have // Allow users to add another row without requiring existing rows to have
// values. // values.
'#limit_validation_errors' => [], '#limit_validation_errors' => [],
...@@ -210,6 +219,10 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state ...@@ -210,6 +219,10 @@ public function storageSettingsForm(array &$form, FormStateInterface $form_state
'callback' => [static::class, 'addMoreAjax'], 'callback' => [static::class, 'addMoreAjax'],
'wrapper' => $wrapper_id, 'wrapper' => $wrapper_id,
'effect' => 'fade', 'effect' => 'fade',
'progress' => [
'type' => 'throbber',
'message' => $this->t('Adding a new item...'),
],
], ],
]; ];
...@@ -246,10 +259,14 @@ public static function addMoreAjax(array $form, FormStateInterface $form_state) ...@@ -246,10 +259,14 @@ public static function addMoreAjax(array $form, FormStateInterface $form_state)
// Go one level up in the form. // Go one level up in the form.
$element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
$delta = $element['table']['#max_delta']; $delta = $element['table']['#max_delta'];
$element['table'][$delta]['item']['#prefix'] = '<div class="ajax-new-content">' . ($element['table'][$delta]['item']['#prefix'] ?? ''); $element['table'][$delta]['item']['#prefix'] = '<div class="ajax-new-content" data-drupal-selector="field-list-add-more-focus-target">' . ($element['table'][$delta]['item']['#prefix'] ?? '');
$element['table'][$delta]['item']['#suffix'] = ($element['table'][$delta]['item']['#suffix'] ?? '') . '</div>'; $element['table'][$delta]['item']['#suffix'] = ($element['table'][$delta]['item']['#suffix'] ?? '') . '</div>';
return $element; $response = new AjaxResponse();
$response->addCommand(new InsertCommand(NULL, $element));
$response->addCommand(new FocusFirstCommand('[data-drupal-selector="field-list-add-more-focus-target"]'));
return $response;
} }
/** /**
......
...@@ -79,7 +79,8 @@ protected function setUp(): void { ...@@ -79,7 +79,8 @@ protected function setUp(): void {
* *
* @dataProvider providerTestOptionsAllowedValues * @dataProvider providerTestOptionsAllowedValues
*/ */
public function testOptionsAllowedValues($option_type, $options, $is_string_option) { public function testOptionsAllowedValues($option_type, $options, $is_string_option, string $add_row_method) {
$assert = $this->assertSession();
$this->fieldName = 'field_options_text'; $this->fieldName = 'field_options_text';
$this->createOptionsField($option_type); $this->createOptionsField($option_type);
$page = $this->getSession()->getPage(); $page = $this->getSession()->getPage();
...@@ -87,15 +88,79 @@ public function testOptionsAllowedValues($option_type, $options, $is_string_opti ...@@ -87,15 +88,79 @@ public function testOptionsAllowedValues($option_type, $options, $is_string_opti
$this->drupalGet($this->adminPath); $this->drupalGet($this->adminPath);
$i = 0; $i = 0;
$expected_rows = 1;
$this->assertAllowValuesRowCount(1);
foreach ($options as $option_key => $option_label) { foreach ($options as $option_key => $option_label) {
$page->fillField("settings[allowed_values][table][$i][item][label]", $option_label); $enter_element_name = $label_element_name = "settings[allowed_values][table][$i][item][label]";
$page->fillField($label_element_name, $option_label);
$key_element_name = "settings[allowed_values][table][$i][item][key]";
// Add keys if not string option list. // Add keys if not string option list.
if (!$is_string_option) { if (!$is_string_option) {
$page->fillField("settings[allowed_values][table][$i][item][key]", $option_key); $this->pressEnterOnElement("[name=\"$label_element_name\"]");
// Assert that pressing enter on label field does not create the new
// row if the key field is visible.
$this->assertAllowValuesRowCount($expected_rows);
$enter_element_name = $key_element_name;
$this->assertHasFocusByAttribute('name', $key_element_name);
$page->fillField($key_element_name, $option_key);
}
else {
$this->assertFalse($assert->fieldExists($key_element_name)->isVisible());
} }
$page->pressButton('Add another item'); switch ($add_row_method) {
case 'Press button':
$page->pressButton('Add another item');
break;
case 'Enter button':
$button = $assert->buttonExists('Add another item');
$this->pressEnterOnElement('[data-drupal-selector="' . $button->getAttribute('data-drupal-selector') . '"]');
break;
case 'Enter element':
// If testing using the "enter" key while focused on element there a
// few different scenarios to test.
switch ($i) {
case 0:
// For string options the machine name input can be exposed which
// will mean the label input will no longer create the next row.
if ($is_string_option) {
$this->exposeOptionMachineName($expected_rows);
$this->pressEnterOnElement("[name=\"$enter_element_name\"]");
$this->assertHasFocusByAttribute('name', $key_element_name);
// Ensure that pressing enter while focused on the label input
// did not create a new row if the machine name field is
// visible.
$this->assertAllowValuesRowCount($expected_rows);
$enter_element_name = $key_element_name;
}
break;
}
$this->pressEnterOnElement("[name=\"$enter_element_name\"]");
break;
default:
throw new \UnexpectedValueException("Unknown method $add_row_method");
}
$i++; $i++;
$expected_rows++;
$this->assertSession()->waitForElementVisible('css', "[name='settings[allowed_values][table][$i][item][label]']"); $this->assertSession()->waitForElementVisible('css', "[name='settings[allowed_values][table][$i][item][label]']");
$this->assertHasFocusByAttribute('name', "settings[allowed_values][table][$i][item][label]");
$this->assertAllowValuesRowCount($expected_rows);
if ($is_string_option) {
// Expose the key input for string options for the previous row to test
// shifting focus from the label to key inputs on the previous row by
// pressing enter.
$this->exposeOptionMachineName($expected_rows - 1);
}
// Test that pressing enter on the label input on previous row will shift
// focus to key input of that row.
$this->pressEnterOnElement("[name=\"$label_element_name\"]");
$this->assertHasFocusByAttribute('name', $key_element_name);
$this->assertAllowValuesRowCount($expected_rows);
} }
$page->pressButton('Save field settings'); $page->pressButton('Save field settings');
...@@ -200,6 +265,21 @@ protected function createOptionsField($type) { ...@@ -200,6 +265,21 @@ protected function createOptionsField($type) {
$this->adminPath = 'admin/structure/types/manage/' . $this->type . '/fields/node.' . $this->type . '.' . $this->fieldName . '/storage'; $this->adminPath = 'admin/structure/types/manage/' . $this->type . '/fields/node.' . $this->type . '.' . $this->fieldName . '/storage';
} }
/**
* Presses "Enter" on the specified element.
*
* @param string $selector
* Current element having focus.
*/
private function pressEnterOnElement(string $selector): void {
$javascript = <<<JS
const element = document.querySelector('$selector');
const event = new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true });
element.dispatchEvent(event);
JS;
$this->getSession()->executeScript($javascript);
}
/** /**
* Data provider for testOptionsAllowedValues(). * Data provider for testOptionsAllowedValues().
* *
...@@ -208,9 +288,11 @@ protected function createOptionsField($type) { ...@@ -208,9 +288,11 @@ protected function createOptionsField($type) {
* - Option type. * - Option type.
* - Array of option type values. * - Array of option type values.
* - Whether option type is string type or not. * - Whether option type is string type or not.
* - The method which should be used to add another row to the table. The
* possible values are 'Press button', 'Enter button' or 'Enter element'.
*/ */
public function providerTestOptionsAllowedValues() { public function providerTestOptionsAllowedValues() {
return [ $type_cases = [
'List integer' => [ 'List integer' => [
'list_integer', 'list_integer',
[1 => 'First', 2 => 'Second', 3 => 'Third'], [1 => 'First', 2 => 'Second', 3 => 'Third'],
...@@ -227,6 +309,62 @@ public function providerTestOptionsAllowedValues() { ...@@ -227,6 +309,62 @@ public function providerTestOptionsAllowedValues() {
TRUE, TRUE,
], ],
]; ];
// Test adding options for each option field type using several possible
// methods that could be used for navigating the options list:
// - Press button: add a new item by pressing the 'Add another item'
// button using mouse.
// - Enter button: add a new item by pressing the 'Add another item'
// button using enter key on the keyboard.
// - Enter element: add a new item by pressing enter on the last text
// field inside the table.
$test_cases = [];
foreach ($type_cases as $key => $type_case) {
foreach (['Press button', 'Enter button', 'Enter element'] as $add_more_method) {
$test_cases["$key: $add_more_method"] = array_merge($type_case, [$add_more_method]);
}
}
return $test_cases;
}
/**
* Assert the count of the allowed values rows.
*
* @param int $expected_count
* The expected row count.
*/
private function assertAllowValuesRowCount(int $expected_count): void {
$this->assertCount(
$expected_count,
$this->getSession()->getPage()->findAll('css', '#allowed-values-order tr.draggable')
);
}
/**
* Asserts an element specified by an attribute value has focus.
*
* @param string $name
* The attribute name.
* @param string $value
* The attribute value.
*
* @todo Replace with assertHasFocus() in https://drupal.org/i/3041768.
*/
private function assertHasFocusByAttribute(string $name, string $value): void {
$active_element = $this->getSession()->evaluateScript('document.activeElement');
$this->assertSame($value, $active_element->getAttribute($name));
}
/**
* Exposes the machine name input for a row.
*
* @param int $row
* The row number.
*/
private function exposeOptionMachineName(int $row): void {
$index = $row - 1;
$rows = $this->getSession()->getPage()->findAll('css', '#allowed-values-order tr.draggable');
$this->assertSession()->buttonExists('Edit', $rows[$index])->click();
$this->assertSession()->waitForElementVisible('css', "[name='settings[allowed_values][table][$index][item][key]']");
} }
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment