diff --git a/.ddev/.env.example b/.ddev/.env.example index df5aa81207..910941075a 100644 --- a/.ddev/.env.example +++ b/.ddev/.env.example @@ -81,3 +81,8 @@ AWS_BEDROCK_ROLE_SESSION_NAME='' AWS_BEDROCK_ACCESS_KEY_ID='' AWS_BEDROCK_SECRET_ACCESS_KEY='' AWS_BEDROCK_REGION=us-east-1 + +## Feedback Submission Lambda API endpoint +## Local dev: Use local Docker endpoint or tunneled Lambda +## Example for local testing: http://localhost:3000/feedback/submit, after you run the Lambda from ETL repo (this can be outdated). +FEEDBACK_SUBMISSION_API_ENDPOINT='' diff --git a/changelogs/DP-43340.yml b/changelogs/DP-43340.yml new file mode 100644 index 0000000000..ed9b8157ff --- /dev/null +++ b/changelogs/DP-43340.yml @@ -0,0 +1,41 @@ +# +# Write your changelog entry here. Every pull request must have a changelog yml file. +# +# Change types: +# ############################################################################# +# You can use one of the following types: +# - Added: For new features. +# - Changed: For changes to existing functionality. +# - Deprecated: For soon-to-be removed features. +# - Removed: For removed features. +# - Fixed: For any bug fixes. +# - Security: In case of vulnerabilities. +# +# Format +# ############################################################################# +# The format is crucial. Please follow the examples below. For reference, the requirements are: +# - All 3 parts are required and you must include "Type", "description" and "issue". +# - "Type" must be left aligned and followed by a colon. +# - "description" must be indented with 2 spaces followed by a colon +# - "issue" must be indented with 4 spaces followed by a colon. +# - "issue" is for the Jira ticket number only e.g. DP-1234 +# - No extra spaces, indents, or blank lines are allowed. +# +# Example: +# ############################################################################# +# Fixed: +# - description: Fixes scrolling on edit pages in Safari. +# issue: DP-13314 +# +# You may add more than 1 description & issue for each type using the following format: +# Changed: +# - description: Automating the release branch. +# issue: DP-10166 +# - description: Second change item that needs a description. +# issue: DP-19875 +# - description: Third change item that needs a description along with an issue. +# issue: DP-19843 +# +Changed: + - description: Switch to custom feedback backend, remove useless code, improve feedback form for nodes without organization. + issue: DP-43340 diff --git a/docroot/modules/custom/mass_feedback_form/js/mass-feedback-form.behaviors.js b/docroot/modules/custom/mass_feedback_form/js/mass-feedback-form.behaviors.js index 61025ffb1a..b72878d732 100644 --- a/docroot/modules/custom/mass_feedback_form/js/mass-feedback-form.behaviors.js +++ b/docroot/modules/custom/mass_feedback_form/js/mass-feedback-form.behaviors.js @@ -1,107 +1,163 @@ /** * @file * Provides JavaScript for Mass Feedback Forms. + * Handles submission to the Lambda feedback API instead of Formstack. */ -/* global dataLayer */ - -(function ($) { +(function ($, once) { 'use strict'; /** - * Support a multi-step Feedback form. + * Support feedback form submission to Lambda API. */ Drupal.behaviors.massFeedbackForm = { attach: function (context) { - // This field is used by the feedback manager to join the survey (second) with the first submission - var MG_FEEDBACK_ID = 'field68557708'; - - // This field is used to set a unique device ID to form submissions. - var UNIQUE_ID_FIELD = 'field68798989'; + // Process feedback forms using Drupal's once() function + once('massFeedbackForm', '.ma__mass-feedback-form', context).forEach(function (element) { + const $self = $(element); + const $form = $self.find('form').not('has-error'); - // For certain form inputs, use a value from the data layer. - $('.data-layer-substitute', context).each(function (index) { - var $this = $(this); - var property = $this.val(); - var sub = ''; + if (!$form.length) { + return; + } - for (var i = 0; i < dataLayer.length; i++) { - if (typeof dataLayer[i][property] !== 'undefined') { - sub = dataLayer[i][property]; + const feedback = $self[0]; + const $success = $self.find('#success-screen'); + const $submitBtn = $('input[type="submit"]', $form); + const formAction = $form.attr('action'); + let isSubmitting = false; + + // Prevent double-click form submission + $submitBtn.on('click', function (e) { + if (isSubmitting) { + e.preventDefault(); + return false; } - } + }); - if (sub !== '' && typeof sub === 'string') { - $this.val(sub); - } - $this.removeClass('data-layer-substitute'); - }); + // Handle form submission + $form.on('submit', function (e) { + e.preventDefault(); - // Process the multistep form. - $('.ma__mass-feedback-form', context).each(function (index) { - var $self = $(this); - var feedback = $self[0]; - - var $formOriginal = $self.find('form').not('has-error'); - // Checks to avoid bots submitting the form - // On the first form, the Feedback manager lambda will populate this field, - // so a populated field was a bot, we honey potted him/her/they - if ($formOriginal.find('#field68798989').length && $('#field68798989').val()) { - // We don't need to show the bot anything - return false; - } + if (isSubmitting) { + return false; + } - var $form = $self.find('form').not('has-error'); - var $success = $self.find('#success-screen'); - // This is to stop a double click submitting the form twice - var $submitBtn = $('input[type="submit"]', $form); + isSubmitting = true; + $submitBtn.prop('disabled', true); - // Use device ID set in docroot/themes/custom/mass_theme/overrides/js/device.js. - var massgovDeviceId = localStorage.getItem('massgovDeviceId') || ''; - $form.find('input[name="' + UNIQUE_ID_FIELD + '"]').val(massgovDeviceId); + // Submit feedback. + submitFeedback($form, formAction, $success, feedback, $submitBtn, function () { + isSubmitting = false; + }); - $form.submit(function () { - $submitBtn.prop('disabled', true); - }); - $form.on('submit', function (e) { - $form.addClass('hidden'); - $success.removeClass('hidden'); - feedback.scrollIntoView(); + return false; }); + }); - $form.ajaxForm({ - data: {jsonp: 1}, - dataType: 'script' + /** + * Submit feedback to Lambda API. + * + * @param {jQuery} $form The form element. + * @param {string} formAction The API endpoint URL. + * @param {jQuery} $success The success message element. + * @param {Element} feedback The feedback container element. + * @param {jQuery} $submitBtn The submit button element. + * @param {Function} onComplete Callback when submission is complete. + */ + function submitFeedback($form, formAction, $success, feedback, $submitBtn, onComplete) { + const formData = new FormData($form[0]); + + // Get explain field - handle both visible and hidden textareas with same name + // The form has two textareas with name="explain" (positive and negative feedback) + // FormData.get() only returns the first one, so we need to get the visible one + let explainField = ''; + const explainInputs = $form.find('textarea[name="explain"]'); + explainInputs.each(function () { + const $textarea = $(this); + // Check if textarea is visible (not hidden by CSS display:none or parent hidden class) + if ($textarea.is(':visible') && !$textarea.closest('.feedback-response').hasClass('hidden')) { + explainField = $textarea.val() || ''; + } }); - window['form' + $form.attr('id')] = { - onPostSubmit: function (message) { - // If MG_FEEDBACK_ID is 'uniqueId', then we are submitting the first (feedback) form - // so we now need to set the MG_FEEDBACK_ID value with the ID returned from formstack. - var submissionId = message.submission; - if ($('#' + MG_FEEDBACK_ID).val() === 'uniqueId') { - $('#' + MG_FEEDBACK_ID).val(submissionId); - } - feedback.scrollIntoView(); - }, - onSubmitError: function (err) { - var message = 'Submission Failure: ' + err.error; - getMessaging($form).html(message); - } + // Get unique device ID from localStorage for spam detection + // This ID is created by device.js and persists across sessions + const mgFeedbackId = localStorage.getItem('massgovDeviceId') || null; + + const payload = { + node_id: parseInt(formData.get('node_id')) || 0, + info_found: formData.get('info_found') === 'Yes', + explain: explainField, + url: window.location.href, + timestamp: new Date().toISOString(), + mg_feedback_id: mgFeedbackId }; - }); - // Handle the creation and management of the form messages. - function getMessaging($form) { - var $messages = $('.messages', $form); + fetch(formAction, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Accept-Language': navigator.language || 'en-US', + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': navigator.userAgent + }, + body: JSON.stringify(payload) + }) + .then(function (response) { + if (!response.ok) { + return response.json().then(function (data) { + throw { + status: response.status, + data: data + }; + }); + } + return response.json(); + }) + .then(function (data) { + // Show success screen and keep it visible + $form.addClass('hidden'); + $success.removeClass('hidden'); + feedback.scrollIntoView({behavior: 'smooth'}); + }) + .catch(function (error) { + console.error('Feedback submission failed:', error); + + var errorMessage = 'Unable to submit your feedback. Please try again later.'; + if (error.status === 400 && error.data && error.data.errors) { + errorMessage = 'Submission error: ' + error.data.errors.join(', '); + } + + showErrorMessage($form, errorMessage); + $submitBtn.prop('disabled', false); + }) + .finally(function () { + onComplete(); + }); + } + + /** + * Display error message in the form. + * + * @param {jQuery} $form The form element. + * @param {string} message The error message to display. + */ + function showErrorMessage($form, message) { + let $messages = $form.find('.messages'); if (!$messages.length) { - $form.find('input[type="submit"]').parent().prepend('
'); - $messages = $('.messages', $form); + $form.prepend(''); + $messages = $form.find('.messages'); } - return $messages; - } + $messages.html(message).show(); + // Auto-hide after 5 seconds + setTimeout(function () { + $messages.fadeOut(); + }, 5000); + } } }; -})(jQuery); +})(jQuery, once); diff --git a/docroot/modules/custom/mass_feedback_form/mass_feedback_form.libraries.yml b/docroot/modules/custom/mass_feedback_form/mass_feedback_form.libraries.yml index 00717813c8..9cbcd0bca1 100644 --- a/docroot/modules/custom/mass_feedback_form/mass_feedback_form.libraries.yml +++ b/docroot/modules/custom/mass_feedback_form/mass_feedback_form.libraries.yml @@ -1,7 +1,8 @@ feedback-form-behaviors: - version: 1.x + version: 1.1.0 js: js/mass-feedback-form.behaviors.js: {} dependencies: - core/jquery - core/internal.jquery.form + - core/once diff --git a/docroot/modules/custom/mass_feedback_form/mass_feedback_form.module b/docroot/modules/custom/mass_feedback_form/mass_feedback_form.module index 997b641658..9d258cd7c1 100644 --- a/docroot/modules/custom/mass_feedback_form/mass_feedback_form.module +++ b/docroot/modules/custom/mass_feedback_form/mass_feedback_form.module @@ -29,16 +29,11 @@ function mass_feedback_form_help($route_name, RouteMatchInterface $route_match) /** * Implements hook_theme(). * - * Defines twig templates for two feedback form blocks, one called by PageFeedbackForm and the other by NodeFeedbackContainer. + * Defines twig templates for two feedback form blocks, one called by + * PageFeedbackForm and the other by NodeFeedbackContainer. */ function mass_feedback_form_theme() { return [ - 'mass_feedback_form_without_organization' => [ - 'render element' => NULL, - ], - 'mass_feedback_form' => [ - 'variables' => ['node_id' => NULL], - ], 'block__node_feedback_container' => [ 'render element' => 'elements', 'template' => 'block--node-feedback-container', @@ -47,6 +42,15 @@ function mass_feedback_form_theme() { ]; } +/** + * Implements hook_preprocess(). + */ +function mass_feedback_form_preprocess(&$variables, $hook) { + if (in_array($hook, ['node', 'node__mass_feedback_form_without_organization'])) { + $variables['feedback_submission_api_endpoint'] = getenv('FEEDBACK_SUBMISSION_API_ENDPOINT'); + } +} + /** * Implements hook_node_update() */ @@ -130,9 +134,8 @@ function mass_feedback_form_node_view(array &$build, EntityInterface $node, Enti $org_node_view_feedback = $view_builder->view($node, 'feedback'); } elseif ($node->bundle() === 'topic_page' && $node->get('field_organizations')->isEmpty()) { - $org_node_view_feedback = [ - '#theme' => 'mass_feedback_form_without_organization', - ]; + $org_node_view_feedback = $view_builder->view($node, 'feedback'); + $org_node_view_feedback['#theme'] = 'node__mass_feedback_form_without_organization'; } else { if ($node->field_organizations->count() > 0 && !empty($node->field_organizations[0]->entity)) { diff --git a/docroot/modules/custom/mass_feedback_form/templates/mass-feedback-form.html.twig b/docroot/modules/custom/mass_feedback_form/templates/mass-feedback-form.html.twig deleted file mode 100644 index 42590866cc..0000000000 --- a/docroot/modules/custom/mass_feedback_form/templates/mass-feedback-form.html.twig +++ /dev/null @@ -1,134 +0,0 @@ -{{ attach_library('mass_feedback_form/feedback-form') }} -