From c1ad9475d72d3f4ea6655b2881dbd7eb8797d4f6 Mon Sep 17 00:00:00 2001 From: Rishabh Garg Date: Mon, 28 Feb 2022 16:08:44 +0530 Subject: [PATCH] Added filter by tiers to members filter UI (#2274) closes https://github.com/TryGhost/Team/issues/1029 - allows site owner to filter members on specific tier - needs tiers beta flag enabled and site should have more than 1 paid tiers. --- .../gh-members-list-item-column.hbs | 5 + .../components/gh-members-list-item-column.js | 5 + .../app/components/members/filter-value.hbs | 10 ++ .../app/components/members/filter-value.js | 17 +++ ghost/admin/app/components/members/filter.hbs | 2 +- ghost/admin/app/components/members/filter.js | 47 +++++++- .../app/components/tiers/segment-select.hbs | 22 ++++ .../app/components/tiers/segment-select.js | 102 ++++++++++++++++++ ghost/admin/app/controllers/members.js | 16 ++- .../app/styles/components/filter-builder.css | 8 ++ ghost/admin/mirage/config/members.js | 14 +++ ghost/admin/mirage/models/product.js | 3 +- .../tests/acceptance/members/filter-test.js | 50 ++++++++- 13 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 ghost/admin/app/components/tiers/segment-select.hbs create mode 100644 ghost/admin/app/components/tiers/segment-select.js diff --git a/ghost/admin/app/components/gh-members-list-item-column.hbs b/ghost/admin/app/components/gh-members-list-item-column.hbs index 9fd748541a..cf1e5f91e8 100644 --- a/ghost/admin/app/components/gh-members-list-item-column.hbs +++ b/ghost/admin/app/components/gh-members-list-item-column.hbs @@ -3,6 +3,11 @@ {{this.labels}} +{{else if (eq @filterColumn 'product')}} + + {{this.products}} + + {{else if (eq @filterColumn 'status')}} {{#if (not (is-empty @member.status))}} diff --git a/ghost/admin/app/components/gh-members-list-item-column.js b/ghost/admin/app/components/gh-members-list-item-column.js index c7d3db43c9..7a33b3070f 100644 --- a/ghost/admin/app/components/gh-members-list-item-column.js +++ b/ghost/admin/app/components/gh-members-list-item-column.js @@ -10,6 +10,11 @@ export default class GhMembersListItemColumn extends Component { return labelData.map(label => label.name).join(', '); } + get products() { + const productData = this.args.member.get('products') || []; + return productData.map(product => product.name).join(', '); + } + get subscriptionStatus() { const subscriptions = this.args.member.get('subscriptions') || []; return subscriptions[0]?.status; diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs index c271729ad8..28b0fc6fe0 100644 --- a/ghost/admin/app/components/members/filter-value.hbs +++ b/ghost/admin/app/components/members/filter-value.hbs @@ -8,6 +8,16 @@ @allowEdit={{true}} /> +{{else if (eq @filter.type 'product')}} +
+ +
+ {{else if (eq @filter.type 'subscribed')}} { + return { + slug: tier + }; + }); + } + return []; + } + @action setInputFilterValue(filterType, filterId, event) { this.filterValue = event.target.value; @@ -62,6 +74,11 @@ export default class MembersFilterValue extends Component { this.args.setFilterValue(filterType, filterId, labels.map(label => label.slug)); } + @action + setProductsFilterValue(filterType, filterId, tiers) { + this.args.setFilterValue(filterType, filterId, tiers.map(tier => tier.slug)); + } + @action setFilterValue(filterType, filterId, value) { this.args.setFilterValue(filterType, filterId, value); diff --git a/ghost/admin/app/components/members/filter.hbs b/ghost/admin/app/components/members/filter.hbs index 213b278a59..05bce7b0df 100644 --- a/ghost/admin/app/components/members/filter.hbs +++ b/ghost/admin/app/components/members/filter.hbs @@ -13,7 +13,7 @@ - +

Filter list

{{#each this.filters as |filter index|}} diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index e9cbb64983..4c68e4fa21 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -4,6 +4,7 @@ import nql from '@nexes/nql-lang'; import {A} from '@ember/array'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; const FILTER_PROPERTIES = [ @@ -12,6 +13,7 @@ const FILTER_PROPERTIES = [ // {label: 'Email', name: 'email', group: 'Basic'}, // {label: 'Location', name: 'location', group: 'Basic'}, {label: 'Label', name: 'label', group: 'Basic'}, + {label: 'Tiers', name: 'product', group: 'Basic', feature: 'multipleProducts'}, {label: 'Newsletter subscription', name: 'subscribed', group: 'Basic'}, {label: 'Last seen', name: 'last_seen_at', group: 'Basic', feature: 'membersLastSeenFilter'}, @@ -46,6 +48,10 @@ const FILTER_RELATIONS_OPTIONS = { {label: 'is', name: 'is'}, {label: 'is not', name: 'is-not'} ], + product: [ + {label: 'is', name: 'is'}, + {label: 'is not', name: 'is-not'} + ], subscribed: [ {label: 'is', name: 'is'}, {label: 'is not', name: 'is-not'} @@ -127,7 +133,9 @@ export default class MembersFilter extends Component { @service feature; @service session; @service settings; + @service store; + @tracked productsList; @tracked filters = A([ new Filter({ id: `filter-0`, @@ -144,10 +152,17 @@ export default class MembersFilter extends Component { get availableFilterProperties() { let availableFilters = FILTER_PROPERTIES; + const hasMultipleProducts = this.productsList?.length > 1; // exclude any filters that are behind disabled feature flags availableFilters = availableFilters.filter(prop => !prop.feature || this.feature[prop.feature]); + // exclude tiers filter if site has only single tier + availableFilters = availableFilters + .filter((filter) => { + return filter.name === 'product' ? hasMultipleProducts : true; + }); + // exclude subscription filters if Stripe isn't connected if (!this.settings.get('stripeConnectAccountId')) { availableFilters = availableFilters.reject(prop => prop.group === 'Subscription'); @@ -173,6 +188,11 @@ export default class MembersFilter extends Component { } } + @action + setup() { + this.fetchProducts.perform(); + } + @action addFilter() { this.filters.pushObject(new Filter({ @@ -198,6 +218,10 @@ export default class MembersFilter extends Component { const relationStr = filter.relation === 'is-not' ? '-' : ''; const filterValue = '[' + filter.value.join(',') + ']'; query += `${filter.type}:${relationStr}${filterValue}+`; + } else if (filter.type === 'product' && filter.value?.length) { + const relationStr = filter.relation === 'is-not' ? '-' : ''; + const filterValue = '[' + filter.value.join(',') + ']'; + query += `${filter.type}:${relationStr}${filterValue}+`; } else if (filter.type === 'last_seen_at') { // is-greater = more than x days ago = date @@ -221,7 +245,7 @@ export default class MembersFilter extends Component { const filterId = this.nextFilterId; if (typeof value === 'object') { - if (value.$in !== undefined && key === 'label') { + if (value.$in !== undefined && ['label', 'product'].includes(key)) { this.nextFilterId = this.nextFilterId + 1; return new Filter({ id: `filter-${filterId}`, @@ -231,7 +255,7 @@ export default class MembersFilter extends Component { relationOptions: FILTER_RELATIONS_OPTIONS[key] }); } - if (value.$nin !== undefined && key === 'label') { + if (value.$nin !== undefined && ['label', 'product'].includes(key)) { this.nextFilterId = this.nextFilterId + 1; return new Filter({ id: `filter-${filterId}`, @@ -350,6 +374,10 @@ export default class MembersFilter extends Component { defaultValue = []; } + if (newType === 'product' && !defaultValue) { + defaultValue = []; + } + const filterToEdit = this.filters.findBy('id', filterId); if (filterToEdit) { filterToEdit.type = newType; @@ -361,6 +389,10 @@ export default class MembersFilter extends Component { if (newType !== 'label' && defaultValue) { this.applySoftFilter(); } + + if (newType !== 'product' && defaultValue) { + this.applySoftFilter(); + } } @action @@ -383,6 +415,9 @@ export default class MembersFilter extends Component { if (fil.type === 'label') { return fil.value?.length; } + if (fil.type === 'product') { + return fil.value?.length; + } return fil.value; }); const query = this.generateNqlFilter(validFilters); @@ -392,7 +427,7 @@ export default class MembersFilter extends Component { @action applyFilter() { const validFilters = this.filters.filter((fil) => { - if (fil.type === 'label') { + if (['label', 'product'].includes(fil.type)) { return fil.value?.length; } return fil.value; @@ -416,4 +451,10 @@ export default class MembersFilter extends Component { ]); this.args.onResetFilter(); } + + @task({drop: true}) + *fetchProducts() { + const response = yield this.store.query('product', {filter: 'type:paid'}); + this.productsList = response; + } } diff --git a/ghost/admin/app/components/tiers/segment-select.hbs b/ghost/admin/app/components/tiers/segment-select.hbs new file mode 100644 index 0000000000..7a928d2415 --- /dev/null +++ b/ghost/admin/app/components/tiers/segment-select.hbs @@ -0,0 +1,22 @@ + + {{option.name}} + + +{{#if @showMemberCount}} + +{{/if}} diff --git a/ghost/admin/app/components/tiers/segment-select.js b/ghost/admin/app/components/tiers/segment-select.js new file mode 100644 index 0000000000..bc9530a72e --- /dev/null +++ b/ghost/admin/app/components/tiers/segment-select.js @@ -0,0 +1,102 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; + +export default class TiersSegmentSelect extends Component { + @service store; + @service feature; + + @tracked _options = []; + @tracked products = []; + + get renderInPlace() { + return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; + } + + constructor() { + super(...arguments); + this.fetchOptionsTask.perform(); + } + + get options() { + return this._options; + } + + get flatOptions() { + const options = []; + + function getOptions(option) { + if (option.options) { + return option.options.forEach(getOptions); + } + + options.push(option); + } + + this._options.forEach(getOptions); + + return options; + } + + get selectedOptions() { + const tierList = (this.args.tiers || []).map((product) => { + return this.products.find((p) => { + return p.id === product.id || p.slug === product.slug; + }); + }).filter(d => !!d); + const tierIdList = tierList.map(d => d.id); + return this.flatOptions.filter(option => tierIdList.includes(option.id)); + } + + @action + setSegment(options) { + let ids = options.mapBy('id').map((id) => { + let product = this.products.find((p) => { + return p.id === id; + }); + return { + id: product.id, + slug: product.slug, + name: product.name + }; + }) || []; + this.args.onChange?.(ids); + } + + @task + *fetchOptionsTask() { + const options = yield []; + + if (this.feature.get('multipleProducts')) { + // fetch all products with count + // TODO: add `include: 'count.members` to query once API supports + const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'}); + this.products = products; + + if (products.length > 0) { + const productsGroup = { + groupName: 'Tiers', + options: [] + }; + + products.forEach((product) => { + productsGroup.options.push({ + name: product.name, + id: product.id, + count: product.count?.members, + class: 'segment-product' + }); + }); + + options.push(productsGroup); + if (this.args.selectDefaultProduct && !this.args.tiers) { + this.setSegment([productsGroup.options[0]]); + } + } + } + + this._options = options; + } +} diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index df9dffd047..e13e2d50a0 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -170,7 +170,8 @@ export default class MembersController extends Controller { const filterColumnLabelMap = { 'subscriptions.plan_interval': 'Billing period', subscribed: 'Subscribed to email', - 'subscriptions.status': 'Subscription Status' + 'subscriptions.status': 'Subscription Status', + product: 'Tiers' }; return this.filterColumns.map((d) => { return { @@ -180,6 +181,13 @@ export default class MembersController extends Controller { }); } + includeProductQuery() { + const availableFilters = this.filters.length ? this.filters : this.softFilters; + return availableFilters.some((f) => { + return f.type === 'product'; + }); + } + getApiQueryObject({params, extraFilters = []} = {}) { let {label, paidParam, searchParam, filterParam} = params ? params : this; @@ -391,8 +399,12 @@ export default class MembersController extends Controller { 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']; + if (this.includeProductQuery()) { + includes.push('products'); + } query = Object.assign({ + include: includes.join(','), order, limit: range.length, page: range.page diff --git a/ghost/admin/app/styles/components/filter-builder.css b/ghost/admin/app/styles/components/filter-builder.css index 7c3ef566b2..590677de4b 100644 --- a/ghost/admin/app/styles/components/filter-builder.css +++ b/ghost/admin/app/styles/components/filter-builder.css @@ -115,6 +115,14 @@ margin: 2px !important; } +.gh-filter-block .token-segment-product { + margin: 2px !important; +} + +.gh-filter-block .token-segment-product .ember-power-select-multiple-remove-btn svg { + margin-right: 0!important; +} + .gh-filter-builder .ember-power-select-multiple-trigger { padding: 2px; } diff --git a/ghost/admin/mirage/config/members.js b/ghost/admin/mirage/config/members.js index ed64a7e2c8..1db39f8f3e 100644 --- a/ghost/admin/mirage/config/members.js +++ b/ghost/admin/mirage/config/members.js @@ -78,6 +78,10 @@ export default function mockMembers(server) { { key: 'label', replacement: 'labels.slug' + }, + { + key: 'product', + replacement: 'products.slug' } ] }); @@ -101,6 +105,16 @@ export default function mockMembers(server) { serializedMember.labels.push(serializedLabel); }); + // similar deal for associated product models + serializedMember.products = []; + member.products.models.forEach((product) => { + const serializedProduct = {}; + Object.keys(product.attrs).forEach((key) => { + serializedProduct[underscore(key)] = product.attrs[key]; + }); + serializedMember.products.push(serializedProduct); + }); + return nqlFilter.queryJSON(serializedMember); }); } diff --git a/ghost/admin/mirage/models/product.js b/ghost/admin/mirage/models/product.js index e773e83f9e..f3376214fa 100644 --- a/ghost/admin/mirage/models/product.js +++ b/ghost/admin/mirage/models/product.js @@ -3,5 +3,6 @@ import {Model, hasMany} from 'ember-cli-mirage'; export default Model.extend({ // ran into odd relationship bugs when called `benefits` // serializer will rename to `benefits` - productBenefits: hasMany() + productBenefits: hasMany(), + members: hasMany() }); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js index 5e260e40d7..bf6f91ae59 100644 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ b/ghost/admin/tests/acceptance/members/filter-test.js @@ -19,6 +19,7 @@ describe('Acceptance: Members filtering', function () { this.server.loadFixtures('configs'); this.server.loadFixtures('settings'); enableLabsFlag(this.server, 'membersLastSeenFilter'); + enableLabsFlag(this.server, 'multipleProducts'); // test with stripe connected and email turned on // TODO: add these settings to default fixtures @@ -76,7 +77,6 @@ describe('Acceptance: Members filtering', function () { // add a labelled member so we can test the filter includes correctly const label = this.server.create('label'); this.server.createList('member', 3, {labels: [label]}); - // add some non-labelled members so we can see the filter excludes correctly this.server.createList('member', 4); @@ -119,6 +119,54 @@ describe('Acceptance: Members filtering', function () { .to.equal(7); }); + it('can filter by tier', async function () { + // add some labels to test the selection dropdown + this.server.createList('product', 4); + + // add a labelled member so we can test the filter includes correctly + const product = this.server.create('product'); + this.server.createList('member', 3, {products: [product]}); + + // add some non-labelled members so we can see the filter excludes correctly + this.server.createList('member', 4); + + await visit('/members'); + + expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') + .to.equal(7); + await click('[data-test-button="members-filter-actions"]'); + + const filterSelector = `[data-test-members-filter="0"]`; + + await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'product'); + + // has the right operators + const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); + expect(operatorOptions).to.have.length(2); + expect(operatorOptions[0]).to.have.value('is'); + expect(operatorOptions[1]).to.have.value('is-not'); + + // value dropdown can open and has all labels + await click(`${filterSelector} .gh-tier-token-input .ember-basic-dropdown-trigger`); + expect(findAll(`${filterSelector} [data-test-tiers-segment]`).length, '# of label options').to.equal(5); + + // selecting a value updates table + await selectChoose(`${filterSelector} .gh-tier-token-input`, product.name); + + expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${product.name}`) + .to.equal(3); + // table shows labels column+data + expect(find('[data-test-table-column="product"]')).to.exist; + expect(findAll('[data-test-table-data="product"]').length).to.equal(3); + expect(find('[data-test-table-data="product"]')).to.contain.text(product.name); + + // can delete filter + await click('[data-test-delete-members-filter="0"]'); + + expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') + .to.equal(7); + }); + it('can filter by newsletter subscription', async function () { // add some members to filter this.server.createList('member', 3, {subscribed: true});