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.
This commit is contained in:
Rishabh Garg 2022-02-28 16:08:44 +05:30 committed by GitHub
parent a7ef6c97e8
commit c1ad9475d7
13 changed files with 293 additions and 8 deletions

View File

@ -3,6 +3,11 @@
<span class="gh-members-list-labels">{{this.labels}}</span>
</LinkTo>
{{else if (eq @filterColumn 'product')}}
<LinkTo @route="member" @model={{@member}} class="gh-list-data wrap middarkgrey f8" data-test-table-data={{@filterColumn}}>
<span class="gh-members-list-labels">{{this.products}}</span>
</LinkTo>
{{else if (eq @filterColumn 'status')}}
<LinkTo @route="member" @model={{@member}} class="gh-list-data middarkgrey f8" data-test-table-data={{@filterColumn}}>
{{#if (not (is-empty @member.status))}}

View File

@ -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;

View File

@ -8,6 +8,16 @@
@allowEdit={{true}}
/>
{{else if (eq @filter.type 'product')}}
<div class="relative">
<Tiers::SegmentSelect
@onChange={{fn this.setProductsFilterValue @filter.type @filter.id}}
@tiers={{this.productFilterValue}}
@renderInPlace={{true}}
@hideOptionsWhenAllSelected={{true}}
/>
</div>
{{else if (eq @filter.type 'subscribed')}}
<span class="gh-select">
<OneWaySelect

View File

@ -36,6 +36,18 @@ export default class MembersFilterValue extends Component {
this.filterValue = this.args.filter.value;
}
get productFilterValue() {
if (this.args.filter?.type === 'product') {
const tiers = this.args.filter?.value || [];
return tiers.map((tier) => {
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);

View File

@ -13,7 +13,7 @@
</span>
</dd.Trigger>
<dd.Content class="gh-member-actions-menu gh-filter-builder gh-members-filter-builder dropdown-menu dropdown-triangle-top-right">
<dd.Content class="gh-member-actions-menu gh-filter-builder gh-members-filter-builder dropdown-menu dropdown-triangle-top-right" {{did-insert this.setup}}>
<h3>Filter list</h3>
<section class="gh-filters">
{{#each this.filters as |filter index|}}

View File

@ -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
// is-less = less 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;
}
}

View File

@ -0,0 +1,22 @@
<GhTokenInput
@options={{this.options}}
@selected={{this.selectedOptions}}
@disabled={{or @disabled this.fetchOptionsTask.isRunning}}
@optionsComponent="power-select/options"
@allowCreation={{false}}
@renderInPlace={{this.renderInPlace}}
@onChange={{this.setSegment}}
@class="select-members gh-tier-token-input"
@placeholder="Select a tier"
as |option|
>
<span data-test-tiers-segment={{option.name}}>{{option.name}}</span>
</GhTokenInput>
{{#if @showMemberCount}}
<GhMembersSegmentCount
@segment={{@segment}}
@enforcedFilter={{@enforcedCountFilter}}
@onSegmentCountChange={{@onSegmentCountChange}}
/>
{{/if}}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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);
});
}

View File

@ -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()
});

View File

@ -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});