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:
parent
debdf1f804
commit
c119b64b74
19 changed files with 374 additions and 299 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
185
app/javascript/packs/admin/users/editUserModals.js
Normal file
185
app/javascript/packs/admin/users/editUserModals.js
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
15
app/views/admin/users/modals/_add_role_modal.html.erb
Normal file
15
app/views/admin/users/modals/_add_role_modal.html.erb
Normal 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>
|
||||
32
app/views/admin/users/modals/_adjust_credits_modal.html.erb
Normal file
32
app/views/admin/users/modals/_adjust_credits_modal.html.erb
Normal 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>
|
||||
15
app/views/admin/users/modals/_banish_modal.html.erb
Normal file
15
app/views/admin/users/modals/_banish_modal.html.erb
Normal 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>
|
||||
9
app/views/admin/users/modals/_unpublish_modal.html.erb
Normal file
9
app/views/admin/users/modals/_unpublish_modal.html.erb
Normal 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>
|
||||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue