mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
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:
parent
a7ef6c97e8
commit
c1ad9475d7
@ -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))}}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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|}}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
22
ghost/admin/app/components/tiers/segment-select.hbs
Normal file
22
ghost/admin/app/components/tiers/segment-select.hbs
Normal 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}}
|
102
ghost/admin/app/components/tiers/segment-select.js
Normal file
102
ghost/admin/app/components/tiers/segment-select.js
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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()
|
||||
});
|
||||
|
@ -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});
|
||||
|
Loading…
Reference in New Issue
Block a user