Ghost/ghost/admin/app/controllers/members.js
Kevin Ansfield fe48f7ed80 Added utils service with downloadFile() method
no issue

- pattern of downloading a file by creating an iframe and setting the `src` attribute was repeated throughout the codebase and was using a mix of native and jQuery patterns
- added a `utils` service for housing one-off utility methods like this to avoid repetition and mixed patterns becoming more widespread (we want to get rid of jQuery usage)
2021-10-05 14:21:19 +01:00

557 lines
16 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 {capitalize} from '@ember/string';
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;
@service utils;
queryParams = [
'label',
{paidParam: 'paid'},
{searchParam: 'search'},
{orderParam: 'order'},
{filterParam: 'filter'}
];
@tracked members = A([]);
@tracked searchText = '';
@tracked searchParam = '';
@tracked filterParam = null;
@tracked softFilterParam = null;
@tracked paidParam = null;
@tracked label = null;
@tracked orderParam = null;
@tracked modalLabel = null;
@tracked showLabelModal = false;
@tracked showDeleteMembersModal = false;
@tracked showUnsubscribeMembersModal = false;
@tracked showAddMembersLabelModal = false;
@tracked showRemoveMembersLabelModal = false;
@tracked filters = A([]);
@tracked softFilters = A([]);
@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 && !this.filterParam && !this.softFilterParam;
}
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 || this.filterParam);
}
get filterColumns() {
const defaultColumns = ['name', 'email'];
const availableFilters = this.filters.length ? this.filters : this.softFilters;
return availableFilters.map((filter) => {
return filter.type;
}).filter((f, idx, arr) => {
return arr.indexOf(f) === idx;
}).filter(d => !defaultColumns.includes(d));
}
get filterColumnLabels() {
const filterColumnLabelMap = {
'subscriptions.plan_interval': 'Billing period',
subscribed: 'Subscribed to email',
'subscriptions.status': 'Subscription Status'
};
return this.filterColumns.map((d) => {
return filterColumnLabelMap[d] ? filterColumnLabelMap[d] : capitalize(d.replace(/_/g, ' '));
});
}
getApiQueryObject({params, extraFilters = []} = {}) {
let {label, paidParam, searchParam, filterParam} = params ? params : this;
let filters = [];
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');
}
}
if (filterParam) {
filters.push(filterParam);
}
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
applyFilter(filterStr, filters) {
this.softFilters = A([]);
this.filterParam = filterStr || null;
this.filters = filters;
}
@action
applySoftFilter(filterStr, filters) {
this.softFilters = filters;
this.softFilterParam = filterStr || null;
let {label, paidParam, searchParam, orderParam} = this;
this.fetchMembersTask.perform({label, paidParam, searchParam, orderParam, filterParam: filterStr});
}
@action
resetSoftFilter() {
if (this.softFilters.length > 0 || !!this.softFilterParam) {
this.softFilters = A([]);
this.softFilterParam = null;
this.fetchMembersTask.perform();
}
}
@action
resetFilter() {
this.softFilters = A([]);
this.softFilterParam = null;
this.filters = A([]);
this.filterParam = null;
this.fetchMembersTask.perform();
}
@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');
this.utils.downloadFile(`${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
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
addLabelToMembers(selectedLabel) {
return this.addLabelToMembersTask.perform(selectedLabel);
}
@action
removeLabelFromMembers(selectedLabel) {
return this.removeLabelFromMembersTask.perform(selectedLabel);
}
// 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, filterParam} = 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._lastOrderParam
|| filterParam !== this._lastFilterParam;
this._lastLabel = label;
this._lastPaidParam = paidParam;
this._lastSearchParam = searchParam;
this._lastOrderParam = orderParam;
this._lastFilterParam = filterParam;
// 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;
}
@task({drop: true})
*unsubscribeMembersTask() {
const query = new URLSearchParams(this.getApiQueryObject());
const unsubscribeUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
// response contains details of which members failed to be unsubscribe
const response = yield this.ajax.put(unsubscribeUrl, {
data: {
bulk: {
action: 'unsubscribe',
meta: {}
}
}
});
// reset and reload
this.store.unloadAll('member');
this.reload();
this.membersStats.invalidate();
this.membersStats.fetchCounts();
return response?.bulk?.meta;
}
@task({drop: true})
*addLabelToMembersTask(selectedLabel) {
const query = new URLSearchParams(this.getApiQueryObject());
const addLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
const response = yield this.ajax.put(addLabelUrl, {
data: {
bulk: {
action: 'addLabel',
meta: {
label: {
id: selectedLabel
}
}
}
}
});
// reset and reload
this.store.unloadAll('member');
this.reload();
return response?.bulk?.meta;
}
@task({drop: true})
*removeLabelFromMembersTask(selectedLabel) {
const query = new URLSearchParams(this.getApiQueryObject());
const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`;
const response = yield this.ajax.put(removeLabelUrl, {
data: {
bulk: {
action: 'removeLabel',
meta: {
label: {
id: selectedLabel
}
}
}
}
});
// reset and reload
this.store.unloadAll('member');
this.reload();
return response?.bulk?.meta;
}
// Internal ----------------------------------------------------------------
resetSearch() {
this.searchText = '';
}
resetFilters(params) {
if (!params?.filterParam) {
this.filters = A([]);
this.softFilterParam = null;
this.softFilters = A([]);
}
}
reload(params) {
this.membersStats.invalidate();
this.membersStats.fetchCounts();
this.fetchMembersTask.perform(params);
}
}