From 91d2e1e4eb4708e10ebb91ab0c1cca5c1d19d59f Mon Sep 17 00:00:00 2001 From: Edmund Dunn <109987005+edmund-dunn@users.noreply.github.com> Date: Wed, 10 Jan 2024 13:52:19 -0800 Subject: [PATCH] VACMS-15785: alt text validation (#15787) * VACMS-15435: hard validation for alt text field * VACMS-15436: clientside validation for alt text field * VACMS-15436: updated composer.lock * VACMS-15436: fix linting errors * VACMS-15436: one more linting error fixed * VACMS-15436: more linting fixes * VACMS-15785: updated composer.lock * VACMS-15962: theming changes for image form * VACMS-15962: fix linting errors * VACMS-15962: final fix * VACMS-15783: Alt-Text Validation Testing (#16159) * VACMS-15783 adds alt-text validation tests * VACMS-15785: update composer.lock, fix merge error * VACMS-15785: fix for spacing around error * VACMS-15785: updated composer.lock * Updates content * smdh --------- Co-authored-by: Tony Taylor Co-authored-by: Nathan Douglas --- composer.json | 5 + composer.lock | 78 ++++++- .../sync/clientside_validation.settings.yml | 4 + .../clientside_validation_jquery.settings.yml | 6 + ...ntity_form_display.media.image.default.yml | 24 +- config/sync/core.extension.yml | 2 + .../design-system/components/input/input.scss | 3 +- .../components/tokens/_variables.scss | 2 +- .../EventSubscriber/ThemeEventSubscriber.php | 19 -- .../js/alt_text_validation.es6.js | 78 +++++++ .../va_gov_media/js/alt_text_validation.js | 52 +++++ .../AltTextValidationController.php | 66 ++++++ .../EventSubscriber/MediaEventSubscriber.php | 219 ++++++++++++++++++ .../custom/va_gov_media/va_gov_media.info.yml | 4 + .../va_gov_media/va_gov_media.libraries.yml | 5 + .../custom/va_gov_media/va_gov_media.module | 21 +- .../va_gov_media/va_gov_media.routing.yml | 8 + .../va_gov_media/va_gov_media.services.yml | 8 + .../assets/scss/components/_fields.scss | 11 + .../assets/scss/components/_media.scss | 34 +++ .../content-edit/file-managed-file.html.twig | 25 +- patches/2949540-31.patch | 219 ++++++++++++++++++ phpstan-baseline.neon | 5 + .../platform/alt_text_validation.feature | 42 ++++ .../common/i_create_an_image_with_alt_text.js | 59 +++++ .../common/i_save_an_image_with_alt_text.js | 45 ++++ 26 files changed, 987 insertions(+), 57 deletions(-) create mode 100644 config/sync/clientside_validation.settings.yml create mode 100644 config/sync/clientside_validation_jquery.settings.yml create mode 100644 docroot/modules/custom/va_gov_media/js/alt_text_validation.es6.js create mode 100644 docroot/modules/custom/va_gov_media/js/alt_text_validation.js create mode 100644 docroot/modules/custom/va_gov_media/src/Controller/AltTextValidationController.php create mode 100644 docroot/modules/custom/va_gov_media/src/EventSubscriber/MediaEventSubscriber.php create mode 100644 docroot/modules/custom/va_gov_media/va_gov_media.libraries.yml create mode 100644 docroot/modules/custom/va_gov_media/va_gov_media.routing.yml create mode 100644 docroot/modules/custom/va_gov_media/va_gov_media.services.yml create mode 100644 patches/2949540-31.patch create mode 100644 tests/cypress/integration/features/platform/alt_text_validation.feature create mode 100644 tests/cypress/integration/step_definitions/common/i_create_an_image_with_alt_text.js create mode 100644 tests/cypress/integration/step_definitions/common/i_save_an_image_with_alt_text.js diff --git a/composer.json b/composer.json index 7dd19e59c5..372eda027c 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "drupal/block_content_permissions": "^1.6", "drupal/cer": "^5.0@beta", "drupal/change_labels": "dev-3326097-remove-dependency-on-drupal-autoservices#7f92f90b456ac2f394dd434257e39e1d9b3086eb", + "drupal/clientside_validation": "^4.0", "drupal/ckeditor_abbreviation": "^4.0@alpha", "drupal/coder": "^8.3", "drupal/codit_menu_tools": "^1.0@alpha", @@ -204,6 +205,7 @@ "mikey179/vfsstream": "^1.6", "mnsami/composer-custom-directory-installer": "^2.0", "npm-asset/dropzone": "^5.5", + "npm-asset/jquery-validation": "^1.17", "npm-asset/yarn": "1.19.1", "oomphinc/composer-installers-extender": "^2.0", "orakili/composer-drupal-info-file-patch-helper": "*", @@ -366,6 +368,9 @@ "3200122 - Remove delete hook": "https://www.drupal.org/files/issues/2021-02-24/delete-hook-added-in-dev-causes-test-failures.patch", "3254663 - Notice: Undefined index: target_bundles on Drupal\\cer\\Entity\\CorrespondingReference->synchronizeCorrespondingField()": "https://www.drupal.org/files/issues/2022-02-14/prevent-undefined-index-3254663-6.patch" }, + "drupal/clientside_validation": { + "2949540 - Allow specific form ids for clientside validation": "https://www.drupal.org/files/issues/2023-10-27/2949540-31.patch" + }, "drupal/consumer_image_styles": { "3301224 - Follow-up: Very slow JSON:API responses when images are stored on AWS bucket": "https://www.drupal.org/files/issues/2023-02-07/3301224-9.patch" }, diff --git a/composer.lock b/composer.lock index 566767364a..c7f79601c0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dd7a8b38039836aeebdc472d433eaf95", + "content-hash": "519122c85d79517c409d1c39cf3c0b00", "packages": [ { "name": "asm89/stack-cors", @@ -2990,6 +2990,68 @@ "issues": "http://drupal.org/project/issues/ckeditor_abbreviation" } }, + { + "name": "drupal/clientside_validation", + "version": "4.0.2", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/clientside_validation.git", + "reference": "4.0.2" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/clientside_validation-4.0.2.zip", + "reference": "4.0.2", + "shasum": "bfaf0fa81d645427c1b3ccfd2d5e493a10b7f483" + }, + "require": { + "drupal/core": "^9.4 || ^10.0", + "php": ">=7.4.0" + }, + "require-dev": { + "drupal/clientside_validation_demo": "*", + "drupal/clientside_validation_jquery": "*" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "4.0.2", + "datestamp": "1676011269", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "attiks", + "homepage": "https://www.drupal.org/user/105002" + }, + { + "name": "Jelle_S", + "homepage": "https://www.drupal.org/user/829198" + }, + { + "name": "joseph.olstad", + "homepage": "https://www.drupal.org/user/1321830" + }, + { + "name": "nikunjkotecha", + "homepage": "https://www.drupal.org/user/694082" + } + ], + "description": "This module adds clientside validation", + "homepage": "https://www.drupal.org/project/clientside_validation", + "support": { + "source": "https://git.drupalcode.org/project/clientside_validation", + "issues": "https://drupal.org/project/issues/clientside_validation" + } + }, { "name": "drupal/coder", "version": "8.3.22", @@ -17070,6 +17132,18 @@ "MIT" ] }, + { + "name": "npm-asset/jquery-validation", + "version": "1.20.0", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/jquery-validation/-/jquery-validation-1.20.0.tgz" + }, + "type": "npm-asset", + "license": [ + "MIT" + ] + }, { "name": "npm-asset/yarn", "version": "1.19.1", @@ -26771,5 +26845,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/sync/clientside_validation.settings.yml b/config/sync/clientside_validation.settings.yml new file mode 100644 index 0000000000..4814ea29e3 --- /dev/null +++ b/config/sync/clientside_validation.settings.yml @@ -0,0 +1,4 @@ +_core: + default_config_hash: GfA9lmRURupNRAARLoOTo1Ihq1-M3ktT0sKSjUcL2sw +enable_all_forms: true +enabled_forms: { } diff --git a/config/sync/clientside_validation_jquery.settings.yml b/config/sync/clientside_validation_jquery.settings.yml new file mode 100644 index 0000000000..1c68f90ce1 --- /dev/null +++ b/config/sync/clientside_validation_jquery.settings.yml @@ -0,0 +1,6 @@ +_core: + default_config_hash: 3YUV4RQQ4k8drO7uzYJ7lNc5Az0iDAH5YW8KbZVxjeY +use_cdn: false +cdn_base_url: 'https://cdn.jsdelivr.net/npm/jquery-validation@1.17.0/dist/' +validate_all_ajax_forms: 2 +force_validate_on_blur: 0 diff --git a/config/sync/core.entity_form_display.media.image.default.yml b/config/sync/core.entity_form_display.media.image.default.yml index 1b2576ac17..ed14648e9b 100644 --- a/config/sync/core.entity_form_display.media.image.default.yml +++ b/config/sync/core.entity_form_display.media.image.default.yml @@ -8,9 +8,10 @@ dependencies: - field.field.media.image.field_media_submission_guideline - field.field.media.image.field_owner - field.field.media.image.image - - image.style.3_2_medium_thumbnail + - image.style.full_content_width - media.type.image module: + - change_labels - field_group - image_widget_crop - markup @@ -47,6 +48,7 @@ content: maxlength: 300 counter_position: after js_prevent_submit: true + count_only_mode: false count_html_characters: true textcount_status_message: 'Characters Remaining: @remaining_count' third_party_settings: { } @@ -68,14 +70,23 @@ content: region: content settings: progress_indicator: throbber - preview_image_style: 3_2_medium_thumbnail + preview_image_style: full_content_width crop_preview_image_style: crop_thumbnail - crop_list: { } + crop_list: + - '2_1' + - '2_3' + - '3_2' + - '7_2' + - freeform + - original + - square crop_types_required: { } warn_multiple_usages: false show_crop_area: true show_default_crop: true - third_party_settings: { } + third_party_settings: + change_labels: + remove_label: '' name: type: string_textfield weight: 1 @@ -84,6 +95,11 @@ content: size: 60 placeholder: '' third_party_settings: { } + translation: + weight: 10 + region: content + settings: { } + third_party_settings: { } hidden: created: true field_media_in_library: true diff --git a/config/sync/core.extension.yml b/config/sync/core.extension.yml index 67e9b28403..d7ad4a2d38 100644 --- a/config/sync/core.extension.yml +++ b/config/sync/core.extension.yml @@ -24,6 +24,8 @@ module: change_labels: 0 ckeditor5: 0 ckeditor_abbreviation: 0 + clientside_validation: 0 + clientside_validation_jquery: 0 codit_menu_tools: 0 components: 0 computed_breadcrumbs: 0 diff --git a/docroot/design-system/components/input/input.scss b/docroot/design-system/components/input/input.scss index 50cc01ec0d..9ab24f9452 100644 --- a/docroot/design-system/components/input/input.scss +++ b/docroot/design-system/components/input/input.scss @@ -23,5 +23,6 @@ } .form-item--error-message { - color: var(--va-red-bright); + color: var(--va-red-dark); + margin: 5px 0; } diff --git a/docroot/design-system/components/tokens/_variables.scss b/docroot/design-system/components/tokens/_variables.scss index a86e64bfa4..fbbc25b89c 100644 --- a/docroot/design-system/components/tokens/_variables.scss +++ b/docroot/design-system/components/tokens/_variables.scss @@ -95,7 +95,7 @@ --color-absolutezero-hover: var(--va-blue-dark); --color-absolutezero-active: var(--va-blue-darker); --color-sunglow: var(--va-gold-med); - --color-maximumred: var(--va-red-bright); + --color-maximumred: var(--va-red-dark); --color-lightninggreen: var(--va-green); --color-lightgray: var(--va-gray-lighter); --color-whitesmoke: var(--va-gray-lightest); diff --git a/docroot/modules/custom/va_gov_backend/src/EventSubscriber/ThemeEventSubscriber.php b/docroot/modules/custom/va_gov_backend/src/EventSubscriber/ThemeEventSubscriber.php index d3b8ab3f77..70de248527 100644 --- a/docroot/modules/custom/va_gov_backend/src/EventSubscriber/ThemeEventSubscriber.php +++ b/docroot/modules/custom/va_gov_backend/src/EventSubscriber/ThemeEventSubscriber.php @@ -7,9 +7,6 @@ use Drupal\core_event_dispatcher\Event\Theme\ThemeSuggestionsAlterEvent; use Drupal\core_event_dispatcher\FormHookEvents; use Drupal\core_event_dispatcher\ThemeHookEvents; -use Drupal\field_event_dispatcher\Event\Field\WidgetSingleElementFormAlterEvent; -use Drupal\field_event_dispatcher\FieldHookEvents; -use Drupal\image\Plugin\Field\FieldWidget\ImageWidget; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -22,27 +19,11 @@ class ThemeEventSubscriber implements EventSubscriberInterface { */ public static function getSubscribedEvents(): array { return [ - FieldHookEvents::WIDGET_SINGLE_ELEMENT_FORM_ALTER => 'formWidgetAlter', FormHookEvents::FORM_ALTER => 'formAlter', ThemeHookEvents::THEME_SUGGESTIONS_ALTER => 'themeSuggestionsAlter', ]; } - /** - * Widget form alter Event call. - * - * @param \Drupal\field_event_dispatcher\Event\Field\WidgetSingleElementFormAlterEvent $event - * The event. - */ - public function formWidgetAlter(WidgetSingleElementFormAlterEvent $event): void { - $element = &$event->getElement(); - $context = $event->getContext(); - // If this is an image field type of instance. - if ($context['widget'] instanceof ImageWidget) { - $element['#process'][] = '_va_gov_media_image_field_widget_process'; - } - } - /** * Form alter Event call. * diff --git a/docroot/modules/custom/va_gov_media/js/alt_text_validation.es6.js b/docroot/modules/custom/va_gov_media/js/alt_text_validation.es6.js new file mode 100644 index 0000000000..5601ef0e63 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/js/alt_text_validation.es6.js @@ -0,0 +1,78 @@ +/** + * @file + * Attaches behaviors VA GOv Media module. + */ +(($, Drupal, once, drupalSettings) => { + if (typeof drupalSettings.cvJqueryValidateOptions === "undefined") { + drupalSettings.cvJqueryValidateOptions = {}; + } + + if (drupalSettings.clientside_validation_jquery.force_validate_on_blur) { + drupalSettings.cvJqueryValidateOptions.onfocusout = (element) => { + // "eager" validation + this.element(element); + }; + } + + drupalSettings.cvJqueryValidateOptions.rules = { + "image[0][alt]": { + remote: { + url: `${drupalSettings.path.baseUrl}media/validate`, + type: "post", + data: { + value() { + return $("textarea[data-drupal-selector='edit-image-0-alt']").val(); + }, + }, + dataType: "json", + }, + }, + "media[0][fields][image][0][alt]": { + remote: { + url: `${drupalSettings.path.baseUrl}media/validate`, + type: "post", + data: { + value() { + return $( + "textarea[data-drupal-selector='edit-media-0-fields-image-0-alt']" + ).val(); + }, + }, + dataType: "json", + }, + }, + }; + + // Add messages with translations from backend. + $.extend( + $.validator.messages, + drupalSettings.clientside_validation_jquery.messages + ); + + /** + * Attaches jQuery validate behavior to forms. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the outline behavior to the right context. + */ + Drupal.behaviors.altTextValidate = { + // eslint-disable-next-line no-unused-vars + attach(context) { + // Allow all modules to update the validate options. + // Example of how to do this is shown below. + $(document).trigger( + "cv-jquery-validate-options-update", + drupalSettings.cvJqueryValidateOptions + ); + + // Process for all the forms on the page everytime, + // we already use once so we should be good. + once("altTextValidate", "body form").forEach((element) => { + $(element).validate(drupalSettings.cvJqueryValidateOptions); + }); + }, + }; + // eslint-disable-next-line no-undef +})(jQuery, Drupal, once, drupalSettings); diff --git a/docroot/modules/custom/va_gov_media/js/alt_text_validation.js b/docroot/modules/custom/va_gov_media/js/alt_text_validation.js new file mode 100644 index 0000000000..62b484ddf0 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/js/alt_text_validation.js @@ -0,0 +1,52 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ +var _this = this; +(function ($, Drupal, once, drupalSettings) { + if (typeof drupalSettings.cvJqueryValidateOptions === "undefined") { + drupalSettings.cvJqueryValidateOptions = {}; + } + if (drupalSettings.clientside_validation_jquery.force_validate_on_blur) { + drupalSettings.cvJqueryValidateOptions.onfocusout = function (element) { + _this.element(element); + }; + } + drupalSettings.cvJqueryValidateOptions.rules = { + "image[0][alt]": { + remote: { + url: drupalSettings.path.baseUrl + "media/validate", + type: "post", + data: { + value: function value() { + return $("textarea[data-drupal-selector='edit-image-0-alt']").val(); + } + }, + dataType: "json" + } + }, + "media[0][fields][image][0][alt]": { + remote: { + url: drupalSettings.path.baseUrl + "media/validate", + type: "post", + data: { + value: function value() { + return $("textarea[data-drupal-selector='edit-media-0-fields-image-0-alt']").val(); + } + }, + dataType: "json" + } + } + }; + $.extend($.validator.messages, drupalSettings.clientside_validation_jquery.messages); + Drupal.behaviors.altTextValidate = { + attach: function attach(context) { + $(document).trigger("cv-jquery-validate-options-update", drupalSettings.cvJqueryValidateOptions); + once("altTextValidate", "body form").forEach(function (element) { + $(element).validate(drupalSettings.cvJqueryValidateOptions); + }); + } + }; +})(jQuery, Drupal, once, drupalSettings); \ No newline at end of file diff --git a/docroot/modules/custom/va_gov_media/src/Controller/AltTextValidationController.php b/docroot/modules/custom/va_gov_media/src/Controller/AltTextValidationController.php new file mode 100644 index 0000000000..8ce7af0f37 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/src/Controller/AltTextValidationController.php @@ -0,0 +1,66 @@ +loggerFactory = $loggerFactory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('logger.factory') + ); + } + + /** + * Validate the alt text. + */ + public function validate(Request $req) { + $logger = $this->loggerFactory->get('va_gov_media'); + $value = $req->request->get('value'); + $value_length = MediaEventSubscriber::getLengthOfSubmittedValue($value); + $res = TRUE; + if ($value_length > 150) { + $logger->error("[CC] Alternative text ({$value}) cannot be longer than 150 characters. {$value_length} characters were submitted."); + $res = $this->t('Alternative text cannot be longer than 150 characters.'); + } + if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $value)) { + $logger->error("[FN] Alternative text cannot contain file names. {$value} was submitted."); + $res = $this->t('Alternative text cannot contain file names.'); + } + if (preg_match('/(image|photo|graphic|picture) of/i', $value)) { + $logger->error("[RP] Alternative text cannot contain repetitive phrases. {$value} was submitted."); + $res = $this->t('Alternative text cannot contain phrases like “image of”, “photo of”, “graphic of”, “picture of”, etc.'); + } + return new JsonResponse($res); + } + +} diff --git a/docroot/modules/custom/va_gov_media/src/EventSubscriber/MediaEventSubscriber.php b/docroot/modules/custom/va_gov_media/src/EventSubscriber/MediaEventSubscriber.php new file mode 100644 index 0000000000..7f78cdc0a8 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/src/EventSubscriber/MediaEventSubscriber.php @@ -0,0 +1,219 @@ + 'formWidgetAlter', + FormHookEvents::FORM_ALTER => 'formAlter', + ]; + } + + /** + * Form alter Event call. + * + * @param \Drupal\core_event_dispatcher\Event\Form\FormAlterEvent $event + * The event. + */ + public function formAlter(FormAlterEvent $event): void { + $form = &$event->getForm(); + + $form_id = $form['#id']; + if ($form_id === 'media-image-add-form') { + $form['name']['widget'][0]['value']['#description'] = $this->t('Provide a name that will help other users of the CMS find and reuse this image. The name is not visible to end users.'); + unset($form['field_media_submission_guideline']); + } + } + + /** + * Widget form alter Event call. + * + * @param \Drupal\field_event_dispatcher\Event\Field\WidgetSingleElementFormAlterEvent $event + * The event. + */ + public function formWidgetAlter(WidgetSingleElementFormAlterEvent $event): void { + $element = &$event->getElement(); + $context = $event->getContext(); + // If this is an image field type of instance. + if ($context['widget'] instanceof ImageWidget) { + $element['#process'][] = [static::class, 'imageFieldWidgetProcess']; + } + } + + /** + * Changes the alt text description to be more helpful and add validation. + * + * @param array $element + * The element to change the alt text description. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $form + * The form. + * + * @return array + * The element. + */ + public static function imageFieldWidgetProcess(array $element, FormStateInterface &$form_state, array $form) { + if (isset($element['alt'])) { + $element['alt']['#description'] = t('Adding a clear and meaningful description of the image is important for accessibility.'); + $element['alt']['#element_validate'] = [ + [static::class, 'validateAltText'], + ]; + $element['alt']['#type'] = 'textarea'; + $element['alt']['#rows'] = 3; + + // Add the textfield counter to the alt text field. + $position = 'after'; + $form_id = $form['#form_id']; + if ($form_id === 'media_library_add_form_dropzonejs') { + $form_storage = $form_state->getStorage(); + $entity = $form_storage['media'][0]; + } + else { + /* var $form_object \Drupal\media\MediaForm */ + $form_object = $form_state->getFormObject(); + /* var $entity \Drupal\media\MediaInterface */ + $entity = $form_object->getEntity(); + } + + $delta = $element['#delta']; + $fieldDefinition = $entity->getFieldDefinition('image'); + + $keys = [$element['#entity_type']]; + $keys[] = $entity->id() ? $entity->id() : 0; + if (method_exists($fieldDefinition, 'id')) { + $field_definition_id = str_replace('.', '--', $fieldDefinition->id()); + } + else { + $field_definition_id = "{$entity->getEntityTypeId()}--{$entity->bundle()}--{$fieldDefinition->getName()}"; + } + + $keys[] = $field_definition_id; + $keys[] = $delta; + $keys[] = 'alt'; + + $key = implode('-', $keys); + + $element['alt']['#attributes']['class'][] = $key; + $element['alt']['#attributes']['class'][] = 'textfield-counter-element'; + $element['alt']['#attributes']['data-field-definition-id'] = $field_definition_id; + + $element['alt']['#attached']['library'][] = 'textfield_counter/counter'; + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['key'][$delta] = $key; + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['maxlength'] = (int) self::MAX_LENGTH; + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['counterPosition'] = $position; + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['textCountStatusMessage'] = 'Characters remaining: @remaining_count'; + + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['preventSubmit'] = TRUE; + + $element['alt']['#attached']['drupalSettings']['textfieldCounter'][$key]['countHTMLCharacters'] = self::COUNT_HTML; + + } + + // Return the altered element. + return $element; + } + + /** + * Custom validation of image widget alt text field. + * + * @param array $element + * The image widget alt text element. + * @param \Drupal\Core\Form\FormStateInterface $formState + * The form state. + */ + public static function validateAltText(array $element, FormStateInterface $formState) { + // Only perform validation if the function is triggered from other places + // than the image process form. We don't want this validation to run when an + // image was just uploaded, and they haven't had an opportunity to provide + // the alt text. ImageWidget does this too, see ::validateRequiredFields. + $triggering_element = $formState->getTriggeringElement(); + if (!empty($triggering_element['#submit']) && in_array('file_managed_file_submit', $triggering_element['#submit'], TRUE)) { + $formState->setLimitValidationErrors([]); + return; + } + + $parents = $element['#parents']; + array_pop($parents); + + // Back out if no image was submitted. + $fid_form_element = array_merge($parents, ['fids']); + if (empty($formState->getValue($fid_form_element))) { + return; + } + + $logger = \Drupal::logger('va_gov_media'); + $value = $formState->getValue($element['#parents']); + $value_length = static::getLengthOfSubmittedValue($value); + if ($value_length > self::MAX_LENGTH) { + $formState->setErrorByName(implode('][', $element['#parents']), t('Alternative text cannot be longer than 150 characters.')); + $logger->error("[CC] Alternative text ({$value}) cannot be longer than 150 characters. {$value_length} characters were submitted."); + } + + if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $value)) { + $formState->setErrorByName(implode('][', $element['#parents']), t('Alternative text cannot contain file names.')); + $logger->error("[FN] Alternative text cannot contain file names. {$value} was submitted."); + } + + if (preg_match('/(image|photo|graphic|picture) of/i', $value)) { + $formState->setErrorByName(implode('][', $element['#parents']), t('Alternative text cannot contain phrases like “image of”, “photo of”, “graphic of”, “picture of”, etc.')); + $logger->error("[RP] Alternative text cannot contain repetitive phrases. {$value} was submitted."); + } + } + + /** + * Get the length of the submitted text value. + * + * @param string $value + * The value whose length is to be calculated. + * + * @return int + * The length of the value. + */ + public static function getLengthOfSubmittedValue(string $value): int { + $parts = explode(PHP_EOL, $value); + $newline_count = count($parts) - 1; + + if (self::COUNT_HTML) { + $value_length = mb_strlen($value) - $newline_count; + } + else { + $value_length = str_replace(' ', ' ', $value); + $value_length = trim($value_length); + $value_length = preg_replace("/(\r?\n|\r)+/", "\n", $value_length); + $value_length = mb_strlen(strip_tags($value_length)); + } + + return $value_length; + } + +} diff --git a/docroot/modules/custom/va_gov_media/va_gov_media.info.yml b/docroot/modules/custom/va_gov_media/va_gov_media.info.yml index e2d945d896..3d4f813043 100644 --- a/docroot/modules/custom/va_gov_media/va_gov_media.info.yml +++ b/docroot/modules/custom/va_gov_media/va_gov_media.info.yml @@ -3,3 +3,7 @@ type: module description: 'Manage images and other media' core_version_requirement: ^9 || ^10 package: 'Custom' + +dependencies: + - drupal:clientside_validation + - drupal:clientside_validation_jquery diff --git a/docroot/modules/custom/va_gov_media/va_gov_media.libraries.yml b/docroot/modules/custom/va_gov_media/va_gov_media.libraries.yml new file mode 100644 index 0000000000..a26a5f6e2d --- /dev/null +++ b/docroot/modules/custom/va_gov_media/va_gov_media.libraries.yml @@ -0,0 +1,5 @@ +cv.alt-text.validate: + js: + js/alt_text_validation.js: {} + dependencies: + - clientside_validation_jquery/jquery.validate diff --git a/docroot/modules/custom/va_gov_media/va_gov_media.module b/docroot/modules/custom/va_gov_media/va_gov_media.module index f9970c83ce..745d2367af 100644 --- a/docroot/modules/custom/va_gov_media/va_gov_media.module +++ b/docroot/modules/custom/va_gov_media/va_gov_media.module @@ -74,23 +74,10 @@ function _va_gov_media_image_style_warmer_warm_up(EntityInterface $entity) { } /** - * Changes the alt text description to be more helpful. - * - * @param array $element - * The element to change the alt text description. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The form state. - * @param array $form - * The form. - * - * @return array - * The element. + * Implements hook_clientside_validation_validator_info_alter(). */ -function _va_gov_media_image_field_widget_process(array $element, FormStateInterface &$form_state, array $form) { - if (isset($element['alt'])) { - $element['alt']['#description'] = t('Adding a clear and meaningful description of the image is important for accessibility.'); +function va_gov_media_clientside_validation_validator_info_alter(&$validators) { + foreach ($validators as &$validator) { + $validator['attachments']['library'][] = 'va_gov_media/cv.alt-text.validate'; } - - // Return the altered element. - return $element; } diff --git a/docroot/modules/custom/va_gov_media/va_gov_media.routing.yml b/docroot/modules/custom/va_gov_media/va_gov_media.routing.yml new file mode 100644 index 0000000000..7e4e00af32 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/va_gov_media.routing.yml @@ -0,0 +1,8 @@ +va_gov_media.alt_text_validate: + path: /media/validate + defaults: + _title: 'Validate Alt Text' + _controller: '\Drupal\va_gov_media\Controller\AltTextValidationController::validate' + methods: ['POST'] + requirements: + _user_is_logged_in: 'TRUE' diff --git a/docroot/modules/custom/va_gov_media/va_gov_media.services.yml b/docroot/modules/custom/va_gov_media/va_gov_media.services.yml new file mode 100644 index 0000000000..97babefb39 --- /dev/null +++ b/docroot/modules/custom/va_gov_media/va_gov_media.services.yml @@ -0,0 +1,8 @@ +services: + logger.channel.va_gov_media: + parent: logger.channel_base + arguments: [ 'va_gov_media' ] + va_gov_media.event_subscriber: + class: Drupal\va_gov_media\EventSubscriber\MediaEventSubscriber + tags: + - { name: event_subscriber } diff --git a/docroot/themes/custom/vagovclaro/assets/scss/components/_fields.scss b/docroot/themes/custom/vagovclaro/assets/scss/components/_fields.scss index b6d17bdb7d..2578550a08 100644 --- a/docroot/themes/custom/vagovclaro/assets/scss/components/_fields.scss +++ b/docroot/themes/custom/vagovclaro/assets/scss/components/_fields.scss @@ -227,6 +227,17 @@ body:not(.role-admin) { margin-left: var(--spacing-xl); } +.form-item--media-0-fields-image-0-alt, +.form-item--image-0-alt { + .form-item--error-message { + margin: 5px 0; + } +} + +#edit-field-publish-to-outreach-cal-wrapper div.form-item--field-publish-to-outreach-cal-value { + margin-left: auto; +} + #edit-field-publish-to-outreach-cal-wrapper label { margin-left: var(--spacing-xs); } diff --git a/docroot/themes/custom/vagovclaro/assets/scss/components/_media.scss b/docroot/themes/custom/vagovclaro/assets/scss/components/_media.scss index 0af2976882..aea6e81eca 100644 --- a/docroot/themes/custom/vagovclaro/assets/scss/components/_media.scss +++ b/docroot/themes/custom/vagovclaro/assets/scss/components/_media.scss @@ -52,3 +52,37 @@ max-width: calc(100% - 1.7rem); } } + +.form-item--image-0 { + .form-managed-file.no-upload { + display: flex; + } +} + +.image-widget { + &.form-managed-file.has-meta .form-managed-file__image-preview { + margin: 0 0 1rem 1rem; + max-width: 600px; + } + + .form-managed-file__main { + display: flex; + } + + .form-managed-file__meta-items { + .form-item__description { + margin-right: 16px; + } + } + + .image-preview__img-wrapper { + box-shadow: unset; + display: flex; + flex-direction: row; + justify-content: end; + + img { + width: 100%; + } + } +} diff --git a/docroot/themes/custom/vagovclaro/templates/content-edit/file-managed-file.html.twig b/docroot/themes/custom/vagovclaro/templates/content-edit/file-managed-file.html.twig index 3df68c5278..3c9c482b1e 100644 --- a/docroot/themes/custom/vagovclaro/templates/content-edit/file-managed-file.html.twig +++ b/docroot/themes/custom/vagovclaro/templates/content-edit/file-managed-file.html.twig @@ -40,13 +40,6 @@ {% if has_meta or data.preview %}
- {% if data.preview %} -
-
- {{ data.preview }} -
-
- {% endif %} {% if data.description or display or data.alt or data.title %}
{{ data.description }} @@ -63,22 +56,28 @@ {% set inlineguidance %}

Best practices

    -
  • Be accurate and descriptive, clearly identifying the main purpose of the image.
  • -
  • Be concise, ideally no more that 150 characters.
  • -
  • Avoid phrases like “image of”, “photo of”, “graphic of”, etc.
  • -
  • Leave the file name of the image out of the alt text.
  • -
  • Learn more about alt text guidelines
  • +
  • Be accurate and descriptive.
  • +
  • Don’t use phrases that screen readers already use to describe images such as “image of”, “photo of”, “graphic of”, or “picture of”.
  • +
  • Don't use the name of the image file as alt text. This does not provide clear information to people that use a screen reader.
  • +
  • Learn more about alt text guidelines (opens in a new tab)
{% endset %} {% include '@components/inline-guidance/text-box.twig' with { "content": inlineguidance, - "classes": 'show', + "classes": 'hide', } %} {{ data.alt|without('#title', '#description') }} {{ data.title }}
{% endif %} + {% if data.preview %} +
+
+ {{ data.preview }} +
+
+ {% endif %}
{% endif %} diff --git a/patches/2949540-31.patch b/patches/2949540-31.patch new file mode 100644 index 0000000000..55487454f1 --- /dev/null +++ b/patches/2949540-31.patch @@ -0,0 +1,219 @@ +diff --git a/clientside_validation.links.menu.yml b/clientside_validation.links.menu.yml +new file mode 100644 +index 0000000..0803f80 +--- /dev/null ++++ b/clientside_validation.links.menu.yml +@@ -0,0 +1,5 @@ ++clientside_validation.settings_form: ++ title: 'Clientside Validation Settings' ++ description: 'Configure clientside validation settings.' ++ route_name: clientside_validation.settings_form ++ parent: system.admin_config_ui +diff --git a/clientside_validation.module b/clientside_validation.module +index 50fa8b3..6da4e71 100644 +--- a/clientside_validation.module ++++ b/clientside_validation.module +@@ -12,7 +12,49 @@ use Drupal\Core\Render\Element; + * Implements hook_form_alter(). + */ + function clientside_validation_form_alter(&$form, FormStateInterface &$form_state, $form_id) { +- $form['#after_build'][] = 'clientside_validation_form_after_build'; ++ $config = \Drupal::config('clientside_validation.settings'); ++ ++ // Add cache tags for the config. ++ if (!empty($form['#cache']['tags'])) { ++ $form['#cache']['tags'] = array_merge($form['#cache']['tags'], $config->getCacheTags()); ++ } ++ else { ++ $form['#cache']['tags'] = $config->getCacheTags(); ++ } ++ ++ // If enabled for all forms, add the after build function. ++ $enable_all_forms = $config->get('enable_all_forms'); ++ if ($enable_all_forms) { ++ $form['#after_build'][] = 'clientside_validation_form_after_build'; ++ } ++ // Else, add it only if the form ID was added in configuration. ++ else { ++ $enabled_forms = $config->get('enabled_forms'); ++ if (!empty($enabled_forms) && in_array($form_id, $enabled_forms)) { ++ $form['#after_build'][] = 'clientside_validation_form_after_build'; ++ } ++ } ++ ++ // Webform has its own checkbox for disabling clientside validation, ++ // making it always enabled unless there is a novalidate attribute. ++ if ( ++ substr($form_id, 0, 19) == 'webform_submission_' && ++ !empty($form['#webform_id']) && ++ isset($form['#after_build']) && ++ !in_array('clientside_validation_form_after_build', $form['#after_build']) ++ ) { ++ $form['#after_build'][] = 'clientside_validation_form_after_build'; ++ } ++ ++ // Remove the clientside validation if the novalidate attribute was set. ++ if ( ++ isset($form['#attributes']['novalidate']) && ++ isset($form['#after_build']) && ++ in_array('clientside_validation_form_after_build', $form['#after_build']) ++ ) { ++ $validation_key = array_search('clientside_validation_form_after_build', $form['#after_build']); ++ unset($form['#after_build'][$validation_key]); ++ } + } + + /** +diff --git a/clientside_validation.permissions.yml b/clientside_validation.permissions.yml +new file mode 100644 +index 0000000..579ecaa +--- /dev/null ++++ b/clientside_validation.permissions.yml +@@ -0,0 +1,4 @@ ++administer clientside validation: ++ description: 'Grants access to the clientside validation configuration form.' ++ title: 'Administer clientside validation' ++ restrict access: TRUE +diff --git a/clientside_validation.routing.yml b/clientside_validation.routing.yml +new file mode 100644 +index 0000000..9cf0f16 +--- /dev/null ++++ b/clientside_validation.routing.yml +@@ -0,0 +1,7 @@ ++clientside_validation.settings_form: ++ path: '/admin/config/user-interface/clientside-validation' ++ defaults: ++ _form: '\Drupal\clientside_validation\Form\ClientsideValidationSettingsForm' ++ _title: 'Clientside Validation Settings' ++ requirements: ++ _permission: 'administer clientside validation' +diff --git a/config/install/clientside_validation.settings.yml b/config/install/clientside_validation.settings.yml +new file mode 100644 +index 0000000..dd9fdb9 +--- /dev/null ++++ b/config/install/clientside_validation.settings.yml +@@ -0,0 +1,2 @@ ++enable_all_forms: true ++enabled_forms: { } +diff --git a/config/schema/clientside_validation.schema.yml b/config/schema/clientside_validation.schema.yml +new file mode 100644 +index 0000000..6f8ec51 +--- /dev/null ++++ b/config/schema/clientside_validation.schema.yml +@@ -0,0 +1,12 @@ ++clientside_validation.settings: ++ type: config_object ++ mapping: ++ enable_all_forms: ++ type: boolean ++ label: 'Setting to enable all forms for clientside validation' ++ enabled_forms: ++ type: sequence ++ label: 'A list of clientside validation enabled forms' ++ sequence: ++ type: string ++ label: 'The form ID' +diff --git a/src/Form/ClientsideValidationSettingsForm.php b/src/Form/ClientsideValidationSettingsForm.php +new file mode 100644 +index 0000000..36b2547 +--- /dev/null ++++ b/src/Form/ClientsideValidationSettingsForm.php +@@ -0,0 +1,98 @@ ++config('clientside_validation.settings'); ++ ++ // Add a note in regards to the overrides with the novalidate attribute. ++ $form['novalidate_note'] = [ ++ '#markup' => $this->t('Forms with the "novalidate" attribute will not have clientside validation enabled, regardless of these settings.'), ++ ]; ++ ++ // General enabling for all forms. ++ $form['enable_all_forms'] = [ ++ '#type' => 'checkbox', ++ '#title' => $this->t('Use Clientside Validation in all forms'), ++ '#description' => $this->t('Enable Clientside Validation for all forms on this site.'), ++ '#default_value' => $config->get('enable_all_forms'), ++ ]; ++ ++ // Enabled forms. ++ $enabled_forms = (!empty($config->get('enabled_forms'))) ? $config->get('enabled_forms') : []; ++ $form['enabled_forms'] = [ ++ '#type' => 'textarea', ++ '#title' => $this->t('Clientside Validation Enabled Forms'), ++ '#description' => $this->t('Enter form IDs for all forms that should have clientside validation enabled, separated by a new line.'), ++ '#default_value' => implode(PHP_EOL, $enabled_forms), ++ '#states' => [ ++ // Hide this textarea when all forms are enabled. ++ 'invisible' => [ ++ 'input[name="enable_all_forms"]' => ['checked' => TRUE], ++ ], ++ ], ++ ]; ++ ++ return $form; ++ } ++ ++ /** ++ * {@inheritdoc} ++ */ ++ public function submitForm(array &$form, FormStateInterface $form_state) { ++ parent::submitForm($form, $form_state); ++ $config = $this->config('clientside_validation.settings'); ++ $values = $form_state->getValues(); ++ ++ $config->set('enable_all_forms', $values['enable_all_forms']); ++ $enabled_forms = preg_split("[\n|\r]", $values['enabled_forms']); ++ $enabled_forms = array_filter($enabled_forms); ++ $config->set('enabled_forms', $enabled_forms); ++ $config->save(); ++ } ++ ++} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 744efcbf12..211a18632c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -494,3 +494,8 @@ parameters: message: "#^Variable \\$process might not be defined\\.$#" count: 7 path: tests/scaling_performance.php + + - + message: "#^Call to an undefined method Drupal\\\\Core\\\\\Form\\\\\FormInterface\\:\\:getEntity\\(\\)\\.$#" + count: 1 + path: docroot/modules/custom/va_gov_media/src/EventSubscriber/MediaEventSubscriber.php diff --git a/tests/cypress/integration/features/platform/alt_text_validation.feature b/tests/cypress/integration/features/platform/alt_text_validation.feature new file mode 100644 index 0000000000..a818c5967e --- /dev/null +++ b/tests/cypress/integration/features/platform/alt_text_validation.feature @@ -0,0 +1,42 @@ + +Feature: Alt-Text Validation + In order to enhance the veteran experience + As an editor + I need just-in-time guidance as to best practices surrounding alt-text content + + Scenario: An editor supplies verbose alt-text content (server-side validation) + Given I am logged in as a user with the "administrator" role + When I save an image with 152 characters of alt-text content + Then I should see "Alternative text cannot be longer than 150 characters." + + Scenario: An editor supplies redundant alt-text content (server-side validation) + Given I am logged in as a user with the "administrator" role + When I save an image with "Image of polygon" as alt-text + Then I should see "Alternative text cannot contain phrases like “image of”, “photo of”, “graphic of”, “picture of”, etc." + + Scenario: An editor supplies the name of the image file as alt-text content (server-side validation) + Given I am logged in as a user with the "administrator" role + When I save an image with "polygon_image.png" as alt-text + Then I should see "Alternative text cannot contain file names." + + Scenario: An editor supplies verbose alt-text content (element blur validation) + Given I am logged in as a user with the "administrator" role + When I create an image with 152 characters of alt-text content + Then I should see "Alternative text cannot be longer than 150 characters." + + Scenario: An editor supplies redundant alt-text content (element blur validation) + Given I am logged in as a user with the "administrator" role + When I create an image with "Image of polygon" as alt-text + Then I should see "Alternative text cannot contain phrases like “image of”, “photo of”, “graphic of”, “picture of”, etc." + + Scenario: An editor supplies the name of the image file as alt-text content (element blur validation) + Given I am logged in as a user with the "administrator" role + When I create an image with "polygon_image.png" as alt-text + Then I should see "Alternative text cannot contain file names." + + Scenario: An editor supplies the name of the image file and then correctly edits field + Given I am logged in as a user with the "administrator" role + When I create an image with "polygon_image.png" as alt-text + Then I should see "Alternative text cannot contain file names." + When I update alt-text content to display "a simple polygon placeholder" + Then I should see no error message diff --git a/tests/cypress/integration/step_definitions/common/i_create_an_image_with_alt_text.js b/tests/cypress/integration/step_definitions/common/i_create_an_image_with_alt_text.js new file mode 100644 index 0000000000..4b4a52a5ee --- /dev/null +++ b/tests/cypress/integration/step_definitions/common/i_create_an_image_with_alt_text.js @@ -0,0 +1,59 @@ +import { When, Then } from "@badeball/cypress-cucumber-preprocessor"; +import { faker } from "@faker-js/faker"; + +const navigateToAndFillMediaForm = () => { + cy.visit("/media/add/image"); + cy.injectAxe(); + cy.scrollTo("top"); + cy.findAllByLabelText("Name").type(`[Test Data] ${faker.lorem.sentence()}`, { + force: true, + }); + cy.findAllByLabelText("Description").type(faker.lorem.sentence(), { + force: true, + }); + cy.findAllByLabelText("Section").select("VACO"); + cy.get("#edit-image-0-upload") + .attachFile("images/polygon_image.png") + .wait(1000); +}; + +const focusOnNameField = () => { + cy.findAllByLabelText("Name").focus(); +}; + +When("I create an image with {string} as alt-text", (altTextContent) => { + navigateToAndFillMediaForm(); + cy.findAllByLabelText("Alternative text").type(altTextContent, { + force: true, + }); + focusOnNameField(); +}); + +When( + "I create an image with {int} characters of alt-text content", + (charCount) => { + navigateToAndFillMediaForm(); + cy.findAllByLabelText("Alternative text").type( + faker.helpers.repeatString("a", charCount), + { + force: true, + } + ); + focusOnNameField(); + } +); + +When("I update alt-text content to display {string}", (altTextContent) => { + cy.findAllByLabelText("Alternative text").clear(); + cy.findAllByLabelText("Alternative text").type(altTextContent, { + force: true, + }); +}); + +Then("I should see no error message", () => { + cy.get("div.form-item--error-message > strong").should( + "have.attr", + "style", + "display: none;" + ); +}); diff --git a/tests/cypress/integration/step_definitions/common/i_save_an_image_with_alt_text.js b/tests/cypress/integration/step_definitions/common/i_save_an_image_with_alt_text.js new file mode 100644 index 0000000000..900ad1954f --- /dev/null +++ b/tests/cypress/integration/step_definitions/common/i_save_an_image_with_alt_text.js @@ -0,0 +1,45 @@ +import { When } from "@badeball/cypress-cucumber-preprocessor"; +import { faker } from "@faker-js/faker"; + +const navigateToAndFillMediaForm = () => { + cy.visit("/media/add/image"); + cy.injectAxe(); + cy.scrollTo("top"); + cy.findAllByLabelText("Name").type(`[Test Data] ${faker.lorem.sentence()}`, { + force: true, + }); + cy.findAllByLabelText("Description").type(faker.lorem.sentence(), { + force: true, + }); + cy.findAllByLabelText("Section").select("VACO"); + cy.get("#edit-image-0-upload") + .attachFile("images/polygon_image.png") + .wait(1000); +}; + +const clickSaveButton = () => { + cy.get("form.media-form input#edit-submit").click(); + cy.wait(1000); +}; + +When("I save an image with {string} as alt-text", (altTextContent) => { + navigateToAndFillMediaForm(); + cy.findAllByLabelText("Alternative text").type(altTextContent, { + force: true, + }); + clickSaveButton(); +}); + +When( + "I save an image with {int} characters of alt-text content", + (charCount) => { + navigateToAndFillMediaForm(); + cy.findAllByLabelText("Alternative text").type( + faker.helpers.repeatString("a", charCount), + { + force: true, + } + ); + clickSaveButton(); + } +);