Added first pass bulk members delete confirmation and results display

no issue

- display a confirmation modal when bulk deleting members
- hit the `DELETE /members/?all=true` endpoint when confirming
- show counts of members deleted/skipped
- fix selection reset when leaving edit mode
This commit is contained in:
Kevin Ansfield 2020-06-19 18:14:14 +01:00
parent c6d12cbe5b
commit eee84ab5f7
6 changed files with 179 additions and 26 deletions

View File

@ -1,16 +1,69 @@
<header class="modal-header">
<header class="modal-header" data-test-modal="delete-members">
<h1>Are you sure you want to delete these members?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>
You're about to delete "<strong>{{pluralize this.model.memberCount "member"}}</strong>".
This is permanent! We warned you, k?
</p>
</div>
{{#if (not this.confirmed)}}
<div class="modal-body" data-test-state="delete-unconfirmed">
<p>
You're about to delete
<strong data-test-text="delete-count">{{pluralize this.model.memberCount "member"}}</strong>.
This is permanent! We warned you, k?
</p>
</div>
{{else}}
<div class="modal-body bg-whitegray-l2 ba b--whitegrey br3 pa4" data-test-state="delete-complete">
{{#if this.error}}
<div class="flex items-center">
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
<div>
<p class="ma0 pa0">
<span class="fw5" data-test-text="delete-error">{{this.error}}</span>
</p>
</div>
</div>
{{else}}
<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">{{pluralize this.response.deleted.count "member"}}</span>
deleted
</p>
</div>
{{#if this.response.invalid.count}}
<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">{{pluralize this.response.invalid.count "member"}}</span>
skipped
</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}}
{{/if}}
</div>
{{/if}}
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
<GhTaskButton @buttonText="Delete" @successText="Deleted" @task={{this.deleteMembersTask}} @class="gh-btn gh-btn-red gh-btn-icon" />
{{#if (not this.confirmed)}}
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel">
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Delete"
@successText="Deleted"
@task={{this.deleteMembersTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
{{else}}
<button {{action "closeModal"}} class="gh-btn" data-test-button="close-modal">
<span>Close</span>
</button>
{{/if}}
</div>

View File

@ -2,6 +2,10 @@ import ModalComponent from 'ghost-admin/components/modal-base';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
confirmed: false,
response: null,
error: null,
// Allowed actions
confirm: () => {},
@ -13,9 +17,15 @@ export default ModalComponent.extend({
deleteMembersTask: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
this.set('response', yield this.confirm());
this.set('confirmed', true);
} catch (e) {
if (e.payload.errors) {
this.set('confirmed', true);
this.set('error', e.payload.errors[0].message);
}
throw e;
}
}).drop()
});

View File

@ -22,9 +22,11 @@ const PAID_PARAMS = [{
}];
export default class MembersController extends Controller {
@service ajax;
@service config;
@service ellaSparse;
@service feature;
@service ghostPaths;
@service membersStats;
@service store;
@ -141,11 +143,18 @@ export default class MembersController extends Controller {
@action
toggleEditMode() {
this.isEditing = !this.isEditing;
if (this.isEditing) {
this.resetSelection();
} else {
this.isEditing = true;
}
}
@action
toggleSelectAll() {
if (this.members.length === 0) {
return this.allSelected = false;
}
this.allSelected = !this.allSelected;
}
@ -289,14 +298,18 @@ export default class MembersController extends Controller {
@task({drop: true})
*deleteMembersTask() {
yield timeout(1000);
alert('Bulk deletion is not implemented yet, nothing has been deleted');
let query = new URLSearchParams({all: true});
let url = `${this.ghostPaths.url.api('members')}?${query}`;
// response contains details of which members failed to be deleted
let response = yield this.ajax.del(url);
// reset and reload
this.store.unloadAll('member');
this.resetSelection();
this.reload();
return true;
return response.meta.stats;
}
// Internal ----------------------------------------------------------------

View File

@ -59,18 +59,18 @@
<section class="content-list">
{{!-- overlaid on header to keep table column sizing --}}
{{#if this.isEditing}}
<div class="members-list-header-overlay">
<div class="members-list-header-overlay" data-test-edit-overlay>
<div class="flex flex-row">
<div>
<input type="checkbox" id="select-all-members" name="select-all-members" {{on "input" this.toggleSelectAll}}>
<label for="select-all-members">{{this.selectAllLabel}}</label>
<input type="checkbox" id="select-all-members" name="select-all-members" {{on "input" this.toggleSelectAll}} data-test-checkbox="select-all">
<label for="select-all-members" data-test-label="selection">{{this.selectAllLabel}}</label>
</div>
<button class="gh-btn" {{on "click" this.toggleDeleteMembersModal}} disabled={{not this.selectedCount}}>
<button class="gh-btn" {{on "click" this.toggleDeleteMembersModal}} disabled={{not this.selectedCount}} data-test-button="delete">
<span>Delete</span>
</button>
</div>
<button class="gh-btn" {{on "click" this.toggleEditMode}}>
<button class="gh-btn" {{on "click" this.toggleEditMode}} data-test-button="done">
<span>Done</span>
</button>
</div>
@ -83,13 +83,13 @@
{{!-- necessary because we add an extra column in the list items --}}
<div class="gh-list-header gh-members-list-checkbox"></div>
{{/if}}
<div class="gh-list-header">{{this.listHeader}}</div>
<div class="gh-list-header" data-test-list-header>{{this.listHeader}}</div>
<div class="gh-list-header gh-members-list-geolocation gh-list-cellwidth-20 nowrap">Location</div>
<div class="gh-list-header gh-members-list-subscribed-at gh-list-cellwidth-20 nowrap">Created</div>
<div class="gh-list-header gh-members-list-chevron gh-list-cellwidth-chevron">
{{!-- TODO: 🍆🖌 --}}
{{#if this.config.enableDeveloperExperiments}}
<button class="gh-btn" style="position: absolute; top: 2px; right: 3px" {{on "click" this.toggleEditMode}}>
<button class="gh-btn" style="position: absolute; top: 2px; right: 3px" {{on "click" this.toggleEditMode}} data-test-button="edit">
<span>Edit</span>
</button>
{{/if}}
@ -100,11 +100,11 @@
@member={{member}}
@isEditing={{this.isEditing}}
@isSelected={{this.allSelected}}
@data-test-member-id={{member.id}}
@data-test-member={{member.id}}
/>
</VerticalCollection>
{{else}}
<li class="no-posts-box">
<li class="no-posts-box" data-test-no-members>
<div class="no-posts">
{{svg-jar "members-placeholder" class="gh-members-placeholder"}}
{{#if this.showingAll}}

View File

@ -1,5 +1,6 @@
import faker from 'faker';
import moment from 'moment';
import {Response} from 'ember-cli-mirage';
import {paginatedResponse} from '../utils';
export function mockMembersStats(server) {
@ -63,6 +64,28 @@ export default function mockMembers(server) {
server.get('/members/', paginatedResponse('members'));
server.del('/members/', function ({members}, {queryParams}) {
if (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();
return {
meta: {
stats: {
deleted: {
count
}
}
}
};
});
server.get('/members/:id/', function ({members}, {params}) {
let {id} = params;
let member = members.find(id);

View File

@ -137,7 +137,7 @@ describe('Acceptance: Members', function () {
expect(find('.gh-canvas-header h2').textContent, 'settings pane title')
.to.contain('New member');
// // all fields start blank
// all fields start blank
findAll('.gh-member-settings-primary .gh-input').forEach(function (elem) {
expect(elem.value, `input field for ${elem.getAttribute('name')}`)
.to.be.empty;
@ -161,4 +161,58 @@ describe('Acceptance: Members', function () {
.to.equal('example@domain.com');
});
});
describe('bulk editing', function () {
beforeEach(async function () {
this.server.loadFixtures('configs');
let role = this.server.create('role', {name: 'Owner'});
this.server.create('user', {roles: [role]});
return await authenticateSession();
});
it('can bulk delete all members', async function () {
this.server.createList('member', 100);
await visit('/members');
expect(find('[data-test-list-header]'), 'initial list header text').to.contain.text('100 members');
expect(find('[data-test-edit-overlay]'), 'initial header edit overlay').to.not.exist;
await click('[data-test-button="edit"]');
expect(find('[data-test-edit-overlay]'), 'edit mode overlay').to.exist;
expect(find('[data-test-label="selection"'), 'initial selection label').to.contain.text('Select all (100)');
expect(find('[data-test-button="delete"]'), 'initial delete button').to.have.attribute('disabled');
await click('[data-test-checkbox="select-all"]');
expect(find('[data-test-label="selection"'), 'post-select-all selection label').to.contain.text('All items selected');
expect(find('[data-test-button="delete"]'), 'post-select-all delete button').to.not.have.attribute('disabled');
await click('[data-test-button="delete"]');
expect(find('[data-test-modal="delete-members"]'), 'post-delete-click delete modal').to.exist;
expect(find('[data-test-state="delete-unconfirmed"]'), 'post-delete-click unconfirmed state').to.exist;
expect(find('[data-test-text="delete-count"]'), 'confirm delete count').to.contain.text('100 members');
await click('[data-test-button="confirm"]');
expect(find('[data-test-no-members]'), 'background no-members state').to.exist;
expect(find('[data-test-state="delete-complete"]'), 'post-confirm complete state').to.exist;
expect(find('[data-test-text="deleted-count"]'), 'post-confirm delete count').to.contain.text('100');
expect(find('[data-test-bulk-delete-errors]'), 'post-confirm errors').to.not.exist;
await click('[data-test-button="close-modal"');
expect(find('[data-test-modal="delete-members"]'), 'post-close delete modal').to.not.exist;
});
it('can handle bulk delete outright error');
it('can handle partial delete success');
it('can bulk delete all members with a filter');
it('can bulk delete all members with a search');
it('can bulk delete all paid/free members');
});
});