mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-02 08:13:34 +03:00
733f76d571
refs https://github.com/TryGhost/Team/issues/585 - updated bulk destroy task to first use the current query params to fetch from the appropriate CSV export endpoint and trigger a download - fetches via JS and triggers download from a blob URL link instead of an iframe so that we can be sure the download is successful before we hit the bulk delete endpoint - works differently to user deletion download because the server is not generating an export file and saving it meaning the client has to be sure we don't delete data before it's exported - updated copy in the confirmation modal to reflect the download behaviour
387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
import Controller from '@ember/controller';
|
|
import config from 'ghost-admin/config/environment';
|
|
import fetch from 'fetch';
|
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
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';
|
|
import {tracked} from '@glimmer/tracking';
|
|
|
|
const PAID_PARAMS = [{
|
|
name: 'All members',
|
|
value: null
|
|
}, {
|
|
name: 'Free members',
|
|
value: 'false'
|
|
}, {
|
|
name: 'Paid members',
|
|
value: 'true'
|
|
}];
|
|
|
|
export default class MembersController extends Controller {
|
|
@service ajax;
|
|
@service config;
|
|
@service ellaSparse;
|
|
@service feature;
|
|
@service ghostPaths;
|
|
@service membersStats;
|
|
@service router;
|
|
@service store;
|
|
|
|
queryParams = [
|
|
'label',
|
|
{paidParam: 'paid'},
|
|
{searchParam: 'search'},
|
|
{orderParam: 'order'}
|
|
];
|
|
|
|
@tracked members = A([]);
|
|
@tracked searchText = '';
|
|
@tracked searchParam = '';
|
|
@tracked paidParam = null;
|
|
@tracked label = null;
|
|
@tracked orderParam = null;
|
|
@tracked modalLabel = null;
|
|
@tracked showLabelModal = false;
|
|
@tracked showDeleteMembersModal = false;
|
|
|
|
@tracked _availableLabels = A([]);
|
|
|
|
paidParams = PAID_PARAMS;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
this._availableLabels = this.store.peekAll('label');
|
|
|
|
if (this.isTesting === undefined) {
|
|
this.isTesting = config.environment === 'test';
|
|
}
|
|
}
|
|
|
|
// Computed properties -----------------------------------------------------
|
|
|
|
get listHeader() {
|
|
let {searchText, selectedLabel, members} = this;
|
|
|
|
if (members.loading) {
|
|
return 'Loading...';
|
|
}
|
|
|
|
if (searchText) {
|
|
return 'Search result';
|
|
}
|
|
|
|
let count = ghPluralize(members.length, 'member');
|
|
|
|
if (selectedLabel && selectedLabel.slug) {
|
|
if (members.length > 1) {
|
|
return `${count} match current filter`;
|
|
} else {
|
|
return `${count} matches current filter`;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
get showingAll() {
|
|
return !this.searchParam && !this.paidParam && !this.label;
|
|
}
|
|
|
|
get availableOrders() {
|
|
// don't return anything if email analytics is disabled because
|
|
// we don't want to show an order dropdown with only a single option
|
|
|
|
if (this.feature.get('emailAnalytics')) {
|
|
return [{
|
|
name: 'Newest',
|
|
value: null
|
|
}, {
|
|
name: 'Open rate',
|
|
value: 'email_open_rate'
|
|
}];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
get selectedOrder() {
|
|
return this.availableOrders.find(order => order.value === this.orderParam);
|
|
}
|
|
|
|
get availableLabels() {
|
|
let labels = this._availableLabels
|
|
.filter(label => !label.isNew)
|
|
.filter(label => label.id !== null)
|
|
.sort((labelA, labelB) => labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}));
|
|
let options = labels.toArray();
|
|
|
|
options.unshiftObject({name: 'All labels', slug: null});
|
|
|
|
return options;
|
|
}
|
|
|
|
get selectedLabel() {
|
|
let {label, availableLabels} = this;
|
|
return availableLabels.findBy('slug', label);
|
|
}
|
|
|
|
get labelModalData() {
|
|
let label = this.modalLabel;
|
|
let labels = this.availableLabels;
|
|
|
|
return {
|
|
label,
|
|
labels
|
|
};
|
|
}
|
|
|
|
get selectedPaidParam() {
|
|
return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'};
|
|
}
|
|
|
|
get isFiltered() {
|
|
return !!(this.label || this.paidParam || this.searchParam);
|
|
}
|
|
|
|
getApiQueryObject({params, extraFilters = []} = {}) {
|
|
let {label, paidParam, searchParam} = params ? params : this;
|
|
|
|
let filters = [];
|
|
|
|
filters.concat(extraFilters);
|
|
|
|
if (label) {
|
|
filters.push(`label:'${label}'`);
|
|
}
|
|
|
|
if (paidParam !== null) {
|
|
if (paidParam === 'true') {
|
|
filters.push('status:-free');
|
|
} else {
|
|
filters.push('status:free');
|
|
}
|
|
}
|
|
|
|
let searchQuery = searchParam ? {search: searchParam} : {};
|
|
|
|
return Object.assign({}, {filter: filters.join('+')}, searchQuery);
|
|
}
|
|
|
|
// Actions -----------------------------------------------------------------
|
|
|
|
@action
|
|
refreshData() {
|
|
this.fetchMembersTask.perform();
|
|
this.fetchLabelsTask.perform();
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
}
|
|
|
|
@action
|
|
changeOrder(order) {
|
|
this.orderParam = order.value;
|
|
}
|
|
|
|
@action
|
|
search(e) {
|
|
this.searchTask.perform(e.target.value);
|
|
}
|
|
|
|
@action
|
|
exportData() {
|
|
let exportUrl = ghostPaths().url.api('members/upload');
|
|
let downloadParams = new URLSearchParams(this.getApiQueryObject());
|
|
downloadParams.set('limit', 'all');
|
|
|
|
let iframe = document.getElementById('iframeDownload');
|
|
|
|
if (!iframe) {
|
|
iframe = document.createElement('iframe');
|
|
iframe.id = 'iframeDownload';
|
|
iframe.style.display = 'none';
|
|
document.body.append(iframe);
|
|
}
|
|
iframe.setAttribute('src', `${exportUrl}?${downloadParams.toString()}`);
|
|
}
|
|
|
|
@action
|
|
changeLabel(label, e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
this.label = label.slug;
|
|
}
|
|
|
|
@action
|
|
addLabel(e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
const newLabel = this.store.createRecord('label');
|
|
this.modalLabel = newLabel;
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
editLabel(label, e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
let modalLabel = this.availableLabels.findBy('slug', label);
|
|
this.modalLabel = modalLabel;
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
toggleLabelModal() {
|
|
this.showLabelModal = !this.showLabelModal;
|
|
}
|
|
|
|
@action
|
|
changePaidParam(paid) {
|
|
this.paidParam = paid.value;
|
|
}
|
|
|
|
@action
|
|
toggleDeleteMembersModal() {
|
|
this.showDeleteMembersModal = !this.showDeleteMembersModal;
|
|
}
|
|
|
|
@action
|
|
deleteMembers() {
|
|
return this.deleteMembersTask.perform();
|
|
}
|
|
|
|
// Tasks -------------------------------------------------------------------
|
|
|
|
@task({restartable: true})
|
|
*searchTask(query) {
|
|
yield timeout(250); // debounce
|
|
this.searchParam = query;
|
|
}
|
|
|
|
@task
|
|
*fetchLabelsTask() {
|
|
yield this.store.query('label', {limit: 'all'});
|
|
}
|
|
|
|
@task({restartable: true})
|
|
*fetchMembersTask(params) {
|
|
// params is undefined when called as a "refresh" of the model
|
|
let {label, paidParam, searchParam, orderParam} = typeof params === 'undefined' ? this : params;
|
|
|
|
if (!searchParam) {
|
|
this.resetSearch();
|
|
}
|
|
|
|
// use a fixed created_at date so that subsequent pages have a consistent index
|
|
let startDate = new Date();
|
|
|
|
// bypass the stale data shortcut if params change
|
|
let forceReload = !params
|
|
|| label !== this._lastLabel
|
|
|| paidParam !== this._lastPaidParam
|
|
|| searchParam !== this._lastSearchParam
|
|
|| orderParam !== this._orderParam;
|
|
this._lastLabel = label;
|
|
this._lastPaidParam = paidParam;
|
|
this._lastSearchParam = searchParam;
|
|
this._lastOrderParam = orderParam;
|
|
|
|
// unless we have a forced reload, do not re-fetch the members list unless it's more than a minute old
|
|
// keeps navigation between list->details->list snappy
|
|
if (!forceReload && this._startDate && !(this._startDate - startDate > 1 * 60 * 1000)) {
|
|
return this.members;
|
|
}
|
|
|
|
this._startDate = startDate;
|
|
|
|
this.members = yield this.ellaSparse.array((range = {}, query = {}) => {
|
|
const searchQuery = this.getApiQueryObject({
|
|
params,
|
|
extraFilters: [`created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`]
|
|
});
|
|
const order = orderParam ? `${orderParam} desc` : `created_at desc`;
|
|
|
|
query = Object.assign({
|
|
order,
|
|
limit: range.length,
|
|
page: range.page
|
|
}, searchQuery, query);
|
|
|
|
return this.store.query('member', query).then((result) => {
|
|
return {
|
|
data: result,
|
|
total: result.meta.pagination.total
|
|
};
|
|
});
|
|
}, {
|
|
limit: 50
|
|
});
|
|
}
|
|
|
|
@task({drop: true})
|
|
*deleteMembersTask() {
|
|
const query = new URLSearchParams(this.getApiQueryObject());
|
|
|
|
// Trigger download before deleting. Uses the CSV export endpoint but
|
|
// needs to fetch the file and trigger a download directly rather than
|
|
// via an iframe. The iframe approach can't tell us when a download has
|
|
// started/finished meaning we could end up deleting the data before exporting it
|
|
const exportUrl = ghostPaths().url.api('members/upload');
|
|
const exportParams = new URLSearchParams(this.getApiQueryObject());
|
|
exportParams.set('limit', 'all');
|
|
|
|
yield fetch(exportUrl, {method: 'GET'})
|
|
.then(res => res.blob())
|
|
.then((blob) => {
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = `members.${moment().format('YYYY-MM-DD')}.csv`;
|
|
document.body.appendChild(a);
|
|
if (!this.isTesting) {
|
|
a.click();
|
|
}
|
|
a.remove();
|
|
URL.revokeObjectURL(blobUrl);
|
|
});
|
|
|
|
// backup downloaded, continue with deletion
|
|
|
|
const deleteUrl = `${this.ghostPaths.url.api('members')}?${query}`;
|
|
|
|
// response contains details of which members failed to be deleted
|
|
const response = yield this.ajax.del(deleteUrl);
|
|
|
|
// 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 response.meta;
|
|
}
|
|
|
|
// Internal ----------------------------------------------------------------
|
|
|
|
resetSearch() {
|
|
this.searchText = '';
|
|
}
|
|
|
|
reload(params) {
|
|
this.membersStats.invalidate();
|
|
this.membersStats.fetchCounts();
|
|
this.fetchMembersTask.perform(params);
|
|
}
|
|
}
|