Reusable user admin modals (#17763)

* target modal content by a classname to avoid duplicate IDs

* woops - fix missed selector

* use showWindowModal in editUser

* move add org to a partial

* move add role into a partial

* move adjust credits to a partial

* move profile modals to be re-used

* generalise approach to add organisation modal

* generalise add role modal form

* generalise adjust credits modal

* rework unpublish modal

* refactor banish user modal

* refactors

* prevent issues with duplicate ids

* fix banish form action

* make sure role management specs covered in cypress

* let hidden modal content use IDs

* rename file for clarity

* add some JSDoc notes

* cleanup some redundant changes

* one more

* woops - fixed id that contained a classname
This commit is contained in:
Suzanne Aitchison 2022-06-02 16:52:26 +01:00 committed by GitHub
parent debdf1f804
commit c119b64b74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 374 additions and 299 deletions

View file

@ -12,8 +12,7 @@ export default {
description: 'A unique identifier for the end date input (required)',
},
defaultStartDate: {
description:
'A default value for the start date of the range (optional)',
description: 'A default value for the start date of the range (optional)',
control: 'date',
},
defaultEndDate: {

View file

@ -1,117 +1,9 @@
import { showUserModal } from './users/editUserModals';
import { initializeDropdown } from '@utilities/dropdownUtils';
function adjustCreditRange(event) {
const {
target: { value, name, form },
} = event;
if (name === 'user[credit_action]') {
const creditAmount = form['user[credit_amount]'];
if (value === 'Add') {
if (creditAmount.getAttribute('data-old-max')) {
creditAmount.setAttribute(
'max',
creditAmount.getAttribute('data-old-max'),
);
}
} else {
creditAmount.setAttribute(
'data-old-max',
creditAmount.getAttribute('max'),
);
creditAmount.setAttribute('max', creditAmount.dataset.unspentCredits);
}
}
}
function enableEvents(key, enabled = true) {
if (!eventMap.has(key)) {
return;
}
const [eventType, handler] = eventMap.get(key);
modalContainer[enabled ? 'addEventListener' : 'removeEventListener'](
eventType,
handler,
);
}
function getModalContents(modalContentSelector) {
if (!modalContents.has(modalContentSelector)) {
const modelContentElement = document.querySelector(modalContentSelector);
const modalContent = modelContentElement.innerHTML;
// Remove the element from the DOM to avoid duplicate ID errors in regards to a11y.
modelContentElement.remove();
modalContents.set(modalContentSelector, modalContent);
}
return modalContents.get(modalContentSelector);
}
let preact;
let AdminModal;
const modalContents = new Map();
// Keys are the modalContentSelector data attribute on a button that opens a modal.
// Values are a tuple containing the event type and handler to add to the modal container.
const eventMap = new Map();
eventMap.set('#adjust-balance', ['change', adjustCreditRange]);
// Append an empty div to the end of the document so that is does not affect the layout.
const modalContainer = document.createElement('div');
document.body.appendChild(modalContainer);
initializeDropdown({
triggerElementId: 'options-dropdown-trigger',
dropdownContentId: 'options-dropdown',
});
const openModal = async (event) => {
const { dataset } = event.target;
if (!Object.prototype.hasOwnProperty.call(dataset, 'modalTrigger')) {
// We're not trying to trigger a modal.
return;
}
event.preventDefault();
// Only load Preact if we haven't already.
if (!preact) {
[preact, { Modal: AdminModal }] = await Promise.all([
import('preact'),
import('@crayons/Modal/Modal'),
]);
}
const { h, render } = preact;
const { modalTitle, modalSize, modalContentSelector } = dataset;
enableEvents(modalContentSelector);
render(
<AdminModal
title={modalTitle}
size={modalSize}
onClose={() => {
render(null, modalContainer);
enableEvents(modalContentSelector, false);
}}
>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getModalContents(modalContentSelector),
}}
/>
</AdminModal>,
modalContainer,
);
};
document.body.addEventListener('click', openModal);
document.body.addEventListener('click', showUserModal);

View file

@ -0,0 +1,185 @@
import { WINDOW_MODAL_ID, showWindowModal } from '@utilities/showModal';
const getModalContent = () => document.getElementById(WINDOW_MODAL_ID);
/**
* Populate the add organization modal with user data
*
* @param {Object} dataset
* @param {string} dataset.userName
* @param {string} dataset.userId
*/
const initializeAddOrganizationContent = ({ userName, userId }) => {
const modalContent = getModalContent();
modalContent.querySelector('#organization_membership_user_id').value =
parseInt(userId, 10);
modalContent.querySelector('.js-user-name').innerText = userName;
};
/**
* Populate the add role modal with its action
*
* @param {Object} dataset
* @param {string} dataset.formAction The URL for the form action
*/
const initializeAddRoleContent = ({ formAction }) => {
getModalContent().querySelector('.js-add-role-form').action = formAction;
};
/**
* Populate the adjust credits modal with user data
*
* @param {Object} dataset
* @param {string} dataset.userName
* @param {string} dataset.unspentCreditsCount The user's current credit balance
* @param {string} dataset.formAction The URL for the form action
*/
const initializeAdjustCreditBalanceContent = ({
userName,
unspentCreditsCount,
formAction,
}) => {
const modalContent = getModalContent();
const form = modalContent.querySelector('.js-adjust-credits-form');
form.action = formAction;
const canRemoveCredits = unspentCreditsCount > 0;
if (canRemoveCredits) {
const remove = document.createElement('option');
remove.value = 'Remove';
remove.innerText = 'Remove';
modalContent.querySelector('.js-credit-action').appendChild(remove);
}
modalContent.querySelector('.js-user-name').innerText = userName;
modalContent.querySelector('.js-unspent-credits-count').innerText =
unspentCreditsCount;
modalContent.querySelector('.js-credit-amount').dataset.unspentCredits =
unspentCreditsCount;
form.addEventListener('change', ({ target: { value, name, form } }) => {
if (name === 'user[credit_action]') {
const creditAmount = form['user[credit_amount]'];
if (value === 'Add') {
if (creditAmount.getAttribute('data-old-max')) {
creditAmount.setAttribute(
'max',
creditAmount.getAttribute('data-old-max'),
);
}
} else {
creditAmount.setAttribute(
'data-old-max',
creditAmount.getAttribute('max'),
);
creditAmount.setAttribute('max', creditAmount.dataset.unspentCredits);
}
}
});
};
/**
* Populate the unpublish all posts model with user data
*
* @param {Object} dataset
* @param {string} dataset.formAction The URL for the form action
* @param {string} dataset.userName
*/
const initializeUnpublishAllPostsContent = ({ formAction, userName }) => {
const modalContent = getModalContent();
modalContent.querySelector('.js-unpublish-form').action = formAction;
modalContent
.querySelectorAll('.js-user-name')
.forEach((span) => (span.innerText = userName));
};
/**
* Populate the banish modal with user data
*
* @param {Object} dataset
* @param {string} dataset.formAction The URL for the form action
* @param {string} dataset.userName
* @param {string} dataset.banishableUser "true" or "false" - is it possible to banish this user
*/
const initializeBanishContent = ({ formAction, userName, banishableUser }) => {
const modalContent = getModalContent();
const banishable = banishableUser === 'true';
const banishableContent = modalContent.querySelector('.js-banishable-user');
const notBanishableContent = modalContent.querySelector(
'.js-not-banishable-user',
);
if (banishable) {
banishableContent.classList.remove('hidden');
notBanishableContent.classList.add('hidden');
modalContent.querySelector('.js-banish-form').action = formAction;
} else {
banishableContent.classList.add('hidden');
notBanishableContent.classList.remove('hidden');
}
modalContent
.querySelectorAll('.js-user-name')
.forEach((span) => (span.innerText = userName));
};
const modalContentInitializers = {
'#add-organization': initializeAddOrganizationContent,
'#add-role-modal': initializeAddRoleContent,
'#adjust-balance': initializeAdjustCreditBalanceContent,
'#unpublish-all-posts': initializeUnpublishAllPostsContent,
'#banish-for-spam': initializeBanishContent,
};
const modalContents = new Map();
/**
* Helper function to handle finding and caching modal content. Since our Preact modal helper works by duplicating HTML content,
* and our user modals rely on IDs to label form controls, we remove the original hidden content from the DOM to avoid ID conflicts.
*
* @param {string} modalContentSelector The CSS selector used to identify the correct modal content
*/
const getModalContents = (modalContentSelector) => {
if (!modalContents.has(modalContentSelector)) {
const modalContentElement = document.querySelector(modalContentSelector);
const modalContent = modalContentElement.innerHTML;
modalContentElement.remove();
modalContents.set(modalContentSelector, modalContent);
}
return modalContents.get(modalContentSelector);
};
/**
* Helper function for views which use admin user modals. May be attached as an event listener, and its actions will only be triggered
* if the target of the event is a recognised user modal trigger.
*
* @param {Object} event
*/
export const showUserModal = (event) => {
const { dataset } = event.target;
if (!Object.prototype.hasOwnProperty.call(dataset, 'modalContentSelector')) {
// We're not trying to trigger a modal.
return;
}
event.preventDefault();
const { modalTitle, modalSize, modalContentSelector } = dataset;
showWindowModal({
modalContent: getModalContents(modalContentSelector),
title: modalTitle,
size: modalSize,
onOpen: () => {
modalContentInitializers[modalContentSelector]?.(dataset);
},
});
};

View file

@ -113,10 +113,10 @@ document.querySelectorAll('.js-export-csv-modal-trigger').forEach((item) => {
item.addEventListener('click', () => {
showWindowModal({
title: 'Download Member Data',
contentSelector: '#export-csv-modal',
contentSelector: item.dataset.modalContentSelector,
overlay: true,
onOpen: () => {
document
document
.querySelector('#window-modal .js-export-csv-modal-cancel')
?.addEventListener('click', closeWindowModal);
},

View file

@ -20,14 +20,21 @@ const getModalImports = () => {
};
/**
* This helper function finds the HTML with the given contentSelector, and presents it inside a Preact modal.
* This helper function presents content inside a Preact modal.
*
* The modal content may be passed either as:
* - the actual HTML (using modalContent prop), which will be dropped straight into the modal
* - a CSS selector (using contentSelector prop), which will be used to locate the HTML content on the current page before dropping it into the modal
*
* Only one modal will be presented at any given time. All additional props will be passed directly to the Modal component.
*
* @param {Object} args
* @param {string} args.contentSelector The CSS query to locate the HTML to be presented in the modal (e.g. '#my-modal-content')
* @param {HTMLElement} args.modalContent The HTML to display inside of the modal
* @param {string} args.contentSelector The CSS query to locate the HTML to be presented in the modal, as an alternative to passing the actual HTML (e.g. '#my-modal-content')
* @param {Function} args.onOpen A callback function to run when the modal opens. This can be useful, for example, to attach any event listeners to items inside the modal.
*/
export const showWindowModal = async ({
modalContent,
contentSelector,
onOpen,
...modalProps
@ -56,7 +63,8 @@ export const showWindowModal = async ({
className="h-100 w-100"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: document.querySelector(contentSelector).innerHTML,
__html:
modalContent ?? document.querySelector(contentSelector)?.innerHTML,
}}
/>
</Modal>,

View file

@ -1,3 +1,3 @@
<button type="button" class="js-export-csv-modal-trigger c-btn" data-modal-trigger data-modal-title="Download member data" aria-label="Download member data" data-modal-size="small" data-modal-content-selector="#export-csv-modal">
<button type="button" class="js-export-csv-modal-trigger c-btn" data-modal-title="Download member data" aria-label="Download member data" data-modal-size="small" data-modal-content-selector="#export-csv-modal">
<%= crayons_icon_tag(:export, class: "c-btn__icon", aria_hidden: true) %>
</button>

View file

@ -0,0 +1,17 @@
<div id="add-organization" class="hidden">
<%= form_with model: [:admin, OrganizationMembership.new], local: true, html: { class: "flex flex-col gap-4" } do |f| %>
<%= f.hidden_field :user_id %>
<div class="crayons-field">
<%= f.label :organization_id, "Organization ID", class: "crayons-field__label" %>
<%= f.text_field :organization_id, class: "crayons-textfield", size: 8, required: true, inputmode: "numeric", type: "number", placeholder: "1234" %>
</div>
<div class="crayons-field">
<%= f.label :type_of_user, "Role", class: "crayons-field__label" %>
<p id="add-org-permission-description" class="crayons-field__description">You can assign <span class="js-user-name"></span> to be an Admin or a regular member.</p>
<%= f.select(:type_of_user, options_for_select(%w[member admin]), {}, class: "crayons-select", aria: { describedby: "add-org-permission-description" }) %>
</div>
<div>
<%= f.submit "Add organization", class: "c-btn c-btn--primary" %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,15 @@
<div id="add-role-modal" class="hidden">
<%= form_for(:user, method: "patch", html: { class: "js-add-role-form flex flex-col gap-4", id: nil }) do |f| %>
<div class="crayons-field">
<%= f.label :user_status, "Role", class: "crayons-field__label" %>
<%= f.select(:user_status, grouped_options_for_select(role_options(current_user)), { include_blank: "Select role" }, class: "crayons-select") %>
</div>
<div class="crayons-field">
<%= f.label :note_for_current_role, "Add a note to this action:", class: "crayons-field__label" %>
<%= f.text_area :note_for_current_role, required: true, class: "crayons-textfield", placeholder: "" %>
</div>
<div>
<%= f.button "Add", class: "c-btn c-btn--primary" %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,32 @@
<div id="adjust-balance" class="hidden">
<%= form_with scope: :user, method: :patch, local: true, html: { class: "js-adjust-credits-form flex flex-col gap-4 fs-base" } do |f| %>
<div class="flex items-center gap-2 color-secondary">
<%= crayons_icon_tag(:wallet) %>
<p>
<span class="js-user-name"></span> currently has <span class="color-primary"><span class="js-unspent-credits-count"></span> credits</span>.
</p>
</div>
<div class="crayons-field">
<%= f.label :credit_action, "Adjust balance", class: "crayons-field__label" %>
<div class="flex gap-2 items-center">
<%= f.select :credit_action, options_for_select(%w[Add]), {}, class: "js-credit-action crayons-select w-auto" %>
<%= f.text_field :credit_amount,
type: "number",
required: true,
class: "js-credit-amount crayons-textfield w-auto",
value: 1,
size: 5,
min: 1,
max: 9999 %>
<%= f.label :credit_amount, "credits", class: "color-secondary flex-1", "aria-label": "Amount of credits to add or remove" %>
</div>
</div>
<div class="crayons-field">
<label for="balance-note" class="crayons-field__label">Add a note to this action:</label>
<%= f.text_area :new_note, id: "balance-note", size: 50, required: true, class: "crayons-textfield" %>
</div>
<div>
<button type="submit" class="c-btn c-btn--primary">Adjust balance</button>
</div>
<% end %>
</div>

View file

@ -0,0 +1,15 @@
<div id="banish-for-spam">
<div class="flex flex-col gap-4">
<div class="js-banishable-user hidden">
<p>This action is irreversible.</p>
<p>Once banished, we will delete all content created by <span class="js-user-name"></span> and change their username to @spam_###.</p>
<p>Be careful with this action.</p>
<%= form_for(:user, html: { method: :post, id: nil, class: "js-banish-form" }) do %>
<button class="c-btn c-btn--primary c-btn--destructive">Banish <span class="js-user-name"></span></button>
<% end %>
</div>
<div class="js-not-banishable-user">
<div class="crayons-notice crayons-notice--danger">This is not a new user. Only Super Admins or Support Admins are allowed to banish <span class="js-user-name"></span>.</div>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<div id="unpublish-all-posts">
<%= form_for(:user, html: { class: "js-unpublish-form flex flex-col gap-4", method: :post, onsubmit: "return confirm('Are you sure? All posts will be unavailable to the community.')", id: nil }) do |f| %>
<p>Once unpublished, all posts by <span class="js-user-name"></span> will become hidden and only accessible to themselves.</p>
<p>If <span class="js-user-name"></span> is not suspended, they can still re-publish their posts from their dashboard.</p>
<div>
<button class="c-btn c-btn--destructive c-btn--primary">Unpublish all posts</button>
</div>
<% end %>
</div>

View file

@ -7,41 +7,12 @@
</header>
<div class="flex justify-between items-center">
<p data-testid="user-credits" class="fs-2xl fw-heavy"><%= @user.unspent_credits_count %></p>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-trigger data-modal-title="Adjust balance" data-modal-size="small" data-modal-content-selector="#adjust-balance">Adjust balance</button>
</div>
<% current_credits = @user.unspent_credits_count %>
<div id="adjust-balance" class="hidden">
<%= form_with scope: :user, url: admin_user_path(@user), method: :patch, local: true, html: { class: "flex flex-col gap-4 fs-base" } do |f| %>
<div class="flex items-center gap-2 color-secondary">
<%= crayons_icon_tag(:wallet) %>
<p>
<%= @user.name %> currently has <span class="color-primary"><%= @user.unspent_credits_count %> credits</span>.
</p>
</div>
<div class="crayons-field">
<%= f.label :credit_action, "Adjust balance", class: "crayons-field__label" %>
<div class="flex gap-2 items-center">
<%= f.select :credit_action, options_for_select(current_credits.positive? ? %w[Add Remove] : %w[Add]), {}, class: "crayons-select w-auto" %>
<%= f.text_field :credit_amount,
type: "number",
required: true,
class: "crayons-textfield w-auto",
value: 1,
size: 5,
min: 1,
max: 9999,
data: {
"unspent-credits": @user.unspent_credits_count
} %>
<%= f.label :credit_amount, "credits", class: "color-secondary flex-1", "aria-label": "Amount of credits to add or remove" %>
</div>
</div>
<div class="crayons-field">
<label for="balance-note" class="crayons-field__label">Add a note to this action:</label>
<%= f.text_area :new_note, id: "balance-note", size: 50, required: true, class: "crayons-textfield" %>
</div>
<div>
<button type="submit" class="c-btn c-btn--primary">Adjust balance</button>
</div>
<% end %>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button"
data-modal-title="Adjust balance"
data-modal-size="small"
data-modal-content-selector="#adjust-balance"
data-user-name="<%= @user.name %>"
data-unspent-credits-count="<%= @user.unspent_credits_count %>"
data-form-action="<%= admin_user_path(@user) %>">Adjust balance</button>
</div>
<%= render "admin/users/modals/adjust_credits_modal" %>

View file

@ -8,7 +8,7 @@
<% if @organization_memberships.load.empty? %>
<div class="flex justify-between items-center gap-4">
<p>Not part of any organization yet.</p>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-trigger data-modal-title="Add organization" data-modal-size="small" data-modal-content-selector="#add-organization">Add organization</button>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-title="Add organization" data-modal-size="small" data-modal-content-selector="#add-organization" data-user-name="<%= @user.name %>" data-user-id="<%= @user.id %>">Add organization</button>
</div>
<% else %>
<%# Used for aria-describedby on input fields, placed here to avoid duplicate IDs when mapping over orgs %>
@ -28,7 +28,7 @@
</div>
<div class="c-card__revealable shrink-0">
<button type="button" class="c-btn c-btn--icon-alone" aria-label="Edit <%= org_membership.organization.name %> organization membership"
data-modal-trigger data-modal-title="Edit <%= @user.name %>'s role at an organization" data-modal-size="small" data-modal-content-selector="#edit-organization-<%= org_membership.organization.id %>">
data-modal-title="Edit <%= @user.name %>'s role at an organization" data-modal-size="small" data-modal-content-selector="#edit-organization-<%= org_membership.organization.id %>">
<%= crayons_icon_tag(:edit, class: "c-btn__icon", aria_hidden: true) %>
</button>
<%= form_with model: [:admin, org_membership], method: :delete, local: true, html: { class: "inline" } do |f| %>
@ -49,23 +49,14 @@
<% end %>
</div>
<% end %>
<button class="c-btn c-btn--secondary whitespace-nowrap w-100" type="button" data-modal-trigger data-modal-title="Add <%= @user.name %> to an organization" data-modal-size="small" data-modal-content-selector="#add-organization">Add organization</button>
<button
class="c-btn c-btn--secondary whitespace-nowrap w-100"
type="button"
data-modal-title="Add <%= @user.name %> to an organization"
data-modal-size="small"
data-modal-content-selector="#add-organization"
data-user-name="<%= @user.name %>"
data-user-id="<%= @user.id %>">Add organization</button>
<% end %>
<div id="add-organization" class="hidden">
<%= form_with model: [:admin, OrganizationMembership.new], local: true, html: { class: "flex flex-col gap-4" } do |f| %>
<%= f.hidden_field :user_id, value: @user.id %>
<div class="crayons-field">
<%= f.label :organization_id, "Organization ID", class: "crayons-field__label" %>
<%= f.text_field :organization_id, class: "crayons-textfield", size: 8, required: true, inputmode: "numeric", type: "number", placeholder: "1234" %>
</div>
<div class="crayons-field">
<%= f.label :type_of_user, "Role", class: "crayons-field__label" %>
<p id="add-org-permission-description" class="crayons-field__description">You can assign <%= @user.name %> to be an Admin or a regular member.</p>
<%= f.select(:type_of_user, options_for_select(%w[member admin]), {}, class: "crayons-select", aria: { describedby: "add-org-permission-description" }) %>
</div>
<div>
<%= f.submit "Add organization", class: "c-btn c-btn--primary" %>
</div>
<% end %>
</div>
<%= render "admin/users/modals/add_organization_modal" %>

View file

@ -8,7 +8,7 @@
<% unless @user.roles.any? %>
<div class="flex justify-between items-center">
<p>No roles assigned yet.</p>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-trigger data-modal-title="Add role" data-modal-size="m" data-modal-content-selector="#add-role">Assign role</button>
<button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-title="Add role" data-modal-size="m" data-modal-content-selector="#add-role-modal" data-form-action="<%= user_status_admin_user_path(@user) %>">Assign role</button>
</div>
<% else %>
<ul class="flex flex-wrap gap-2">
@ -40,22 +40,8 @@
<% end %>
</li>
<% end %>
<li><button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-trigger data-modal-title="Add role" data-modal-size="small" data-modal-content-selector="#add-role">Assign role</button></li>
<li><button class="c-btn c-btn--secondary whitespace-nowrap" type="button" data-modal-title="Add role" data-modal-size="small" data-modal-content-selector="#add-role-modal" data-form-action="<%= user_status_admin_user_path(@user) %>">Assign role</button></li>
</ul>
<% end %>
<div id="add-role" class="hidden">
<%= form_for(@user, url: user_status_admin_user_path(@user), html: { class: "flex flex-col gap-4", id: nil }) do |f| %>
<div class="crayons-field">
<%= f.label :user_status, "Role", class: "crayons-field__label" %>
<%= f.select(:user_status, grouped_options_for_select(role_options(current_user)), { include_blank: "Select role" }, class: "crayons-select") %>
</div>
<div class="crayons-field">
<%= f.label :note_for_current_role, "Add a note to this action:", class: "crayons-field__label" %>
<%= f.text_area :note_for_current_role, required: true, class: "crayons-textfield", placeholder: "" %>
</div>
<div>
<%= f.button "Add", class: "c-btn c-btn--primary" %>
</div>
<% end %>
</div>
<%= render "admin/users/modals/add_role_modal" %>

View file

@ -10,16 +10,33 @@
<% if @user.access_locked? %>
<li><%= link_to "Unlock access", unlock_access_admin_user_path(@user), method: :patch, class: "c-link c-link--block" %></li>
<% end %>
<li><button type="button" class="c-btn w-100 align-left" data-modal-trigger data-modal-title="Export <%= @user.name %>'s data" data-modal-size="small" data-modal-content-selector="#export-data">Export data</button></li>
<li><button type="button" class="c-btn w-100 align-left" data-modal-trigger data-modal-title="Merge users" data-modal-size="small" data-modal-content-selector="#merge-accounts">Merge users</button></li>
<li><button type="button" class="c-btn w-100 align-left" data-modal-title="Export <%= @user.name %>'s data" data-modal-size="small" data-modal-content-selector="#export-data">Export data</button></li>
<li><button type="button" class="c-btn w-100 align-left" data-modal-title="Merge users" data-modal-size="small" data-modal-content-selector="#merge-accounts">Merge users</button></li>
<% if @user.articles_count > 0 %>
<li><button type="button" class="c-btn w-100 align-left" data-modal-trigger data-modal-title="Unpublish all posts" data-modal-size="small" data-modal-content-selector="#unpublish-all-posts">Unpublish all posts</button></li>
<li>
<button type="button"
class="c-btn w-100 align-left"
data-modal-title="Unpublish all posts"
data-modal-size="small"
data-modal-content-selector="#unpublish-all-posts"
data-form-action="<%= unpublish_all_articles_admin_user_path(@user) %>"
data-user-name="<%= @user.name %>">Unpublish all posts</button>
</li>
<% end %>
<% if @user.identities.any? %>
<li><button type="button" class="c-btn w-100 align-left" data-modal-trigger data-modal-title="Remove social accounts" data-modal-size="medium" data-modal-content-selector="#remove-social-accounts">Remove social accounts</button></li>
<li><button type="button" class="c-btn w-100 align-left" data-modal-title="Remove social accounts" data-modal-size="medium" data-modal-content-selector="#remove-social-accounts">Remove social accounts</button></li>
<% end %>
<li><button type="button" class="c-btn c-btn--destructive w-100 align-left" data-modal-trigger data-modal-title="Banish <%= @user.name %>" data-modal-size="small" data-modal-content-selector="#banish-for-spam">Banish user</button></li>
<li><button type="button" class="c-btn c-btn--destructive w-100 align-left" data-modal-trigger data-modal-title="Delete <%= @user.name %>'s account" data-modal-size="small" data-modal-content-selector="#delete-user">Delete user</button></li>
<li>
<button type="button"
class="c-btn c-btn--destructive w-100 align-left"
data-modal-title="Banish <%= @user.name %>"
data-modal-size="small"
data-modal-content-selector="#banish-for-spam"
data-form-action="<%= banish_admin_user_path(@user) %>"
data-user-name="<%= @user.name %>"
data-banishable-user="<%= @banishable_user %>">Banish user</button>
</li>
<li><button type="button" class="c-btn c-btn--destructive w-100 align-left" data-modal-title="Delete <%= @user.name %>'s account" data-modal-size="small" data-modal-content-selector="#delete-user">Delete user</button></li>
</ul>
</div>
</div>
@ -29,8 +46,8 @@
<div class="hidden">
<%= render "admin/users/show/profile/actions/export" %>
<%= render "admin/users/show/profile/actions/merge" %>
<%= render "admin/users/show/profile/actions/unpublish" %>
<%= render "admin/users/modals/unpublish_modal" %>
<%= render "admin/users/show/profile/actions/social_accounts" %>
<%= render "admin/users/show/profile/actions/banish" %>
<%= render "admin/users/modals/banish_modal" %>
<%= render "admin/users/show/profile/actions/delete" %>
</div>

View file

@ -1,14 +0,0 @@
<div id="banish-for-spam">
<div class="flex flex-col gap-4">
<% if @banishable_user %>
<p>This action is irreversible.</p>
<p>Once banished, we will delete all content created by <%= @user.name %> and change their username to @spam_###.</p>
<p>Be careful with this action.</p>
<%= form_for(@user, url: banish_admin_user_path(@user), html: { method: :post, id: nil }) do %>
<button class="c-btn c-btn--primary c-btn--destructive">Banish <%= @user.name %></button>
<% end %>
<% else %>
<div class="crayons-notice crayons-notice--danger">This is not a new user. Only Super Admins or Support Admins are allowed to banish <%= @user.name %>.</div>
<% end %>
</div>
</div>

View file

@ -1,9 +0,0 @@
<div id="unpublish-all-posts">
<%= form_for(@user, url: unpublish_all_articles_admin_user_path(@user), html: { class: "flex flex-col gap-4", method: :post, onsubmit: "return confirm('Are you sure? All posts will be unavailable to the community.')", id: nil }) do |f| %>
<p>Once unpublished, all posts by <%= @user.name %> will become hidden and only accessible to themselves.</p>
<p>If <%= @user.name %> is not suspended, they can still re-publish their posts from their dashboard.</p>
<div>
<button class="c-btn c-btn--destructive c-btn--primary">Unpublish all posts</button>
</div>
<% end %>
</div>

View file

@ -27,7 +27,7 @@ describe('Manage User Roles', () => {
cy.visit('/admin/member_manager/users/2');
});
it('should change a role', () => {
it('Remove other roles and add a note when Warn role added', () => {
checkUserStatus('Trusted');
cy.findByRole('button', { name: 'Remove role: Trusted' }).should(
@ -51,6 +51,42 @@ describe('Manage User Roles', () => {
cy.findByRole('button', { name: 'Remove role: Trusted' }).should(
'not.exist',
);
cy.findByRole('navigation', { name: 'Member details' })
.findByRole('link', { name: 'Notes' })
.click();
cy.findByText('some reason').should('exist');
cy.findByText(/Warn by/).should('exist');
});
it('should remove other roles & add a note when Suspend role added', () => {
cy.findByRole('button', { name: 'Remove role: Trusted' });
openRolesModal().within(() => {
cy.findByRole('combobox', { name: 'Role' }).select('Suspend');
cy.findByRole('textbox', { name: 'Add a note to this action:' }).type(
'some reason',
);
cy.findByRole('button', { name: 'Add' }).click();
});
cy.getModal().should('not.exist');
verifyAndDismissUserUpdatedMessage();
cy.findByRole('button', {
name: "Suspended You can't remove this role.",
}).should('exist');
checkUserStatus('Suspended');
cy.findByRole('button', { name: 'Remove role: Trusted' }).should(
'not.exist',
);
cy.findByRole('navigation', { name: 'Member details' })
.findByRole('link', { name: 'Notes' })
.click();
cy.findByText('some reason').should('exist');
cy.findByText(/Suspend by/).should('exist');
});
it('should remove a role', () => {

View file

@ -1,75 +0,0 @@
require "rails_helper"
RSpec.describe "Admin bans user", type: :system do
let(:admin) { create(:user, :super_admin) }
let(:user) { create(:user) }
before do
sign_in admin
visit admin_user_path(user.id)
end
def suspend_user
visit admin_user_path(user.id)
select("Suspend", from: "user_user_status")
fill_in("user_note_for_current_role", with: "something")
click_button("Add")
expect(page).to have_content("User has been updated")
end
def warn_user
visit admin_user_path(user.id)
select("Warn", from: "user_user_status")
fill_in("user_note_for_current_role", with: "something")
click_button("Add")
expect(page).to have_content("User has been updated")
end
def add_tag_moderator_role
tag = create(:tag)
user.add_role(:tag_moderator, tag)
end
def unsuspend_user
visit admin_user_path(user.id)
select("Regular Member", from: "user_user_status")
fill_in("user_note_for_current_role", with: "good user")
click_button("Add")
expect(page).to have_content("User has been updated")
end
it "checks that the user is warned, has a note, and privileges are removed" do
user.add_role(:trusted)
add_tag_moderator_role
warn_user
expect(user.warned?).to be(true)
expect(Note.last.reason).to eq "Warn"
expect(user.tag_moderator?).to be(false)
end
# to-do: add spec for invalid bans
it "checks that the user is suspended and has note" do
suspend_user
expect(user.suspended?).to be(true)
expect(Note.last.reason).to eq "Suspend"
end
it "removes other roles if user is suspended" do
user.add_role(:trusted)
add_tag_moderator_role
suspend_user
expect(user.suspended?).to be(true)
expect(user.trusted?).to be(false)
expect(user.warned?).to be(false)
expect(user.tag_moderator?).to be(false)
end
it "unsuspends user" do
user.add_role(:suspended)
unsuspend_user
expect(user.suspended?).to be(false)
end
end