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 <nick@forem.com>

* consolidate messaging functions

Co-authored-by: Michael Kohl <citizen428@forem.com>
Co-authored-by: Nick Taylor <nick@forem.com>
This commit is contained in:
Arit Amana 2021-09-15 21:25:01 -04:00 committed by GitHub
parent ae90186ad7
commit aa5f18b234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 322 additions and 63 deletions

View file

@ -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

View file

@ -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 ({
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<p class="fw-bold fs-l">${title}</p>
<button type="button" class="crayons-btn crayons-btn--icon crayons-btn--ghost" data-action="click->config#closeAdminModal">
<button type="button" class="crayons-btn crayons-btn--icon crayons-btn--ghost" data-action="click->${controllerName}#${closeModalFunction}">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
@ -42,13 +46,13 @@ export const adminModal = function ({
<div class="crayons-btn-actions">
<button
class="crayons-btn ${leftBtnClasses}"
data-action="click->config#${leftBtnAction}"
data-action="click->${controllerName}#${leftBtnAction}"
${leftCustomDataAttr}>
${leftBtnText}
</button>
<button
class="crayons-btn ${rightBtnClasses}"
data-action="click->config#${rightBtnAction}"
data-action="click->${controllerName}#${rightBtnAction}"
${rightCustomDataAttr}>
${rightBtnText}
</button>

View file

@ -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 = `
<p>If you disable Email address as a registration option, people cannot create an account with their email address.</p>
<p>However, people who have already created an account using their email address can continue to login.</p>
<p><strong>You must confirm and update the settings below to complete this action.</strong></p>`;
<p>However, people who have already created an account using their email address can continue to login.</p>`;
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',

View file

@ -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();
}
}

View file

@ -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 = `
<div
class="crayons-notice crayons-notice--danger mb-3"
style="display:flex; justify-content:space-between;">
<div>${errMsg}</div>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="714d29e78a3867c79b07f310e075e824"
data-action="click->error#closeErrorAlert"
>
<title id="714d29e78a3867c79b07f310e075e824">Close</title>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</div>
`;
}
}

View file

@ -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 },
}),
);
};

View file

@ -1,44 +1,91 @@
<div class="alert alert-warning">
<strong>Note: If you remove a badge that is automatically rewarded it will simply be re-awarded despite being removed.</strong>
</div>
<div class="flex">
<div>
<%= link_to "Award Badge", admin_badge_achievements_award_badges_path, class: "btn btn-primary" %>
<div
data-controller="confirmation-modal"
data-confirmation-modal-root-selector-value="#confirmation-modal-root"
data-confirmation-modal-content-selector-value="#confirmation-modal"
data-confirmation-modal-title-value="Confirm changes"
data-confirmation-modal-size-value="m">
<div class="alert alert-warning">
<strong>Note: If you remove a badge that is automatically rewarded it will simply be re-awarded despite being removed.</strong>
</div>
<div class="ml-auto">
<%= 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" %>
<div class="flex">
<div>
<%= link_to "Award Badge", admin_badge_achievements_award_badges_path, class: "btn btn-primary" %>
</div>
<div class="ml-auto">
<%= 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 %>
</div>
</div>
<table class="crayons-table" width="100%">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">User</th>
<th scope="col">Badge</th>
<th scope="col">Badge Image</th>
</tr>
</thead>
<tbody class="crayons-card">
<% @badge_achievements.each do |badge_achievement| %>
<tr data-row-id="<%= badge_achievement.id %>">
<td class="whitespace-nowrap"><%= badge_achievement.user.id %></td>
<td><%= badge_achievement.user.username %></td>
<td><%= badge_achievement.badge.title %></td>
<h5>
<td class="justify-content-center">
<img class="mx-auto mt-3" width="40" height="40" src="<%= badge_achievement.badge.badge_image %>" alt="badge image" loading="lazy" />
</td>
<td>
<button
class="crayons-btn crayons-btn--danger"
data-item-id="<%= badge_achievement.id %>"
data-endpoint="/admin/content_manager/badge_achievements"
data-username="<%= current_user.username %>"
data-action="click->confirmation-modal#openModal">Remove</button>
</td>
</h5>
</tr>
<% end %>
</tbody>
</table>
<div
id="confirmation-modal-root"
data-confirmation-modal-target="itemId"
data-confirmation-modal-target="endpoint"
data-confirmation-modal-target="username"></div>
<div id="confirmation-modal" class="hidden">
<div class="crayons-field mb-3">
<p id="confirmation-text-instructions">To confirm this update, type in the sentence: <br />
<strong>My username is @<%= current_user.username %> and this action is 100% safe and appropriate.</strong></p>
<div id="mismatch-warning" class="crayons-notice crayons-notice--warning hidden" aria-live="polite">
The confirmation text did not match.
</div>
<input
aria-label="Type the sentence above to confirm this update"
aria-describedby="confirmation-text-instructions"
type="text"
id="confirmation-text-field"
class="crayons-textfield flex-1 mr-2"
placeholder="Confirmation text" />
</div>
<button
class="crayons-btn mr-1 mb-2"
data-action="click->confirmation-modal#checkConfirmationText">
Confirm changes
</button>
<button
class="crayons-btn crayons-btn--secondary"
data-action="click->confirmation-modal#closeModal">
Discard changes
</button>
</div>
</div>
<table class="crayons-table" width="100%">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">User</th>
<th scope="col">Badge</th>
<th scope="col">Badge Image</th>
</tr>
</thead>
<tbody class="crayons-card">
<% @badge_achievements.each do |badge_achievement| %>
<tr>
<td class="whitespace-nowrap"><%= badge_achievement.user.id %></td>
<td><%= badge_achievement.user.username %></td>
<td><%= badge_achievement.badge.title %></td>
<h5>
<td class="justify-content-center">
<img class="mx-auto mt-3" width="40" height="40" src="<%= badge_achievement.badge.badge_image %>" alt="badge image" loading="lazy" />
</td>
<td><%= link_to "Remove", admin_badge_achievement_path(badge_achievement), class: "crayons-btn crayons-btn--danger", method: :delete, data: { confirm: "Are you sure?" } %></td>
</h5>
</tr>
<% end %>
</tbody>
</table>

View file

@ -84,6 +84,13 @@
</div>
<% end %>
<div
data-controller="error"
data-action="error:generate@document->error#generateErrorAlert"
data-testid="errorzone">
<div data-error-target="errorZone" role="alert"></div>
</div>
<% if request.path.split("/")[-3] == "admin" %>
<%= render "admin/shared/tabbed_navbar", menu_items: AdminMenu.nested_menu_items_from_request(request) %>
<% end %>

View file

@ -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');
});
});
});
});
});

View file

@ -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