mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 19:48:50 +03:00
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:
parent
c6d12cbe5b
commit
eee84ab5f7
@ -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>
|
@ -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()
|
||||
});
|
||||
|
@ -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 ----------------------------------------------------------------
|
||||
|
@ -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}}
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user