import BulkAddMembersLabelModal from '../components/members/modals/bulk-add-label'; import BulkDeleteMembersModal from '../components/members/modals/bulk-delete'; import BulkRemoveMembersLabelModal from '../components/members/modals/bulk-remove-label'; import BulkUnsubscribeMembersModal from '../components/members/modals/bulk-unsubscribe'; import Controller from '@ember/controller'; 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, 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 modals; @service router; @service store; @service utils; @service settings; queryParams = [ 'label', {paidParam: 'paid'}, {searchParam: 'search'}, {orderParam: 'order'}, {filterParam: 'filter'} ]; @tracked members = A([]); @tracked searchParam = ''; @tracked searchIsFocused = false; @tracked filterParam = null; @tracked softFilterParam = null; @tracked paidParam = null; @tracked label = null; @tracked orderParam = null; @tracked modalLabel = null; @tracked showLabelModal = false; @tracked filters = A([]); @tracked softFilters = A([]); @tracked _availableLabels = A([]); @tracked parseFilterParamCounter = 0; paidParams = PAID_PARAMS; constructor() { super(...arguments); this._availableLabels = this.store.peekAll('label'); } // Computed properties ----------------------------------------------------- get listHeader() { let {searchParam, selectedLabel, members} = this; if (members.loading) { return 'Loading...'; } if (searchParam) { 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 hideSearchBar() { return !this.members.length && !this.searchParam && !this.searchIsFocused; } 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', 'email_open_rate', 'created_at', 'status', 'tier']; 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 = { subscribed: 'Subscribed to email', 'subscriptions.plan_interval': 'Billing period', 'subscriptions.status': 'Subscription Status', 'subscriptions.start_date': 'Paid start date', 'subscriptions.current_period_end': 'Next billing date', tier: 'Membership tier' }; return this.filterColumns.filter((d) => { // Exclude Signup and conversions (data not yet available in backend when browsing members) return !['signup', 'conversion'].includes(d); }).map((d) => { return { name: d, label: filterColumnLabelMap[d] ? filterColumnLabelMap[d] : capitalize(d.replace(/_/g, ' ')) }; }); } includeTierQuery() { const availableFilters = this.filters.length ? this.filters : this.softFilters; return availableFilters.some((f) => { return f.type === 'tier'; }); } 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(); this.membersStats.fetchMemberCount(); } @action changeOrder(order) { this.orderParam = order.value; } @action applyFilter(filterStr, filters) { this.softFilters = A([]); this.filterParam = filterStr || null; this.filters = filters; } /** * Called to set the filters after the url filterParam has been parsed again */ @action applyParsedFilter(filters) { this.softFilters = A([]); 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 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 bulkAddLabel() { this.modals.open(BulkAddMembersLabelModal, { query: this.getApiQueryObject(), onComplete: this.resetAndReloadMembers }); } @action bulkRemoveLabel() { this.modals.open(BulkRemoveMembersLabelModal, { query: this.getApiQueryObject(), onComplete: this.resetAndReloadMembers }); } @action bulkUnsubscribe() { this.modals.open(BulkUnsubscribeMembersModal, { query: this.getApiQueryObject(), onComplete: this.resetAndReloadMembers }); } @action resetAndReloadMembers() { this.store.unloadAll('member'); this.reload(); } @action bulkDelete() { this.modals.open(BulkDeleteMembersModal, { query: this.getApiQueryObject(), onComplete: () => { // reset, clear filters, and reload list and counts this.store.unloadAll('member'); this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))}); this.membersStats.invalidate(); this.membersStats.fetchCounts(); } }); } @action changePaidParam(paid) { this.paidParam = paid.value; } // 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; // 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`; const includes = ['labels', 'tiers']; query = Object.assign({ include: includes.join(','), 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 }); } // Internal ---------------------------------------------------------------- resetFilters(params) { if (!params?.filterParam) { this.filters = A([]); this.softFilterParam = null; this.softFilters = A([]); } else { this.filterParam = params.filterParam; // Trigger a did-update call in the filter component, so we get freshly parsed filters // This is temporary, and a ugly pattern, but essential to make it work for now, until we moved the filter parsing logic // out of the component this.parseFilterParamCounter += 1; } } reload(params) { this.membersStats.invalidate(); this.membersStats.fetchCounts(); this.fetchMembersTask.perform(params); } }