From c119b64b74248af2d1cb8410c61f9a006246121f Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Thu, 2 Jun 2022 16:52:26 +0100 Subject: [PATCH] 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 --- .../__stories__/DateRangePicker.stories.jsx | 3 +- app/javascript/packs/admin/editUser.jsx | 112 +---------- .../packs/admin/users/editUserModals.js | 185 ++++++++++++++++++ .../packs/admin/users/memberIndex.js | 4 +- app/javascript/utilities/showModal.jsx | 14 +- .../admin/users/controls/_export.html.erb | 2 +- .../modals/_add_organization_modal.html.erb | 17 ++ .../users/modals/_add_role_modal.html.erb | 15 ++ .../modals/_adjust_credits_modal.html.erb | 32 +++ .../admin/users/modals/_banish_modal.html.erb | 15 ++ .../users/modals/_unpublish_modal.html.erb | 9 + .../users/show/overview/_credits.html.erb | 45 +---- .../show/overview/_organizations.html.erb | 31 ++- .../admin/users/show/overview/_roles.html.erb | 20 +- .../users/show/profile/_actions.html.erb | 33 +++- .../show/profile/actions/_banish.html.erb | 14 -- .../show/profile/actions/_unpublish.html.erb | 9 - .../adminFlows/users/manageRoles.spec.js | 38 +++- .../admin/admin_bans_or_warns_user_spec.rb | 75 ------- 19 files changed, 374 insertions(+), 299 deletions(-) create mode 100644 app/javascript/packs/admin/users/editUserModals.js create mode 100644 app/views/admin/users/modals/_add_organization_modal.html.erb create mode 100644 app/views/admin/users/modals/_add_role_modal.html.erb create mode 100644 app/views/admin/users/modals/_adjust_credits_modal.html.erb create mode 100644 app/views/admin/users/modals/_banish_modal.html.erb create mode 100644 app/views/admin/users/modals/_unpublish_modal.html.erb delete mode 100644 app/views/admin/users/show/profile/actions/_banish.html.erb delete mode 100644 app/views/admin/users/show/profile/actions/_unpublish.html.erb delete mode 100644 spec/system/admin/admin_bans_or_warns_user_spec.rb diff --git a/app/javascript/crayons/formElements/DateRangePicker/__stories__/DateRangePicker.stories.jsx b/app/javascript/crayons/formElements/DateRangePicker/__stories__/DateRangePicker.stories.jsx index 12432dc4f..bd7e83cda 100644 --- a/app/javascript/crayons/formElements/DateRangePicker/__stories__/DateRangePicker.stories.jsx +++ b/app/javascript/crayons/formElements/DateRangePicker/__stories__/DateRangePicker.stories.jsx @@ -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: { diff --git a/app/javascript/packs/admin/editUser.jsx b/app/javascript/packs/admin/editUser.jsx index 235388220..d943f92a4 100644 --- a/app/javascript/packs/admin/editUser.jsx +++ b/app/javascript/packs/admin/editUser.jsx @@ -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( - { - render(null, modalContainer); - enableEvents(modalContentSelector, false); - }} - > -
- , - modalContainer, - ); -}; - -document.body.addEventListener('click', openModal); +document.body.addEventListener('click', showUserModal); diff --git a/app/javascript/packs/admin/users/editUserModals.js b/app/javascript/packs/admin/users/editUserModals.js new file mode 100644 index 000000000..74649b9d0 --- /dev/null +++ b/app/javascript/packs/admin/users/editUserModals.js @@ -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); + }, + }); +}; diff --git a/app/javascript/packs/admin/users/memberIndex.js b/app/javascript/packs/admin/users/memberIndex.js index a9aa3cf45..e091cf1d6 100644 --- a/app/javascript/packs/admin/users/memberIndex.js +++ b/app/javascript/packs/admin/users/memberIndex.js @@ -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); }, diff --git a/app/javascript/utilities/showModal.jsx b/app/javascript/utilities/showModal.jsx index f66eb937c..26d074437 100644 --- a/app/javascript/utilities/showModal.jsx +++ b/app/javascript/utilities/showModal.jsx @@ -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, }} /> , diff --git a/app/views/admin/users/controls/_export.html.erb b/app/views/admin/users/controls/_export.html.erb index 28c4e646f..337387a3c 100644 --- a/app/views/admin/users/controls/_export.html.erb +++ b/app/views/admin/users/controls/_export.html.erb @@ -1,3 +1,3 @@ - diff --git a/app/views/admin/users/modals/_add_organization_modal.html.erb b/app/views/admin/users/modals/_add_organization_modal.html.erb new file mode 100644 index 000000000..c4137c0e9 --- /dev/null +++ b/app/views/admin/users/modals/_add_organization_modal.html.erb @@ -0,0 +1,17 @@ + diff --git a/app/views/admin/users/modals/_add_role_modal.html.erb b/app/views/admin/users/modals/_add_role_modal.html.erb new file mode 100644 index 000000000..f1a501510 --- /dev/null +++ b/app/views/admin/users/modals/_add_role_modal.html.erb @@ -0,0 +1,15 @@ + diff --git a/app/views/admin/users/modals/_adjust_credits_modal.html.erb b/app/views/admin/users/modals/_adjust_credits_modal.html.erb new file mode 100644 index 000000000..39d617494 --- /dev/null +++ b/app/views/admin/users/modals/_adjust_credits_modal.html.erb @@ -0,0 +1,32 @@ + diff --git a/app/views/admin/users/modals/_banish_modal.html.erb b/app/views/admin/users/modals/_banish_modal.html.erb new file mode 100644 index 000000000..db60ee2ac --- /dev/null +++ b/app/views/admin/users/modals/_banish_modal.html.erb @@ -0,0 +1,15 @@ +
+
+ +
+
This is not a new user. Only Super Admins or Support Admins are allowed to banish .
+
+
+
diff --git a/app/views/admin/users/modals/_unpublish_modal.html.erb b/app/views/admin/users/modals/_unpublish_modal.html.erb new file mode 100644 index 000000000..30142e412 --- /dev/null +++ b/app/views/admin/users/modals/_unpublish_modal.html.erb @@ -0,0 +1,9 @@ +
+ <%= 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| %> +

Once unpublished, all posts by will become hidden and only accessible to themselves.

+

If is not suspended, they can still re-publish their posts from their dashboard.

+
+ +
+ <% end %> +
diff --git a/app/views/admin/users/show/overview/_credits.html.erb b/app/views/admin/users/show/overview/_credits.html.erb index da1051642..f8c820fce 100644 --- a/app/views/admin/users/show/overview/_credits.html.erb +++ b/app/views/admin/users/show/overview/_credits.html.erb @@ -7,41 +7,12 @@

<%= @user.unspent_credits_count %>

- -
-<% current_credits = @user.unspent_credits_count %> - +<%= render "admin/users/modals/adjust_credits_modal" %> diff --git a/app/views/admin/users/show/overview/_organizations.html.erb b/app/views/admin/users/show/overview/_organizations.html.erb index ab72395ac..cf3b3dba0 100644 --- a/app/views/admin/users/show/overview/_organizations.html.erb +++ b/app/views/admin/users/show/overview/_organizations.html.erb @@ -8,7 +8,7 @@ <% if @organization_memberships.load.empty? %>

Not part of any organization yet.

- +
<% else %> <%# Used for aria-describedby on input fields, placed here to avoid duplicate IDs when mapping over orgs %> @@ -28,7 +28,7 @@
<%= form_with model: [:admin, org_membership], method: :delete, local: true, html: { class: "inline" } do |f| %> @@ -49,23 +49,14 @@ <% end %>
<% end %> - + <% end %> - +<%= render "admin/users/modals/add_organization_modal" %> diff --git a/app/views/admin/users/show/overview/_roles.html.erb b/app/views/admin/users/show/overview/_roles.html.erb index 6e9834c00..3d801e9e7 100644 --- a/app/views/admin/users/show/overview/_roles.html.erb +++ b/app/views/admin/users/show/overview/_roles.html.erb @@ -8,7 +8,7 @@ <% unless @user.roles.any? %>

No roles assigned yet.

- +
<% else %> <% end %> - +<%= render "admin/users/modals/add_role_modal" %> diff --git a/app/views/admin/users/show/profile/_actions.html.erb b/app/views/admin/users/show/profile/_actions.html.erb index 305f07ea0..56ae5a588 100644 --- a/app/views/admin/users/show/profile/_actions.html.erb +++ b/app/views/admin/users/show/profile/_actions.html.erb @@ -10,16 +10,33 @@ <% if @user.access_locked? %>
  • <%= link_to "Unlock access", unlock_access_admin_user_path(@user), method: :patch, class: "c-link c-link--block" %>
  • <% end %> -
  • -
  • +
  • +
  • <% if @user.articles_count > 0 %> -
  • +
  • + +
  • <% end %> <% if @user.identities.any? %> -
  • +
  • <% end %> -
  • -
  • +
  • + +
  • +
  • @@ -29,8 +46,8 @@ diff --git a/app/views/admin/users/show/profile/actions/_banish.html.erb b/app/views/admin/users/show/profile/actions/_banish.html.erb deleted file mode 100644 index 63a810f05..000000000 --- a/app/views/admin/users/show/profile/actions/_banish.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
    -
    - <% if @banishable_user %> -

    This action is irreversible.

    -

    Once banished, we will delete all content created by <%= @user.name %> and change their username to @spam_###.

    -

    Be careful with this action.

    - <%= form_for(@user, url: banish_admin_user_path(@user), html: { method: :post, id: nil }) do %> - - <% end %> - <% else %> -
    This is not a new user. Only Super Admins or Support Admins are allowed to banish <%= @user.name %>.
    - <% end %> -
    -
    diff --git a/app/views/admin/users/show/profile/actions/_unpublish.html.erb b/app/views/admin/users/show/profile/actions/_unpublish.html.erb deleted file mode 100644 index 87813cdd5..000000000 --- a/app/views/admin/users/show/profile/actions/_unpublish.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
    - <%= 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| %> -

    Once unpublished, all posts by <%= @user.name %> will become hidden and only accessible to themselves.

    -

    If <%= @user.name %> is not suspended, they can still re-publish their posts from their dashboard.

    -
    - -
    - <% end %> -
    diff --git a/cypress/integration/seededFlows/adminFlows/users/manageRoles.spec.js b/cypress/integration/seededFlows/adminFlows/users/manageRoles.spec.js index 0e8725288..ab8a16299 100644 --- a/cypress/integration/seededFlows/adminFlows/users/manageRoles.spec.js +++ b/cypress/integration/seededFlows/adminFlows/users/manageRoles.spec.js @@ -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', () => { diff --git a/spec/system/admin/admin_bans_or_warns_user_spec.rb b/spec/system/admin/admin_bans_or_warns_user_spec.rb deleted file mode 100644 index 9794d929b..000000000 --- a/spec/system/admin/admin_bans_or_warns_user_spec.rb +++ /dev/null @@ -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