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-timezone'; import {A} from '@ember/array'; import {action} from '@ember/object'; import {didCancel, task, timeout} from 'ember-concurrency'; import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; import {inject} from 'ghost-admin/decorators/inject'; import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; import {inject as service} from '@ember/service'; 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 ellaSparse; @service feature; @service ghostPaths; @service membersStats; @service modals; @service router; @service store; @service utils; @service settings; @inject config; queryParams = [ 'label', {paidParam: 'paid'}, {searchParam: 'search'}, {orderParam: 'order'}, {filterParam: 'filter'}, {postAnalytics: 'post'} ]; @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; /** * Flag used to determine if we should return to the analytics page */ @tracked postAnalytics = null; get fromAnalytics() { if (!this.postAnalytics) { return null; } return [this.postAnalytics]; } 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 availableFilters() { return this.softFilters.length ? this.softFilters : this.filters; } get filterColumns() { const columns = this.availableFilters.flatMap((filter) => { if (filter.properties?.getColumns) { return filter.properties?.getColumns(filter).map((c) => { return { label: filter.properties.columnLabel, // default value if not provided ...c, name: filter.type }; }); } if (filter.properties?.columnLabel) { return [ { name: filter.type, label: filter.properties.columnLabel, getValue: filter.properties.getColumnValue ? (member => filter.properties.getColumnValue(member, filter)) : null } ]; } return []; }); // Remove duplicates by label const uniqueColumns = columns.filter((c, i) => { return columns.findIndex(c2 => c2.label === c.label) === i; }); return uniqueColumns.splice(0, 2); // Maximum 2 columns } 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; if (filterParam) { // If the provided filter param is a single filter related to newsletter subscription status // remove the surrounding brackets to prevent https://github.com/TryGhost/NQL/issues/16 const BRACKETS_SURROUNDED_RE = /^\(.*\)$/; const MULTIPLE_GROUPS_RE = /\).*\(/; if (BRACKETS_SURROUNDED_RE.test(filterParam) && !MULTIPLE_GROUPS_RE.test(filterParam)) { filterParam = filterParam.slice(1, -1); } } 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() { try { this.fetchMembersTask.perform(); this.fetchLabelsTask.perform(); } catch (e) { // Do not throw cancellation errors if (didCancel(e)) { return; } throw e; } this.membersStats.invalidate(); this.membersStats.fetchCounts(); this.membersStats.fetchMemberCount(); } @action changeOrder(order) { this.orderParam = order.value; } /** * A user clicked 'Apply filters' when editing the filter */ @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; } /** * Already start filtering when the user is editing a filter, without applying it to the URL yet, * and to still allow a cancel action to revert to the previous 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({restartable: true}) *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); } }