Added bulk operations UI for filtered members

refs https://github.com/TryGhost/Team/issues/969

A lot of power of filtering members comes from ability to perform actions on the filtered member list. This change adds bulk operation actions on the the UI to apply on filtered members, but has not wired them up to the API yet.

- adds unsubscribe bulk operation UI
- adds label addition bulk operation UI
- adds label removal bulk operation UI
- adds new single label selection UI for add/remove label to members UI
This commit is contained in:
Rishabh 2021-08-13 17:11:34 +05:30
parent a34fac50b0
commit 38a3962368
10 changed files with 423 additions and 0 deletions

View File

@ -0,0 +1,10 @@
<span class="gh-select">
<OneWaySelect
@options={{this.availableLabels}}
@optionValuePath="slug"
@optionLabelPath="name"
@optionTargetPath="slug"
@update={{this.updateLabel}}
/>
{{svg-jar "arrow-down-small"}}
</span>

View File

@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class GhMemberLabelInput extends Component {
@service store;
@tracked selectedLabel;
get availableLabels() {
return this._availableLabels.toArray().sort((labelA, labelB) => {
return labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true});
});
}
get availableLabelNames() {
return this.availableLabels.map(label => label.name.toLowerCase());
}
constructor(...args) {
super(...args);
// perform a background query to fetch all users and set `availableLabels`
// to a live-query that will be immediately populated with what's in the
// store and be updated when the above query returns
this.store.query('label', {limit: 'all'});
this._availableLabels = this.store.peekAll('label');
this.selectedLabel = this.args.label || null;
}
@action
updateLabel(newLabel) {
// update labels
this.selectedLabel = newLabel;
this.args.onChange(newLabel);
}
}

View File

@ -0,0 +1,36 @@
<header class="modal-header" data-test-modal="delete-members">
<h1>Add Label</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
{{#if (not this.confirmed)}}
<div class="modal-body" data-test-state="add-label-unconfirmed">
<GhMemberSingleLabelInput @onChange={{action "setLabel"}} @triggerId="label-input" data-test-input="" />
<p class="mt2 ml1">
Will be added to the currently selected <span class="fw6" data-test-text="member-count">{{gh-pluralize this.model.memberCount "member"}}</span>
</p>
</div>
{{else}}
<div class="gh-content-box pa" data-test-state="add-label-complete">
</div>
{{/if}}
<div class="modal-footer">
{{#if (not this.confirmed)}}
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel">
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Add Label"
@successText="Added"
@task={{this.deleteMembersTask}}
@class="gh-btn gh-btn-green gh-btn-icon"
data-test-button="confirm"
/>
{{else}}
<button {{action "closeModal"}} class="gh-btn gh-btn-black" data-test-button="close-modal">
<span>Close</span>
</button>
{{/if}}
</div>

View File

@ -0,0 +1,33 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
membersStats: service(),
selectedLabel: null,
// Allowed actions
confirm: () => {},
member: alias('model'),
actions: {
confirm() {
this.deleteMember.perform();
},
setLabel(label) {
this.set('selectedLabel', label);
}
},
deleteMember: task(function* () {
try {
yield this.confirm(this.shouldCancelSubscriptions);
this.membersStats.invalidate();
} finally {
this.send('closeModal');
}
}).drop()
});

View File

@ -0,0 +1,36 @@
<header class="modal-header" data-test-modal="delete-members">
<h1>Remove Label</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
{{#if (not this.confirmed)}}
<div class="modal-body" data-test-state="add-label-unconfirmed">
<GhMemberSingleLabelInput @onChange={{action "setLabel"}} @triggerId="label-input" data-test-input="" />
<p class="mt2 ml1">
Will be removed from the currently selected <span class="fw6" data-test-text="member-count">{{gh-pluralize this.model.memberCount "member"}}</span>
</p>
</div>
{{else}}
<div class="gh-content-box pa" data-test-state="add-label-complete">
</div>
{{/if}}
<div class="modal-footer">
{{#if (not this.confirmed)}}
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel">
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Remove Label"
@successText="Removed"
@task={{this.deleteMembersTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
{{else}}
<button {{action "closeModal"}} class="gh-btn gh-btn-black" data-test-button="close-modal">
<span>Close</span>
</button>
{{/if}}
</div>

View File

@ -0,0 +1,33 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
membersStats: service(),
selectedLabel: null,
// Allowed actions
confirm: () => {},
member: alias('model'),
actions: {
confirm() {
this.deleteMember.perform();
},
setLabel(label) {
this.set('selectedLabel', label);
}
},
deleteMember: task(function* () {
try {
yield this.confirm(this.shouldCancelSubscriptions);
this.membersStats.invalidate();
} finally {
this.send('closeModal');
}
}).drop()
});

View File

@ -0,0 +1,68 @@
<header class="modal-header" data-test-modal="delete-members">
<h1>Unsubscribe selected members?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
{{#if (not this.confirmed)}}
<div class="modal-body" data-test-state="delete-unconfirmed">
<p>
You're about to unsubscribe
<strong data-test-text="unsubscribe-count">{{gh-pluralize this.model.memberCount "member"}}</strong>.
</p>
<p>
Are you sure ?
</p>
</div>
{{else}}
<div class="gh-content-box pa" 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">{{gh-pluralize this.response.stats.successful "member"}}</span>
successfully deleted
</p>
</div>
{{#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.stats.unsuccessful "member"}}</span>
failed to delete
</p>
</div>
</div>
{{/if}}
{{/if}}
</div>
{{/if}}
<div class="modal-footer">
{{#if (not this.confirmed)}}
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel">
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Unsubscribe members"
@successText="Unsubscribed"
@task={{this.deleteMembersTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
{{else}}
<button {{action "closeModal"}} class="gh-btn gh-btn-black" data-test-button="close-modal">
<span>Close</span>
</button>
{{/if}}
</div>

View File

@ -0,0 +1,52 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {reads} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
membersStats: service(),
shouldCancelSubscriptions: false,
// Allowed actions
confirm: () => {},
member: alias('model'),
cancelSubscriptions: reads('shouldCancelSubscriptions'),
hasActiveStripeSubscriptions: computed('member', function () {
let subscriptions = this.member.get('subscriptions');
if (!subscriptions || subscriptions.length === 0) {
return false;
}
let firstActiveStripeSubscription = subscriptions.find((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status);
});
return firstActiveStripeSubscription !== undefined;
}),
actions: {
confirm() {
this.deleteMember.perform();
},
toggleShouldCancelSubscriptions() {
this.set('shouldCancelSubscriptions', !this.shouldCancelSubscriptions);
}
},
deleteMember: task(function* () {
try {
yield this.confirm(this.shouldCancelSubscriptions);
this.membersStats.invalidate();
} finally {
this.send('closeModal');
}
}).drop()
});

View File

@ -52,6 +52,9 @@ export default class MembersController extends Controller {
@tracked modalLabel = null;
@tracked showLabelModal = false;
@tracked showDeleteMembersModal = false;
@tracked showUnsubscribeMembersModal = false;
@tracked showAddMembersLabelModal = false;
@tracked showRemoveMembersLabelModal = false;
@tracked filters = A([]);
@tracked _availableLabels = A([]);
@ -293,11 +296,41 @@ export default class MembersController extends Controller {
this.showDeleteMembersModal = !this.showDeleteMembersModal;
}
@action
toggleUnsubscribeMembersModal() {
this.showUnsubscribeMembersModal = !this.showUnsubscribeMembersModal;
}
@action
toggleAddMembersLabelModal() {
this.showAddMembersLabelModal = !this.showAddMembersLabelModal;
}
@action
toggleRemoveMembersLabelModal() {
this.showRemoveMembersLabelModal = !this.showRemoveMembersLabelModal;
}
@action
deleteMembers() {
return this.deleteMembersTask.perform();
}
@action
unsubscribeMembers() {
return this.unsubscribeMembersTask.perform();
}
@action
addLabelsToMembers() {
return this.addLabelToMembersTask.perform();
}
@action
removeLabelsFromMembers() {
return this.removeLabelFromMembersTask.perform();
}
// Tasks -------------------------------------------------------------------
@task({restartable: true})
@ -411,6 +444,44 @@ export default class MembersController extends Controller {
return response.meta;
}
@task({drop: true})
*unsubscribeMembersTask() {
yield Promise.resolve();
// reset and reload
this.store.unloadAll('member');
this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))});
this.membersStats.invalidate();
this.membersStats.fetchCounts();
return {};
}
@task({drop: true})
*addLabelToMembersTask() {
yield Promise.resolve();
// reset and reload
this.store.unloadAll('member');
this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))});
this.membersStats.invalidate();
this.membersStats.fetchCounts();
return {};
}
@task({drop: true})
*removeLabelFromMembersTask() {
yield Promise.resolve();
// reset and reload
this.store.unloadAll('member');
this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))});
this.membersStats.invalidate();
this.membersStats.fetchCounts();
return {};
}
// Internal ----------------------------------------------------------------
resetSearch() {

View File

@ -73,6 +73,23 @@
{{/if}}
</li>
{{#if (and this.members.length this.isFiltered)}}
{{#if (feature "membersFiltering")}}
<li>
<button class="mr2" {{on "click" this.toggleAddMembersLabelModal}} data-test-button="add-label-selected">
<span>Add label for selected members ({{this.members.length}})</span>
</button>
</li>
<li>
<button class="mr2" {{on "click" this.toggleRemoveMembersLabelModal}} data-test-button="remove-label-selected">
<span>Remove label for selected members ({{this.members.length}})</span>
</button>
</li>
<li>
<button class="mr2" {{on "click" this.toggleUnsubscribeMembersModal}} data-test-button="remove-label-selected">
<span>Unsubscribe selected members ({{this.members.length}})</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" {{on "click" this.toggleDeleteMembersModal}} data-test-button="delete-selected">
<span class="red">Delete selected members ({{this.members.length}})</span>
@ -162,6 +179,36 @@
{{outlet}}
{{#if this.showAddMembersLabelModal}}
<GhFullscreenModal
@modal="add-label-members"
@model={{hash memberCount=this.members.length}}
@confirm={{this.addLabelToMembers}}
@close={{this.toggleAddMembersLabelModal}}
@modifier="action wide"
/>
{{/if}}
{{#if this.showRemoveMembersLabelModal}}
<GhFullscreenModal
@modal="remove-label-members"
@model={{hash memberCount=this.members.length}}
@confirm={{this.removeLabelFromMembers}}
@close={{this.toggleRemoveMembersLabelModal}}
@modifier="action wide"
/>
{{/if}}
{{#if this.showUnsubscribeMembersModal}}
<GhFullscreenModal
@modal="unsubscribe-members"
@model={{hash memberCount=this.members.length}}
@confirm={{this.unsubscribeMembers}}
@close={{this.toggleUnsubscribeMembersModal}}
@modifier="action wide"
/>
{{/if}}
{{#if this.showDeleteMembersModal}}
<GhFullscreenModal
@modal="delete-members"