mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 00:54:50 +03:00
✨ 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:
parent
556141d613
commit
72590083f3
@ -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}}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
15
ghost/admin/mirage/factories/label.js
Normal file
15
ghost/admin/mirage/factories/label.js
Normal 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};
|
||||
}
|
||||
});
|
@ -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'
|
||||
})
|
||||
});
|
||||
|
5
ghost/admin/mirage/models/label.js
Normal file
5
ghost/admin/mirage/models/label.js
Normal file
@ -0,0 +1,5 @@
|
||||
import {Model, hasMany} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
members: hasMany()
|
||||
});
|
@ -1,4 +1,5 @@
|
||||
import {Model} from 'ember-cli-mirage';
|
||||
import {Model, hasMany} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
labels: hasMany()
|
||||
});
|
||||
|
18
ghost/admin/mirage/serializers/label.js
Normal file
18
ghost/admin/mirage/serializers/label.js
Normal 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);
|
||||
}
|
||||
});
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user