Ghost/ghost/admin/app/components/gh-members-recipient-select.js
Kevin Ansfield c24bd50f10 Refactored <GhMembersRecipientSelect> to auto-update on @filter change
no issue

- we want to re-use this component as display-only on the email newsletter settings screen but the previous component design meant that changes to the `@filter` argument did not update the display
- moved to using getters for the internal base/specific filter Set instances so they are auto-updated when the `args.filter` param changes
- updated the `toggleSpecificFilter` action to store the current specific filter as temporary internal state when toggled off so it can be re-filled when toggling back on
  - this interaction was why the component state had previously been disconnected from the `@filter` param
- moved filter string generation into an explicit `updateFilter` method that is called when any action occurs that should update the filter string. Changes to the filters are passed in as arguments so that we call the passed in action which will then update the `@filter` argument and the component state can react accordingly
2022-01-19 14:29:49 +00:00

211 lines
6.2 KiB
JavaScript

import Component from '@glimmer/component';
import flattenGroupedOptions from 'ghost-admin/utils/flatten-grouped-options';
import {Promise} from 'rsvp';
import {action} from '@ember/object';
import {isBlank} from '@ember/utils';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
const BASE_FILTERS = ['status:free', 'status:-free'];
export default class GhMembersRecipientSelect extends Component {
@service membersUtils;
@service session;
@service store;
@service feature;
@tracked forceSpecificChecked = false;
@tracked specificOptions = [];
@tracked freeMemberCount;
@tracked paidMemberCount;
constructor() {
super(...arguments);
this.fetchSpecificOptionsTask.perform();
this.fetchMemberCountsTask.perform();
}
get baseFilters() {
const filterItems = (this.args.filter || '').split(',');
const filterItemsArray = filterItems.filter(item => BASE_FILTERS.includes(item?.trim()));
return new Set(filterItemsArray);
}
get isFreeChecked() {
return this.baseFilters.has('status:free');
}
get isPaidChecked() {
return this.baseFilters.has('status:-free');
}
get isPaidAvailable() {
return this.membersUtils.isStripeEnabled;
}
get specificFilters() {
const filterItems = (this.args.filter || '').split(',');
const filterItemsArray = filterItems.reject(item => isBlank(item) || BASE_FILTERS.includes(item?.trim()));
return new Set(filterItemsArray);
}
get isSpecificChecked() {
return this.forceSpecificChecked || this.specificFilters.size > 0;
}
get selectedSpecificOptions() {
return flattenGroupedOptions(this.specificOptions)
.filter(o => this.specificFilters.has(o.segment));
}
get freeMemberCountLabel() {
if (this.freeMemberCount !== undefined) {
return `(${this.freeMemberCount})`;
}
return '';
}
get paidMemberCountLabel() {
if (this.paidMemberCount !== undefined) {
return `(${this.paidMemberCount})`;
}
return '';
}
@action
toggleFilter(filter, event) {
event?.preventDefault();
if (this.args.disabled) {
return;
}
const newBaseFilters = this.baseFilters;
newBaseFilters.has(filter) ? newBaseFilters.delete(filter) : newBaseFilters.add(filter);
this.updateFilter({newBaseFilters});
}
@action
toggleSpecificFilter(event) {
event?.preventDefault();
if (this.args.disabled) {
return;
}
this.forceSpecificChecked = false;
// on->off, store current filter for re-use when toggled back on
if (this.isSpecificChecked) {
this._previousSpecificFilters = this.specificFilters;
this.updateFilter({newSpecificFilters: new Set()});
return;
}
// off->on, re-use stored filter
if (this._previousSpecificFilters) {
this.updateFilter({newSpecificFilters: this._previousSpecificFilters});
return;
}
// off->on, display the filter selection even though the actual filter is empty
this.forceSpecificChecked = true;
}
@action
selectSpecificOptions(selectedOptions) {
if (this.args.disabled) {
return;
}
const newSpecificFilters = new Set(selectedOptions.map(o => o.segment));
this.updateFilter({newSpecificFilters});
}
updateFilter({newBaseFilters, newSpecificFilters}) {
const selectedFilters = new Set([
...(newBaseFilters || this.baseFilters),
...(newSpecificFilters || this.specificFilters)
]);
if (!this.isPaidAvailable) {
selectedFilters.delete('status:-free');
}
const newFilter = Array.from(selectedFilters).join(',') || null;
this.args.onChange?.(newFilter);
}
@task
*fetchSpecificOptionsTask() {
const options = [];
// fetch all labels w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// TODO: add `include: 'count.members` to query once API is fixed
const labels = yield this.store.query('label', {limit: 'all'});
if (labels.length > 0) {
const labelsGroup = {
groupName: 'Labels',
options: []
};
labels.forEach((label) => {
labelsGroup.options.push({
name: label.name,
segment: `label:${label.slug}`,
count: label.count?.members,
class: 'segment-label'
});
});
options.push(labelsGroup);
}
if (this.feature.get('multipleProducts')) {
// fetch all products w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// TODO: add `include: 'count.members` to query once API supports
const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all'});
if (products.length > 1) {
const productsGroup = {
groupName: 'Tiers',
options: []
};
products.forEach((product) => {
productsGroup.options.push({
name: product.name,
segment: `product:${product.slug}`,
count: product.count?.members,
class: 'segment-product'
});
});
options.push(productsGroup);
}
}
this.specificOptions = options;
}
@task
*fetchMemberCountsTask() {
const user = yield this.session.user;
if (!user.isAdmin) {
return;
}
yield Promise.all([
this.store.query('member', {filter: 'subscribed:true+status:free', limit: 1}).then((res) => {
this.freeMemberCount = res.meta.pagination.total;
}),
this.store.query('member', {filter: 'subscribed:true+status:-free', limit: 1}).then((res) => {
this.paidMemberCount = res.meta.pagination.total;
})
]);
}
}