mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +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"}}">
|
<span class="{{if @selectedLabel.slug "gh-contentfilter-selected"}}">
|
||||||
<GhDropdownButton
|
<GhDropdownButton
|
||||||
@dropdownName="members-label-menu"
|
@dropdownName="members-label-menu"
|
||||||
@classNames="gh-contentfilter-menu-trigger" @title="Member Labels"
|
@classNames="gh-contentfilter-menu-trigger"
|
||||||
@data-test-user-actions="true">
|
@title="Member Labels"
|
||||||
|
data-test-button="labels-filter"
|
||||||
|
>
|
||||||
<span class="gh-btn-filter-maxwidth" title="{{@selectedLabel.name}}">
|
<span class="gh-btn-filter-maxwidth" title="{{@selectedLabel.name}}">
|
||||||
<span>{{@selectedLabel.name}}</span>
|
<span>{{@selectedLabel.name}}</span>
|
||||||
{{svg-jar "arrow-down-small"}}
|
{{svg-jar "arrow-down-small"}}
|
||||||
@ -19,7 +21,7 @@
|
|||||||
{{#each @availableLabels as |label|}}
|
{{#each @availableLabels as |label|}}
|
||||||
<li class="{{if (eq @selectedLabel.name label.name) "selected"}}">
|
<li class="{{if (eq @selectedLabel.name label.name) "selected"}}">
|
||||||
<a>
|
<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}}
|
{{#if label.slug}}
|
||||||
<span class="dropdown-action-icon" {{on "click" (fn @onLabelEdit label.slug)}}> {{svg-jar "pen"}} </span>
|
<span class="dropdown-action-icon" {{on "click" (fn @onLabelEdit label.slug)}}> {{svg-jar "pen"}} </span>
|
||||||
{{/if}}
|
{{/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}}
|
{{#if @member.is_loading}}
|
||||||
<div class="gh-list-data gh-members-list-basic gh-list-loadingcell">
|
<div class="gh-list-data gh-members-list-basic gh-list-loadingcell">
|
||||||
<div class="gh-list-loading-title"></div>
|
<div class="gh-list-loading-title"></div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<header class="modal-header" data-test-modal="delete-members">
|
<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>
|
</header>
|
||||||
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
||||||
|
|
||||||
@ -8,7 +8,7 @@
|
|||||||
<p>
|
<p>
|
||||||
You're about to delete
|
You're about to delete
|
||||||
<strong data-test-text="delete-count">{{gh-pluralize this.model.memberCount "member"}}</strong>.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
@ -26,21 +26,18 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}}
|
{{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}}
|
||||||
<p class="ma0 pa0">
|
<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
|
deleted
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{#if this.response.invalid.count}}
|
{{#if this.response.stats.unsuccessful}}
|
||||||
<div class="flex items-start mt2" data-test-bulk-delete-errors>
|
<div class="flex items-start mt2" data-test-bulk-delete-errors>
|
||||||
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
|
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
|
||||||
<div>
|
<div>
|
||||||
<p class="ma0 pa0">
|
<p class="ma0 pa0">
|
||||||
<span class="fw5" data-test-text="invalid-count">{{gh-pluralize this.response.invalid.count "member"}}</span>
|
<span class="fw5" data-test-text="invalid-count">{{gh-pluralize this.response.stats.unsuccessful "member"}}</span>
|
||||||
skipped
|
failed to delete
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@ -55,7 +52,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<GhTaskButton
|
<GhTaskButton
|
||||||
@buttonText="Delete"
|
@buttonText="Delete members"
|
||||||
@successText="Deleted"
|
@successText="Deleted"
|
||||||
@task={{this.deleteMembersTask}}
|
@task={{this.deleteMembersTask}}
|
||||||
@class="gh-btn gh-btn-red gh-btn-icon"
|
@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('response', yield this.confirm());
|
||||||
this.set('confirmed', true);
|
this.set('confirmed', true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.payload.errors) {
|
if (e.payload?.errors) {
|
||||||
this.set('confirmed', true);
|
this.set('confirmed', true);
|
||||||
this.set('error', e.payload.errors[0].message);
|
this.set('error', e.payload.errors[0].message);
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import moment from 'moment';
|
|||||||
import {A} from '@ember/array';
|
import {A} from '@ember/array';
|
||||||
import {action} from '@ember/object';
|
import {action} from '@ember/object';
|
||||||
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
|
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 {inject as service} from '@ember/service';
|
||||||
import {task} from 'ember-concurrency-decorators';
|
import {task} from 'ember-concurrency-decorators';
|
||||||
import {timeout} from 'ember-concurrency';
|
import {timeout} from 'ember-concurrency';
|
||||||
@ -27,6 +28,7 @@ export default class MembersController extends Controller {
|
|||||||
@service feature;
|
@service feature;
|
||||||
@service ghostPaths;
|
@service ghostPaths;
|
||||||
@service membersStats;
|
@service membersStats;
|
||||||
|
@service router;
|
||||||
@service store;
|
@service store;
|
||||||
|
|
||||||
queryParams = [
|
queryParams = [
|
||||||
@ -137,6 +139,10 @@ export default class MembersController extends Controller {
|
|||||||
return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'};
|
return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isFiltered() {
|
||||||
|
return !!(this.label || this.paidParam || this.searchParam);
|
||||||
|
}
|
||||||
|
|
||||||
// Actions -----------------------------------------------------------------
|
// Actions -----------------------------------------------------------------
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@ -345,9 +351,11 @@ export default class MembersController extends Controller {
|
|||||||
|
|
||||||
// reset and reload
|
// reset and reload
|
||||||
this.store.unloadAll('member');
|
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 ----------------------------------------------------------------
|
// Internal ----------------------------------------------------------------
|
||||||
@ -356,9 +364,9 @@ export default class MembersController extends Controller {
|
|||||||
this.searchText = '';
|
this.searchText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload(params) {
|
||||||
this.membersStats.invalidate();
|
this.membersStats.invalidate();
|
||||||
this.membersStats.fetchCounts();
|
this.membersStats.fetchCounts();
|
||||||
this.fetchMembersTask.perform();
|
this.fetchMembersTask.perform(params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,16 +28,22 @@
|
|||||||
|
|
||||||
<div class="view-actions-top-row">
|
<div class="view-actions-top-row">
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<GhDropdownButton @dropdownName="members-actions-menu"
|
<GhDropdownButton
|
||||||
@classNames="gh-btn gh-btn-icon only-has-icon gh-actions-cog" @title="Members Actions"
|
@dropdownName="members-actions-menu"
|
||||||
@data-test-user-actions="true">
|
@classNames="gh-btn gh-btn-icon only-has-icon gh-actions-cog"
|
||||||
|
@title="Members Actions"
|
||||||
|
data-test-button="members-actions"
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
{{svg-jar "settings"}}
|
{{svg-jar "settings"}}
|
||||||
<span class="hidden">Actions</span>
|
<span class="hidden">Actions</span>
|
||||||
</span>
|
</span>
|
||||||
</GhDropdownButton>
|
</GhDropdownButton>
|
||||||
<GhDropdown @name="members-actions-menu" @tagName="ul"
|
<GhDropdown
|
||||||
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right">
|
@name="members-actions-menu"
|
||||||
|
@tagName="ul"
|
||||||
|
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right"
|
||||||
|
>
|
||||||
<li>
|
<li>
|
||||||
<LinkTo @route="members.import" class="mr2" data-test-link="import-csv">
|
<LinkTo @route="members.import" class="mr2" data-test-link="import-csv">
|
||||||
<span>Import members</span>
|
<span>Import members</span>
|
||||||
@ -58,6 +64,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</li>
|
</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>
|
</GhDropdown>
|
||||||
</span>
|
</span>
|
||||||
<LinkTo @route="member.new" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New member</span></LinkTo>
|
<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 faker from 'faker';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {Response} from 'ember-cli-mirage';
|
import {Response} from 'ember-cli-mirage';
|
||||||
import {paginatedResponse} from '../utils';
|
import {extractFilterParam, paginateModelCollection} from '../utils';
|
||||||
|
import {isEmpty} from '@ember/utils';
|
||||||
|
|
||||||
export function mockMembersStats(server) {
|
export function mockMembersStats(server) {
|
||||||
server.get('/members/stats/count', function (db, {queryParams}) {
|
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}));
|
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}) {
|
server.del('/members/', function ({members}, {queryParams}) {
|
||||||
if (queryParams.all !== 'true') {
|
if (!queryParams.filter && !queryParams.search && queryParams.all !== 'true') {
|
||||||
return new Response(422, {}, {errors: [{
|
return new Response(422, {}, {errors: [{
|
||||||
type: 'IncorrectUsageError',
|
type: 'IncorrectUsageError',
|
||||||
message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
|
message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
|
||||||
}]});
|
}]});
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = members.all().length;
|
let membersToDelete = members.all();
|
||||||
members.all().destroy();
|
|
||||||
|
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 {
|
return {
|
||||||
meta: {
|
meta: {
|
||||||
stats: {
|
stats: {
|
||||||
deleted: {
|
successful: count
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,40 +1,8 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {Response} from 'ember-cli-mirage';
|
import {Response} from 'ember-cli-mirage';
|
||||||
import {dasherize} from '@ember/string';
|
import {dasherize} from '@ember/string';
|
||||||
import {isArray} from '@ember/array';
|
import {extractFilterParam, paginateModelCollection} from '../utils';
|
||||||
import {isBlank, isEmpty} from '@ember/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
|
// NOTE: mirage requires Model objects when saving relationships, however the
|
||||||
// `attrs` on POST/PUT requests will contain POJOs for authors and tags so we
|
// `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 faker from 'faker';
|
||||||
import moment from 'moment';
|
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()) {
|
let randomDate = function randomDate(start = moment().subtract(30, 'days').toDate(), end = new Date()) {
|
||||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
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({
|
export default Factory.extend({
|
||||||
name() { return `${faker.name.firstName()} ${faker.name.lastName()}`; },
|
name() { return `${faker.name.firstName()} ${faker.name.lastName()}`; },
|
||||||
email: faker.internet.email,
|
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({
|
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 */
|
/* eslint-disable max-statements-per-line */
|
||||||
import {Response} from 'ember-cli-mirage';
|
import {Response} from 'ember-cli-mirage';
|
||||||
|
import {isArray} from '@ember/array';
|
||||||
|
|
||||||
export function paginatedResponse(modelName) {
|
export function paginatedResponse(modelName) {
|
||||||
return function (schema, request) {
|
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')
|
expect(find('[data-test-input="member-email"]').value, 'email has been preserved')
|
||||||
.to.equal('example@domain.com');
|
.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