From aa5f18b234214a080b4c8690f4891358b5c0876e Mon Sep 17 00:00:00 2001 From: Arit Amana <32520970+msarit@users.noreply.github.com> Date: Wed, 15 Sep 2021 21:25:01 -0400 Subject: [PATCH] Admin Save Confirmation - Removing Badge Achievements (#14684) * schema file undelete description * update with main * update with origin * update * create controller; hook up to badge_achievements * almost done with implementation * push up what I have * complete implementation; generalize snackbar usage * start writing tests * add confirmation text entry step to test * Fix delete via JS * implement danger notice for errors * complete cypress test * create ErrorAlert CustomEvent and implement * fix failing badge_achievements spec * start applying PR review changes * hook up Preact Modal * fix cypress tests * remove old comment Co-authored-by: Nick Taylor * consolidate messaging functions Co-authored-by: Michael Kohl Co-authored-by: Nick Taylor --- .../admin/badge_achievements_controller.rb | 5 +- app/javascript/admin/adminModal.js | 10 +- .../admin/controllers/config_controller.js | 24 ++-- .../confirmation_modal_controller.js | 71 ++++++++++ .../admin/controllers/error_controller.js | 34 +++++ app/javascript/admin/messageUtilities.js | 31 +++++ .../admin/badge_achievements/index.html.erb | 125 ++++++++++++------ app/views/layouts/admin.html.erb | 7 + .../badges/badgeAchievements.spec.js | 77 ++++++++++- .../requests/admin/badge_achievements_spec.rb | 1 - 10 files changed, 322 insertions(+), 63 deletions(-) create mode 100644 app/javascript/admin/controllers/confirmation_modal_controller.js create mode 100644 app/javascript/admin/controllers/error_controller.js create mode 100644 app/javascript/admin/messageUtilities.js diff --git a/app/controllers/admin/badge_achievements_controller.rb b/app/controllers/admin/badge_achievements_controller.rb index 5fd001dca..798883293 100644 --- a/app/controllers/admin/badge_achievements_controller.rb +++ b/app/controllers/admin/badge_achievements_controller.rb @@ -15,11 +15,10 @@ module Admin @badge_achievement = BadgeAchievement.find(params[:id]) if @badge_achievement.destroy - flash[:success] = "Badge achievement has been deleted!" + render json: { message: "Badge achievement has been deleted!" }, status: :ok else - flash[:danger] = @badge_achievement.errors_as_sentence + render json: { error: "Something went wrong." }, status: :unprocessable_entity end - redirect_to admin_badge_achievements_path end def award diff --git a/app/javascript/admin/adminModal.js b/app/javascript/admin/adminModal.js index 4ff4f5cc4..d29a1a620 100644 --- a/app/javascript/admin/adminModal.js +++ b/app/javascript/admin/adminModal.js @@ -4,6 +4,8 @@ * @function adminModal * @param {Object} modalProps Properties of the Modal * @param {string} modalProps.title The title of the modal. + * @param {string} modalProps.controllerName The name of the controller activating the modal. + * @param {string} modalProps.closeModalFunction The name of the function that closes the modal. * @param {string} modalProps.body The modal's content. May use HTML tags for styling. * @param {string} modalProps.leftBtnText The text for the modal's left button. * @param {string} modalProps.leftBtnAction The function that fires when left button is clicked. @@ -16,6 +18,8 @@ */ export const adminModal = function ({ title, + controllerName, + closeModalFunction, body, leftBtnText, leftBtnAction, @@ -31,7 +35,7 @@ export const adminModal = function ({

${title}

- diff --git a/app/javascript/admin/controllers/config_controller.js b/app/javascript/admin/controllers/config_controller.js index dfd25ec48..4fb913cc3 100644 --- a/app/javascript/admin/controllers/config_controller.js +++ b/app/javascript/admin/controllers/config_controller.js @@ -1,6 +1,7 @@ /* global jQuery */ import { Controller } from 'stimulus'; import { adminModal } from '../adminModal'; +import { displaySnackbar } from '../messageUtilities'; const recaptchaFields = document.getElementById('recaptchaContainer'); const emailRegistrationCheckbox = document.getElementById( @@ -10,11 +11,10 @@ const emailAuthSettingsSection = document.getElementById( 'email-auth-settings-section', ); const emailAuthModalTitle = 'Disable Email address registration'; -// TODO: Remove the "You mut confirm..." warning once we build more robust flow for Admin/Config + const emailAuthModalBody = `

If you disable Email address as a registration option, people cannot create an account with their email address.

-

However, people who have already created an account using their email address can continue to login.

-

You must confirm and update the settings below to complete this action.

`; +

However, people who have already created an account using their email address can continue to login.

`; export default class ConfigController extends Controller { static targets = [ @@ -93,14 +93,6 @@ export default class ConfigController extends Controller { } } - displaySnackbar(message) { - return document.dispatchEvent( - new CustomEvent('snackbar:add', { - detail: { message }, - }), - ); - } - async updateConfigurationSettings(event) { event.preventDefault(); try { @@ -118,9 +110,9 @@ export default class ConfigController extends Controller { const outcome = await response.json(); - this.displaySnackbar(outcome.message ?? outcome.error); + displaySnackbar(outcome.message ?? outcome.error); } catch (err) { - this.displaySnackbar(err.message); + displaySnackbar(err.message); } } @@ -157,6 +149,8 @@ export default class ConfigController extends Controller { event.preventDefault(); this.configModalAnchorTarget.innerHTML = adminModal({ title: emailAuthModalTitle, + controllerName: 'config', + closeModalFunction: 'closeAdminModal', body: emailAuthModalBody, leftBtnText: 'Confirm disable', leftBtnAction: 'disableEmailAuthFromModal', @@ -235,6 +229,8 @@ export default class ConfigController extends Controller { const { providerOfficialName } = event.target.dataset; this.configModalAnchorTarget.innerHTML = adminModal({ title: this.authProviderModalTitle(providerOfficialName), + controllerName: 'config', + closeModalFunction: 'closeAdminModal', body: this.authProviderModalBody(providerOfficialName), leftBtnText: 'Confirm disable', leftBtnAction: 'disableAuthProviderFromModal', @@ -359,6 +355,8 @@ export default class ConfigController extends Controller { activateMissingKeysModal(providers) { this.configModalAnchorTarget.innerHTML = adminModal({ title: 'Setup not complete', + controllerName: 'config', + closeModalFunction: 'closeAdminModal', body: this.missingAuthKeysModalBody(providers), leftBtnText: 'Continue editing', leftBtnAction: 'closeAdminModal', diff --git a/app/javascript/admin/controllers/confirmation_modal_controller.js b/app/javascript/admin/controllers/confirmation_modal_controller.js new file mode 100644 index 000000000..ae9b42ce8 --- /dev/null +++ b/app/javascript/admin/controllers/confirmation_modal_controller.js @@ -0,0 +1,71 @@ +import ModalController from '../controllers/modal_controller'; +import { displayErrorAlert, displaySnackbar } from '../messageUtilities'; + +const confirmationText = (username) => + `My username is @${username} and this action is 100% safe and appropriate.`; + +export default class ConfirmationModalController extends ModalController { + static targets = ['itemId', 'username', 'endpoint']; + + removeBadgeAchievement(id) { + return document.querySelector(`[data-row-id="${id}"]`).remove(); + } + + async sendToEndpoint({ itemId, endpoint }) { + try { + const response = await fetch(`${endpoint}/${itemId}`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']") + ?.content, + }, + credentials: 'same-origin', + }); + + const outcome = await response.json(); + + if (response.ok) { + this.removeBadgeAchievement(itemId); + displaySnackbar(outcome.message); + } else { + displayErrorAlert(outcome.error); + } + + this.closeModal(); + } catch (err) { + displayErrorAlert(err.message); + } + } + + checkConfirmationText() { + const confirmationMismatchWarning = document.querySelector( + '#confirmation-modal-root #mismatch-warning', + ); + + const confirmationTextEntry = document.querySelector( + '#confirmation-modal-root #confirmation-text-field', + ).value; + + if (confirmationTextEntry == confirmationText(this.usernameValue)) { + this.closeModal(); + this.sendToEndpoint({ + itemId: this.itemIdValue, + endpoint: this.endpointValue, + }); + } else { + confirmationMismatchWarning.classList.remove('hidden'); + } + } + + openModal(event) { + const { itemId, endpoint, username } = event.target.dataset; + + this.itemIdValue = itemId; + this.usernameValue = username; + this.endpointValue = endpoint; + + this.toggleModal(); + } +} diff --git a/app/javascript/admin/controllers/error_controller.js b/app/javascript/admin/controllers/error_controller.js new file mode 100644 index 000000000..e933708e8 --- /dev/null +++ b/app/javascript/admin/controllers/error_controller.js @@ -0,0 +1,34 @@ +import { Controller } from 'stimulus'; + +export default class ErrorController extends Controller { + static targets = ['errorZone']; + + closeErrorAlert() { + this.errorZoneTarget.innerHTML = ''; + } + + generateErrorAlert(event) { + const { errMsg } = event.detail; + + this.errorZoneTarget.innerHTML = ` +
+
${errMsg}
+ + Close + + +
+ `; + } +} diff --git a/app/javascript/admin/messageUtilities.js b/app/javascript/admin/messageUtilities.js new file mode 100644 index 000000000..418ae0741 --- /dev/null +++ b/app/javascript/admin/messageUtilities.js @@ -0,0 +1,31 @@ +/** + * A function to generate an error alert within the /admin/ space. + * + * @function errorAlert + * @param {Object} modalProps Properties of the Error Alert + * @param {string} modalProps.errMsg The error message displayed within the alert. + */ + +export const displayErrorAlert = function (errMsg) { + return document.dispatchEvent( + new CustomEvent('error:generate', { + detail: { errMsg }, + }), + ); +}; + +/** + * A function to generate a snackbar within the /admin/ space. + * + * @function displaySnackbar + * @param {Object} modalProps Properties of the Snackbar + * @param {string} modalProps.message The message displayed within the snackbar. + */ + +export const displaySnackbar = function (message) { + return document.dispatchEvent( + new CustomEvent('snackbar:add', { + detail: { message }, + }), + ); +}; diff --git a/app/views/admin/badge_achievements/index.html.erb b/app/views/admin/badge_achievements/index.html.erb index d6129f298..94466903d 100644 --- a/app/views/admin/badge_achievements/index.html.erb +++ b/app/views/admin/badge_achievements/index.html.erb @@ -1,44 +1,91 @@ -
- Note: If you remove a badge that is automatically rewarded it will simply be re-awarded despite being removed. -
- -
-
- <%= link_to "Award Badge", admin_badge_achievements_award_badges_path, class: "btn btn-primary" %> +
+
+ Note: If you remove a badge that is automatically rewarded it will simply be re-awarded despite being removed.
-
- <%= search_form_for @q, url: admin_badge_achievements_path, class: "form-inline justify-content-end" do |f| %> - <%= f.label :user_id_eq, "User ID", class: "sr-only" %> - <%= f.search_field :user_id_eq, placeholder: "User ID", class: "form-control mx-3" %> +
+
+ <%= link_to "Award Badge", admin_badge_achievements_award_badges_path, class: "btn btn-primary" %> +
+
+ <%= search_form_for @q, url: admin_badge_achievements_path, class: "form-inline justify-content-end" do |f| %> - <%= f.submit "Search", class: "btn btn-secondary" %> - <% end %> + <%= f.label :user_id_eq, "User ID", class: "sr-only" %> + <%= f.search_field :user_id_eq, placeholder: "User ID", class: "form-control mx-3" %> + + <%= f.submit "Search", class: "btn btn-secondary" %> + <% end %> +
+
+ + + + + + + + + + + + <% @badge_achievements.each do |badge_achievement| %> + + + + +
+
+ + + + <% end %> + +
User IDUserBadgeBadge Image
<%= badge_achievement.user.id %><%= badge_achievement.user.username %><%= badge_achievement.badge.title %> + badge image + + +
+
+ +
- - - - - - - - - - - - <% @badge_achievements.each do |badge_achievement| %> - - - - -
-
- - - - <% end %> - -
User IDUserBadgeBadge Image
<%= badge_achievement.user.id %><%= badge_achievement.user.username %><%= badge_achievement.badge.title %> - badge image - <%= link_to "Remove", admin_badge_achievement_path(badge_achievement), class: "crayons-btn crayons-btn--danger", method: :delete, data: { confirm: "Are you sure?" } %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index cc8163f4e..efb1116e8 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -84,6 +84,13 @@
<% end %> +
+
+
+ <% if request.path.split("/")[-3] == "admin" %> <%= render "admin/shared/tabbed_navbar", menu_items: AdminMenu.nested_menu_items_from_request(request) %> <% end %> diff --git a/cypress/integration/seededFlows/adminFlows/badges/badgeAchievements.spec.js b/cypress/integration/seededFlows/adminFlows/badges/badgeAchievements.spec.js index 7c0552520..0efbd5b00 100644 --- a/cypress/integration/seededFlows/adminFlows/badges/badgeAchievements.spec.js +++ b/cypress/integration/seededFlows/adminFlows/badges/badgeAchievements.spec.js @@ -5,14 +5,83 @@ describe('Badge Achievements', () => { cy.get('@user').then((user) => { cy.loginAndVisit(user, '/admin/content_manager/badge_achievements'); + + cy.findByRole('table').within(() => { + cy.findByRole('button', { name: 'Remove' }).click(); + }); }); }); - it('delete a badge achievement', () => { - cy.findByText('Remove').as('removeBadgeButton'); + describe('delete a badge achievement', () => { + it('should display confirmation modal', () => { + cy.findByRole('dialog').contains('Confirm changes').should('be.visible'); + }); - cy.get('@removeBadgeButton').click(); + it('should display warning text if confirmation text does not match', () => { + cy.findByRole('dialog').within(() => { + cy.get('input').type('Text that does not match.'); + cy.findByRole('button', { name: 'Confirm changes' }).click(); - cy.get('@removeBadgeButton').should('not.exist'); + cy.get('.crayons-notice') + .contains('The confirmation text did not match.') + .should('be.visible'); + + cy.get('button[aria-label="Close"]').click(); + }); + + cy.findByRole('table').within(() => { + cy.findByRole('button', { name: 'Remove' }).should('be.visible'); + }); + }); + + it('should remove badge achievement if confirmation text matches', () => { + cy.get('@user').then((user) => { + cy.findByRole('dialog').within(() => { + cy.get('input').type( + `My username is @${user.username} and this action is 100% safe and appropriate.`, + ); + cy.findByRole('button', { name: 'Confirm changes' }).click(); + }); + + cy.findByTestId('snackbar').within(() => { + cy.findByRole('alert').should( + 'have.text', + 'Badge achievement has been deleted!', + ); + }); + + cy.findByRole('table').within(() => { + cy.findByRole('button', { name: 'Remove' }).should('not.exist'); + }); + }); + }); + + it('generates error message when remove action fails', () => { + cy.intercept('DELETE', '/admin/content_manager/badge_achievements/**', { + statusCode: 422, + body: { + error: 'Something went wrong.', + }, + }); + + cy.get('@user').then((user) => { + cy.findByRole('dialog').within(() => { + cy.get('input').type( + `My username is @${user.username} and this action is 100% safe and appropriate.`, + ); + cy.findByRole('button', { name: 'Confirm changes' }).click(); + }); + + cy.findByTestId('errorzone').within(() => { + cy.findByRole('alert') + .contains('Something went wrong.') + .should('be.visible'); + }); + + cy.findByRole('table').within(() => { + cy.findByRole('button', { name: 'Remove' }).should('be.visible'); + }); + }); + }); }); }); diff --git a/spec/requests/admin/badge_achievements_spec.rb b/spec/requests/admin/badge_achievements_spec.rb index 33a86e4c4..276440e21 100644 --- a/spec/requests/admin/badge_achievements_spec.rb +++ b/spec/requests/admin/badge_achievements_spec.rb @@ -104,7 +104,6 @@ RSpec.describe "/admin/content_manager/badge_achievements", type: :request do expect do delete admin_badge_achievement_path(badge_achievement.id) end.to change { BadgeAchievement.all.count }.by(-1) - expect(response.body).to redirect_to admin_badge_achievements_path end end end