mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-27 04:43:12 +03:00
fe48f7ed80
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)
557 lines
16 KiB
JavaScript
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);
|
|
}
|
|
}
|