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:
parent
b991af93f9
commit
8cbbc222e9
13 changed files with 280 additions and 126 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
50
app/javascript/admin/controllers/alert_controller.js
Normal file
50
app/javascript/admin/controllers/alert_controller.js
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
33
app/views/admin/shared/_destroy_confirmation_modal.html.erb
Normal file
33
app/views/admin/shared/_destroy_confirmation_modal.html.erb
Normal 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>
|
||||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue