Added ability to bulk delete members by label or status (#1883)

refs https://github.com/TryGhost/Team/issues/585
requires https://github.com/TryGhost/Ghost/pull/12082

When a label or status filter is selected on the members screen show a "Delete selected" option in the actions dropdown. Bulk deleted members will _not_ have any subscription data modified in Stripe, if a member should be deleted and have their subscription cancelled it's necessary to do that on a per-member basis.

- updated bulk delete handling to match API
- added link to bulk delete confirmation modal in members actions dropdown (only shown when label, status, or search is used)
- updated testing framework for members
  - added label factory for easier test setup
  - updated `GET /members` and `DEL /members` endpoints to work with label filters
  - updated test selectors for easier reference in tests
This commit is contained in:
Kevin Ansfield 2021-04-08 12:06:27 +01:00 committed by GitHub
parent 556141d613
commit 72590083f3
15 changed files with 245 additions and 68 deletions

View File

@ -2,8 +2,10 @@
<span class="{{if @selectedLabel.slug "gh-contentfilter-selected"}}">
<GhDropdownButton
@dropdownName="members-label-menu"
@classNames="gh-contentfilter-menu-trigger" @title="Member Labels"
@data-test-user-actions="true">
@classNames="gh-contentfilter-menu-trigger"
@title="Member Labels"
data-test-button="labels-filter"
>
<span class="gh-btn-filter-maxwidth" title="{{@selectedLabel.name}}">
<span>{{@selectedLabel.name}}</span>
{{svg-jar "arrow-down-small"}}
@ -19,7 +21,7 @@
{{#each @availableLabels as |label|}}
<li class="{{if (eq @selectedLabel.name label.name) "selected"}}">
<a>
<span class="dropdown-label" title="{{label.name}}" {{on "click" (fn @onLabelChange label)}}>{{label.name}} </span>
<span class="dropdown-label" title="{{label.name}}" {{on "click" (fn @onLabelChange label)}} data-test-label-filter={{label.name}}>{{label.name}} </span>
{{#if label.slug}}
<span class="dropdown-action-icon" {{on "click" (fn @onLabelEdit label.slug)}}> {{svg-jar "pen"}} </span>
{{/if}}

View File

@ -1,4 +1,4 @@
<li class="gh-list-row gh-members-list-item {{if @member.is_loading "loading"}}" ...attributes>
<li class="gh-list-row gh-members-list-item {{if @member.is_loading "loading"}}" data-test-member={{@member.id}} ...attributes>
{{#if @member.is_loading}}
<div class="gh-list-data gh-members-list-basic gh-list-loadingcell">
<div class="gh-list-loading-title"></div>

View File

@ -1,5 +1,5 @@
<header class="modal-header" data-test-modal="delete-members">
<h1>Are you sure you want to delete these members?</h1>
<h1>Delete selected members?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
@ -8,7 +8,7 @@
<p>
You're about to delete
<strong data-test-text="delete-count">{{gh-pluralize this.model.memberCount "member"}}</strong>.
This is permanent! We warned you, k?
This is permanent! All Ghost data will be deleted, this will have no effect on subscriptions in Stripe.
</p>
</div>
{{else}}
@ -26,21 +26,18 @@
<div class="flex items-center">
{{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}}
<p class="ma0 pa0">
<span class="fw6" data-test-text="deleted-count">{{gh-pluralize this.response.deleted.count "member"}}</span>
<span class="fw6" data-test-text="deleted-count">{{gh-pluralize this.response.stats.successful "member"}}</span>
deleted
</p>
</div>
{{#if this.response.invalid.count}}
{{#if this.response.stats.unsuccessful}}
<div class="flex items-start mt2" data-test-bulk-delete-errors>
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
<div>
<p class="ma0 pa0">
<span class="fw5" data-test-text="invalid-count">{{gh-pluralize this.response.invalid.count "member"}}</span>
skipped
<span class="fw5" data-test-text="invalid-count">{{gh-pluralize this.response.stats.unsuccessful "member"}}</span>
failed to delete
</p>
{{#each this.response.invalid.errors as |error|}}
<p class="gh-members-import-errordetail">{{error.message}} <span class="fw6">{{error.count}}</span></p>
{{/each}}
</div>
</div>
{{/if}}
@ -55,7 +52,7 @@
</button>
<GhTaskButton
@buttonText="Delete"
@buttonText="Delete members"
@successText="Deleted"
@task={{this.deleteMembersTask}}
@class="gh-btn gh-btn-red gh-btn-icon"

View File

@ -20,7 +20,7 @@ export default ModalComponent.extend({
this.set('response', yield this.confirm());
this.set('confirmed', true);
} catch (e) {
if (e.payload.errors) {
if (e.payload?.errors) {
this.set('confirmed', true);
this.set('error', e.payload.errors[0].message);
}

View File

@ -4,6 +4,7 @@ import moment from 'moment';
import {A} from '@ember/array';
import {action} from '@ember/object';
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {timeout} from 'ember-concurrency';
@ -27,6 +28,7 @@ export default class MembersController extends Controller {
@service feature;
@service ghostPaths;
@service membersStats;
@service router;
@service store;
queryParams = [
@ -137,6 +139,10 @@ export default class MembersController extends Controller {
return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'};
}
get isFiltered() {
return !!(this.label || this.paidParam || this.searchParam);
}
// Actions -----------------------------------------------------------------
@action
@ -345,9 +351,11 @@ export default class MembersController extends Controller {
// reset and reload
this.store.unloadAll('member');
this.reload();
this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))});
this.membersStats.invalidate();
this.membersStats.fetchCounts();
return response.meta.stats;
return response.meta;
}
// Internal ----------------------------------------------------------------
@ -356,9 +364,9 @@ export default class MembersController extends Controller {
this.searchText = '';
}
reload() {
reload(params) {
this.membersStats.invalidate();
this.membersStats.fetchCounts();
this.fetchMembersTask.perform();
this.fetchMembersTask.perform(params);
}
}

View File

@ -28,16 +28,22 @@
<div class="view-actions-top-row">
<span class="dropdown">
<GhDropdownButton @dropdownName="members-actions-menu"
@classNames="gh-btn gh-btn-icon only-has-icon gh-actions-cog" @title="Members Actions"
@data-test-user-actions="true">
<GhDropdownButton
@dropdownName="members-actions-menu"
@classNames="gh-btn gh-btn-icon only-has-icon gh-actions-cog"
@title="Members Actions"
data-test-button="members-actions"
>
<span>
{{svg-jar "settings"}}
<span class="hidden">Actions</span>
</span>
</GhDropdownButton>
<GhDropdown @name="members-actions-menu" @tagName="ul"
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right">
<GhDropdown
@name="members-actions-menu"
@tagName="ul"
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right"
>
<li>
<LinkTo @route="members.import" class="mr2" data-test-link="import-csv">
<span>Import members</span>
@ -58,6 +64,13 @@
</button>
{{/if}}
</li>
{{#if (and this.members.length this.isFiltered)}}
<li>
<button class="mr2" {{on "click" this.toggleDeleteMembersModal}} data-test-button="delete-selected">
<span class="red">Delete selected members ({{this.members.length}})</span>
</button>
</li>
{{/if}}
</GhDropdown>
</span>
<LinkTo @route="member.new" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New member</span></LinkTo>

View File

@ -1,7 +1,8 @@
import faker from 'faker';
import moment from 'moment';
import {Response} from 'ember-cli-mirage';
import {paginatedResponse} from '../utils';
import {extractFilterParam, paginateModelCollection} from '../utils';
import {isEmpty} from '@ember/utils';
export function mockMembersStats(server) {
server.get('/members/stats/count', function (db, {queryParams}) {
@ -66,25 +67,64 @@ export default function mockMembers(server) {
return members.create(Object.assign({}, attrs, {id: 99}));
});
server.get('/members/', paginatedResponse('members'));
server.get('/members/', function ({members}, {queryParams}) {
let {filter, page, limit} = queryParams;
page = +page || 1;
limit = +limit || 15;
let labelFilter = extractFilterParam('label', filter);
let collection = members.all().filter((member) => {
let matchesLabel = true;
if (!isEmpty(labelFilter)) {
matchesLabel = false;
labelFilter.forEach((slug) => {
if (member.labels.models.find(l => l.slug === slug)) {
matchesLabel = true;
}
});
}
return matchesLabel;
});
return paginateModelCollection('members', collection, page, limit);
});
server.del('/members/', function ({members}, {queryParams}) {
if (queryParams.all !== 'true') {
if (!queryParams.filter && !queryParams.search && queryParams.all !== 'true') {
return new Response(422, {}, {errors: [{
type: 'IncorrectUsageError',
message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
}]});
}
let count = members.all().length;
members.all().destroy();
let membersToDelete = members.all();
if (queryParams.filter) {
let labelFilter = extractFilterParam('label', queryParams.filter);
membersToDelete = membersToDelete.filter((member) => {
let matches = false;
labelFilter.forEach((slug) => {
if (member.labels.models.find(l => l.slug === slug)) {
matches = true;
}
});
return matches;
});
}
let count = membersToDelete.length;
membersToDelete.destroy();
return {
meta: {
stats: {
deleted: {
count
}
successful: count
}
}
};

View File

@ -1,40 +1,8 @@
import moment from 'moment';
import {Response} from 'ember-cli-mirage';
import {dasherize} from '@ember/string';
import {isArray} from '@ember/array';
import {extractFilterParam, paginateModelCollection} from '../utils';
import {isBlank, isEmpty} from '@ember/utils';
import {paginateModelCollection} from '../utils';
function normalizeBooleanParams(arr) {
if (!isArray(arr)) {
return arr;
}
return arr.map((i) => {
if (i === 'true') {
return true;
} else if (i === 'false') {
return false;
} else {
return i;
}
});
}
// TODO: use GQL to parse filter string?
function extractFilterParam(param, filter) {
let filterRegex = new RegExp(`${param}:(.*?)(?:\\+|$)`);
let match;
let [, result] = filter.match(filterRegex) || [];
if (result.startsWith('[')) {
match = result.replace(/^\[|\]$/g, '').split(',');
} else if (result) {
match = [result];
}
return normalizeBooleanParams(match);
}
// NOTE: mirage requires Model objects when saving relationships, however the
// `attrs` on POST/PUT requests will contain POJOs for authors and tags so we

View File

@ -0,0 +1,15 @@
import moment from 'moment';
import {Factory} from 'ember-cli-mirage';
export default Factory.extend({
createdAt() { return moment().toISOString(); },
createdBy: 1,
name(i) { return `Label ${i}`; },
slug(i) { return `label-${i}`; },
updatedAt() { return moment().toISOString(); },
updatedBy: 1,
count() {
// this gets updated automatically by the label serializer
return {members: 0};
}
});

View File

@ -1,6 +1,6 @@
import faker from 'faker';
import moment from 'moment';
import {Factory} from 'ember-cli-mirage';
import {Factory, trait} from 'ember-cli-mirage';
let randomDate = function randomDate(start = moment().subtract(30, 'days').toDate(), end = new Date()) {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
@ -9,5 +9,19 @@ let randomDate = function randomDate(start = moment().subtract(30, 'days').toDat
export default Factory.extend({
name() { return `${faker.name.firstName()} ${faker.name.lastName()}`; },
email: faker.internet.email,
createdAt() { return randomDate(); }
status: 'free',
subscribed: true,
createdAt() { return randomDate(); },
free: trait({
status: 'free'
}),
paid: trait({
status: 'paid'
}),
comped: trait({
status: 'comped'
})
});

View File

@ -0,0 +1,5 @@
import {Model, hasMany} from 'ember-cli-mirage';
export default Model.extend({
members: hasMany()
});

View File

@ -1,4 +1,5 @@
import {Model} from 'ember-cli-mirage';
import {Model, hasMany} from 'ember-cli-mirage';
export default Model.extend({
labels: hasMany()
});

View File

@ -0,0 +1,18 @@
import BaseSerializer from './application';
export default BaseSerializer.extend({
// make the label.count.members value dynamic
serialize(labelModelOrCollection, request) {
let updateMemberCount = (label) => {
label.update('count', {members: label.memberIds.length});
};
if (this.isModel(labelModelOrCollection)) {
updateMemberCount(labelModelOrCollection);
} else {
labelModelOrCollection.models.forEach(updateMemberCount);
}
return BaseSerializer.prototype.serialize.call(this, labelModelOrCollection, request);
}
});

View File

@ -1,5 +1,6 @@
/* eslint-disable max-statements-per-line */
import {Response} from 'ember-cli-mirage';
import {isArray} from '@ember/array';
export function paginatedResponse(modelName) {
return function (schema, request) {
@ -71,3 +72,53 @@ export function versionMismatchResponse() {
}]
});
}
function normalizeBooleanParams(arr) {
if (!isArray(arr)) {
return arr;
}
return arr.map((i) => {
if (i === 'true') {
return true;
} else if (i === 'false') {
return false;
} else {
return i;
}
});
}
function normalizeStringParams(arr) {
if (!isArray(arr)) {
return arr;
}
return arr.map((i) => {
if (!i.replace) {
return i;
}
return i.replace(/^['"]|['"]$/g, '');
});
}
// TODO: use GQL to parse filter string?
export function extractFilterParam(param, filter) {
let filterRegex = new RegExp(`${param}:(.*?)(?:\\+|$)`);
let match;
let [, result] = filter.match(filterRegex) || [];
if (!result) {
return;
}
if (result.startsWith('[')) {
match = result.replace(/^\[|\]$/g, '').split(',');
} else {
match = [result];
}
return normalizeBooleanParams(normalizeStringParams(match));
}

View File

@ -145,5 +145,50 @@ describe('Acceptance: Members', function () {
expect(find('[data-test-input="member-email"]').value, 'email has been preserved')
.to.equal('example@domain.com');
});
it('can bulk delete members', async function () {
// members to be kept
this.server.createList('member', 6);
// imported members to be deleted
const label = this.server.create('label');
this.server.createList('member', 5, {labels: [label]});
await visit('/members');
expect(findAll('[data-test-member]').length).to.equal(11);
await click('[data-test-button="members-actions"]');
expect(find('[data-test-button="delete-selected"]')).to.not.exist;
// a filter is needed for the delete-selected button to show
await click('[data-test-button="labels-filter"]');
await click(`[data-test-label-filter="${label.name}"]`);
expect(findAll('[data-test-member]').length).to.equal(5);
expect(currentURL()).to.equal('/members?label=label-0');
await click('[data-test-button="members-actions"]');
expect(find('[data-test-button="delete-selected"]')).to.exist;
await click('[data-test-button="delete-selected"]');
expect(find('[data-test-modal="delete-members"]')).to.exist;
expect(find('[data-test-text="delete-count"]')).to.have.text('5 members');
await click('[data-test-button="confirm"]');
expect(find('[data-test-text="deleted-count"]')).to.have.text('5 members');
expect(find('[data-test-button="confirm"]')).to.not.exist;
// members filter is reset
// TODO: fix query params reset for empty strings
expect(currentURL()).to.equal('/members?search=');
await click('[data-test-button="close-modal"]');
expect(find('[data-test-modal="delete-members"]')).to.not.exist;
});
});
});