Heavy Confirmation Flow for Broadcast-Destroy (#14819)

* hook up frontend parts

* complete backend, save success alert issues

* fix broken existing specs

* complete snackbar confirmation on page redirect

* rollback success alert; but leave foundation for it in place

* preserve a11y features of snackbar; complete cypress tests

* test redirect after braodcast destroy
This commit is contained in:
Arit Amana 2021-09-29 07:43:00 -04:00 committed by GitHub
parent b991af93f9
commit 8cbbc222e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 280 additions and 126 deletions

View file

@ -50,11 +50,9 @@ module Admin
@broadcast = Broadcast.find(params[:id])
if @broadcast.destroy
flash[:success] = "Broadcast has been deleted!"
redirect_to admin_broadcasts_path
render json: { message: "Broadcast has been deleted!" }, status: :ok
else
flash[:danger] = "Something went wrong with deleting the broadcast."
render :edit
render json: { error: "Something went wrong with deleting the broadcast." }, status: :unprocessable_entity
end
end

View file

@ -0,0 +1,50 @@
import { Controller } from 'stimulus';
export default class AlertController extends Controller {
static targets = ['alertZone'];
closeAlert() {
this.alertZoneTarget.innerHTML = '';
}
alertMarkup({ alertMsg, classes }) {
return `
<div
class="mb-3 crayons-notice ${classes}"
style="display:flex; justify-content:space-between;">
<div>${alertMsg}</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->alert#closeAlert"
>
<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>
`;
}
generateErrorAlert(event) {
const { alertMsg } = event.detail;
this.alertZoneTarget.innerHTML = this.alertMarkup({
alertMsg,
classes: 'crayons-notice--danger',
});
}
generateSuccessAlert(event) {
const { alertMsg } = event.detail;
this.alertZoneTarget.innerHTML = this.alertMarkup({
alertMsg,
classes: 'crayons-notice--success',
});
}
}

View file

@ -4,11 +4,29 @@ import { displayErrorAlert, displaySnackbar } from '../messageUtilities';
const confirmationText = (username) =>
`My username is @${username} and this action is 100% safe and appropriate.`;
window.addEventListener('load', () => {
const params = new URLSearchParams(window.location.search);
if (params.has('redirected') && localStorage.getItem('outcome') !== null) {
displaySnackbar(localStorage.getItem('outcome'));
localStorage.removeItem('outcome');
}
});
export default class ConfirmationModalController extends ModalController {
static targets = ['itemId', 'username', 'endpoint'];
removeBadgeAchievement(id) {
return document.querySelector(`[data-row-id="${id}"]`).remove();
handleRecord({ endpoint, id, outcome }) {
switch (endpoint) {
case '/admin/content_manager/badge_achievements':
document.querySelector(`[data-row-id="${id}"]`).remove();
displaySnackbar(outcome.message);
break;
case '/admin/advanced/broadcasts':
localStorage.setItem('outcome', outcome.message);
window.location.replace(`${endpoint}?redirected`);
break;
}
}
async sendToEndpoint({ itemId, endpoint }) {
@ -27,8 +45,11 @@ export default class ConfirmationModalController extends ModalController {
const outcome = await response.json();
if (response.ok) {
this.removeBadgeAchievement(itemId);
displaySnackbar(outcome.message);
this.handleRecord({
endpoint,
id: itemId,
outcome,
});
} else {
displayErrorAlert(outcome.error);
}

View file

@ -1,34 +0,0 @@
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

@ -1,15 +1,15 @@
/**
* A function to generate an error alert within the /admin/ space.
*
* @function errorAlert
* @function displayErrorAlert
* @param {Object} modalProps Properties of the Error Alert
* @param {string} modalProps.errMsg The error message displayed within the alert.
* @param {string} modalProps.alertMsg The message displayed within the alert.
*/
export const displayErrorAlert = function (errMsg) {
export const displayErrorAlert = function (alertMsg) {
return document.dispatchEvent(
new CustomEvent('error:generate', {
detail: { errMsg },
detail: { alertMsg },
}),
);
};

View file

@ -55,37 +55,5 @@
<% 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>
<%= render partial: "admin/shared/destroy_confirmation_modal" %>
</div>

View file

@ -1,47 +1,61 @@
<header class="flex">
<h2 class="crayons-title mb-6"><%= @broadcast.title %></h2>
<div class="ml-auto">
<%= link_to "Destroy", admin_broadcast_path(@broadcast), class: "btn btn-danger", method: :delete, data: { confirm: "Are you sure?" } %>
<%= link_to "Edit", edit_admin_broadcast_path, class: "btn btn-primary" %>
</div>
</header>
<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">
<header class="flex">
<h2 class="crayons-title mb-6"><%= @broadcast.title %></h2>
<div class="ml-auto">
<%= link_to "Edit", edit_admin_broadcast_path, class: "crayons-btn" %>
<button
class="crayons-btn crayons-btn--danger"
data-item-id="<%= @broadcast.id %>"
data-endpoint="/admin/advanced/broadcasts"
data-username="<%= current_user.username %>"
data-action="click->confirmation-modal#openModal">Destroy</button>
</div>
</header>
<div class="crayons-card p-6 grid gap-4 mb-6">
<div>
<p class="fw-bold">Type</p>
<p><%= @broadcast.type_of %></p>
<div class="crayons-card p-6 grid gap-4 mb-6">
<div>
<p class="fw-bold">Type</p>
<p><%= @broadcast.type_of %></p>
</div>
<div>
<p class="fw-bold">Content</p>
<p><%= @broadcast.processed_html %></p>
</div>
<div>
<p class="fw-bold">Last active</p>
<p><%= @broadcast.active_status_updated_at&.strftime("%b %d, %Y %H:%M UTC") %></p>
</div>
<div>
<p class="fw-bold">Status</p>
<p>
<span class="badge badge-<%= @broadcast.active? ? "success" : "warning" %>">
<%= @broadcast.active? ? "Active" : "Inactive" %>
</span>
</p>
</div>
</div>
<div>
<p class="fw-bold">Content</p>
<p><%= @broadcast.processed_html %></p>
</div>
<% if @broadcast.processed_html %>
<div class="crayons-card p-6">
<p><strong>Preview</strong></p>
<div class="broadcast-wrapper visible <%= banner_class(@broadcast) %>">
<div class="broadcast-data">
<%= sanitize @broadcast.processed_html, attributes: %w[href style src] %>
</div>
</div>
<p><em>Please note: announcement broadcasts will render directly below the nav bar once activated.</em></p>
</div>
<% end %>
<div>
<p class="fw-bold">Last active</p>
<p><%= @broadcast.active_status_updated_at&.strftime("%b %d, %Y %H:%M UTC") %></p>
</div>
<div>
<p class="fw-bold">Status</p>
<p>
<span class="badge badge-<%= @broadcast.active? ? "success" : "warning" %>">
<%= @broadcast.active? ? "Active" : "Inactive" %>
</span>
</p>
</div>
<%= render partial: "admin/shared/destroy_confirmation_modal" %>
</div>
<% if @broadcast.processed_html %>
<div class="crayons-card p-6">
<p><strong>Preview</strong></p>
<div class="broadcast-wrapper visible <%= banner_class(@broadcast) %>">
<div class="broadcast-data">
<%= sanitize @broadcast.processed_html, attributes: %w[href style src] %>
</div>
</div>
<p><em>Please note: announcement broadcasts will render directly below the nav bar once activated.</em></p>
<div>
<% end %>
<%= csrf_meta_tags %>

View file

@ -0,0 +1,33 @@
<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>

View file

@ -85,10 +85,10 @@
<% end %>
<div
data-controller="error"
data-action="error:generate@document->error#generateErrorAlert"
data-testid="errorzone">
<div data-error-target="errorZone" role="alert"></div>
data-controller="alert"
data-action="error:generate@document->alert#generateErrorAlert"
data-testid="alertzone">
<div data-alert-target="alertZone" role="alert"></div>
</div>
<% if request.path.split("/")[-3] == "admin" %>

View file

@ -72,7 +72,7 @@ describe('Badge Achievements', () => {
cy.findByRole('button', { name: 'Confirm changes' }).click();
});
cy.findByTestId('errorzone').within(() => {
cy.findByTestId('alertzone').within(() => {
cy.findByRole('alert')
.contains('Something went wrong.')
.should('be.visible');

View file

@ -0,0 +1,94 @@
describe('Broadcasts', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginAndVisit(user, '/admin/advanced/broadcasts');
cy.findByRole('table').within(() => {
cy.findByRole('link', { name: 'Mock Broadcast' }).click();
});
cy.findByRole('button', { name: /Destroy/i }).click();
});
});
describe('delete a broadcast', () => {
it('should display confirmation modal', () => {
cy.findByRole('dialog').contains('Confirm changes').should('be.visible');
});
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('.crayons-notice')
.contains('The confirmation text did not match.')
.should('be.visible');
cy.get('button[aria-label="Close"]').click();
});
cy.findByRole('heading', { level: 2, name: 'Mock Broadcast' }).should(
'be.visible',
);
});
it('should delete the broadcast 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();
});
// testing the redirect after broadcast destroy
cy.url().should('include', '/admin/advanced/broadcasts?redirected');
cy.findByTestId('snackbar').within(() => {
cy.findByRole('alert').should(
'have.text',
'Broadcast has been deleted!',
);
});
cy.findByRole('table').within(() => {
cy.findByRole('link', { name: 'Mock Broadcast' }).should('not.exist');
});
});
});
it.skip('generates error message when destroy action fails', () => {
cy.intercept('DELETE', '/admin/advanced/broadcasts/**', {
statusCode: 422,
body: {
error: 'Something went wrong with deleting the broadcast.',
},
});
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('alertzone').within(() => {
cy.findByRole('alert')
.contains('Something went wrong with deleting the broadcast.')
.should('be.visible');
});
cy.url().should('not.include', '?redirected');
cy.findByRole('heading', { level: 2, name: 'Mock Broadcast' }).should(
'be.visible',
);
});
});
});
});

View file

@ -69,7 +69,6 @@ RSpec.describe "/admin/advanced/broadcasts", type: :request do
expect do
delete admin_broadcast_path(broadcast.id)
end.to change { Broadcast.all.count }.by(-1)
expect(response.body).to redirect_to admin_broadcasts_path
end
end
end
@ -101,7 +100,6 @@ RSpec.describe "/admin/advanced/broadcasts", type: :request do
expect do
delete admin_broadcast_path(broadcast.id)
end.to change { Broadcast.all.count }.by(-1)
expect(response.body).to redirect_to admin_broadcasts_path
end
end
end

View file

@ -610,3 +610,15 @@ seeder.create_if_none(FeedbackMessage) do
category: :bug,
)
end
##############################################################################
seeder.create_if_none(Broadcast) do
Broadcast.create!(
title: "Mock Broadcast",
processed_html: "<p>#{Faker::Hipster.paragraph(sentence_count: 2)}</p>",
type_of: "Welcome",
banner_style: "default",
active: true,
)
end