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:
parent
ae90186ad7
commit
aa5f18b234
10 changed files with 322 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
34
app/javascript/admin/controllers/error_controller.js
Normal file
34
app/javascript/admin/controllers/error_controller.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
31
app/javascript/admin/messageUtilities.js
Normal file
31
app/javascript/admin/messageUtilities.js
Normal 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 },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue