Added dynamic columns to member list from filter UI

refs https://github.com/TryGhost/Team/issues/943

- adds new columns to member list table based on selected filters in UI
- handles dynamic columns in members list with formatted output like for labels
- works behind the filtering feature flag
This commit is contained in:
Rishabh 2021-08-10 17:41:22 +05:30
parent 948934da2d
commit 247f24394d
8 changed files with 199 additions and 46 deletions

View File

@ -53,7 +53,8 @@
type="button" type="button"
class="gh-btn gh-btn-text gh-btn-link green gh-btn-icon gh-add-filter" class="gh-btn gh-btn-text gh-btn-link green gh-btn-icon gh-add-filter"
title="Delete filter" title="Delete filter"
{{on "click" (fn this.deleteFilter filter.id)}} {{!-- {{on "click" (fn this.deleteFilter filter.id)}} --}}
{{action "deleteFilter" filter.id}}
> >
{{svg-jar "close"}} <span class="hidden">Delete filter</span> {{svg-jar "close"}} <span class="hidden">Delete filter</span>
</button> </button>

View File

@ -8,34 +8,35 @@ const FILTER_PROPERTIES = [
// Basic // Basic
{label: 'Name', name: 'name', group: 'Basic'}, {label: 'Name', name: 'name', group: 'Basic'},
{label: 'Email', name: 'email', group: 'Basic'}, {label: 'Email', name: 'email', group: 'Basic'},
{label: 'Location', name: 'location', group: 'Basic'}, // {label: 'Location', name: 'location', group: 'Basic'},
{label: 'Newsletter subscription status', name: 'subscribed', group: 'Basic'}, {label: 'Newsletter subscription status', name: 'subscribed', group: 'Basic'},
{label: 'Label', name: 'label', group: 'Basic'}, {label: 'Label', name: 'label', group: 'Basic'},
// Member subscription // Member subscription
{label: 'Member status', name: 'member-status', group: 'Subscription'}, {label: 'Member status', name: 'status', group: 'Subscription'},
{label: 'Tier', name: 'tier', group: 'Subscription'}, // {label: 'Tier', name: 'tier', group: 'Subscription'},
{label: 'Billing period', name: 'billing-period', group: 'Subscription'}, // {label: 'Billing period', name: 'billing-period', group: 'Subscription'},
// Emails // Emails
{label: 'Emails sent (all time)', name: 'x', group: 'Email'}, {label: 'Emails sent (all time)', name: 'email_count', group: 'Email'},
{label: 'Emails opened (all time)', name: 'x', group: 'Email'}, {label: 'Emails opened (all time)', name: 'email_opened_count', group: 'Email'},
{label: 'Open rate (all time)', name: 'x', group: 'Email'}, {label: 'Open rate (all time)', name: 'email_open_rate', group: 'Email'},
{label: 'Emails sent (30 days)', name: 'x', group: 'Email'}, // {label: 'Emails sent (30 days)', name: 'x', group: 'Email'},
{label: 'Emails opened (30 days)', name: 'x', group: 'Email'}, // {label: 'Emails opened (30 days)', name: 'x', group: 'Email'},
{label: 'Open rate (30 days)', name: 'x', group: 'Email'}, // {label: 'Open rate (30 days)', name: 'x', group: 'Email'},
{label: 'Emails sent (60 days)', name: 'x', group: 'Email'}, // {label: 'Emails sent (60 days)', name: 'x', group: 'Email'},
{label: 'Emails opened (60 days)', name: 'x', group: 'Email'}, // {label: 'Emails opened (60 days)', name: 'x', group: 'Email'},
{label: 'Open rate (60 days)', name: 'x', group: 'Email'}, // {label: 'Open rate (60 days)', name: 'x', group: 'Email'},
{label: 'Stripe subscription status', name: 'status', group: 'Email'} {label: 'Stripe subscription status', name: 'stripe-status', group: 'Email'}
]; ];
const FILTER_RELATIONS = [ const FILTER_RELATIONS = [
{label: 'is', name: 'is'}, {label: 'is', name: 'is'},
{label: 'is not', name: 'is-not'}, {label: 'is not', name: 'is-not'},
{label: 'contains', name: 'contains'}, {label: 'in', name: 'in'}
{label: 'exists', name: 'exists'}, // {label: 'contains', name: 'contains'},
{label: 'does not exist', name: 'does-not-exist'} // {label: 'exists', name: 'exists'},
// {label: 'does not exist', name: 'does-not-exist'}
]; ];
export default class GhMembersFilterLabsComponent extends Component { export default class GhMembersFilterLabsComponent extends Component {
@ -71,7 +72,8 @@ export default class GhMembersFilterLabsComponent extends Component {
let query = ''; let query = '';
filters.forEach((filter) => { filters.forEach((filter) => {
const relationStr = filter.relation === 'is-not' ? '-' : ''; const relationStr = filter.relation === 'is-not' ? '-' : '';
query += `${filter.type}:${relationStr}'${filter.value}',`; const filterValue = filter.value.includes(' ') ? `'${filter.value}'` : filter.value;
query += `${filter.type}:${relationStr}${filterValue}+`;
}); });
return query.slice(0, -1); return query.slice(0, -1);
} }
@ -103,6 +105,6 @@ export default class GhMembersFilterLabsComponent extends Component {
@action @action
applyFilter() { applyFilter() {
const query = this.generateNqlFilter(this.filters); const query = this.generateNqlFilter(this.filters);
this.args.onApplyFilter(query); this.args.onApplyFilter(query, this.filters);
} }
} }

View File

@ -0,0 +1,45 @@
{{#if (eq @filterColumn 'label')}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
<span class="midlightgrey">{{labels}}</span>
</LinkTo>
{{/if}}
{{#if (eq @filterColumn 'status')}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
{{#if (not (is-empty @member.status))}}
<span class="gh-members-list-open-rate-mobile">{{capitalize @member.status}}</span>
{{else}}
<span class="midlightgrey">-</span>
{{/if}}
</LinkTo>
{{/if}}
{{#if (eq @filterColumn 'email_count')}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
{{#if (not (is-empty @member.emailCount))}}
<span class="gh-members-list-open-rate-mobile">{{@member.emailCount}}</span>
{{else}}
<span class="midlightgrey">-</span>
{{/if}}
</LinkTo>
{{/if}}
{{#if (eq @filterColumn 'email_opened_count')}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
{{#if (not (is-empty @member.emailOpenedCount))}}
<span class="gh-members-list-open-rate-mobile">{{@member.emailOpenedCount}}</span>
{{else}}
<span class="midlightgrey">-</span>
{{/if}}
</LinkTo>
{{/if}}
{{#if (eq @filterColumn 'subscribed')}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
{{#if (not (is-empty @member.subscribed))}}
<span class="gh-members-list-open-rate-mobile">{{@member.subscribed}}</span>
{{else}}
<span class="midlightgrey">-</span>
{{/if}}
</LinkTo>
{{/if}}

View File

@ -0,0 +1,12 @@
import Component from '@glimmer/component';
export default class GhMembersListItemColumnLabs extends Component {
constructor(...args) {
super(...args);
}
get labels() {
const labelData = this.args.member.get('labels') || [];
return labelData.map(label => label.name).join(',');
}
}

View File

@ -0,0 +1,71 @@
<tr>
{{#if @member.is_loading}}
<div class="gh-list-data gh-members-list-basic gh-list-loadingcell">
<div class="gh-list-loading-title"></div>
<div class="gh-list-loading-detail"></div>
</div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div>
{{#each @filterColumns as |filterColumn|}}
<div class="gh-list-data"></div>
{{/each}}
{{!-- <div class="gh-list-data"></div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div>
<div class="gh-list-data"></div> --}}
{{else}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data">
<div class="flex items-center">
<GhMemberAvatar @member={{@member}} @containerClass="w9 h9 mr3 flex-shrink-0" />
<div class="w-80">
<h3 class="ma0 pa0 gh-members-list-name {{if (not @member.name) "gh-members-name-noname"}}">{{or @member.name @member.email}}</h3>
{{#if @member.name}}
<p class="ma0 pa0 middarkgrey f8 gh-members-list-email">{{@member.email}}</p>
{{/if}}
</div>
</div>
</LinkTo>
{{#if (feature "emailAnalytics")}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8 {{if (not @member.name) "gh-members-list-open-rate-noname"}}">
{{#if (not (is-empty @member.emailOpenRate))}}
<span class="gh-members-list-open-rate-mobile">{{@member.emailOpenRate}}%</span>
{{else}}
<span class="midlightgrey">N/A</span>
{{/if}}
</LinkTo>
{{/if}}
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8 {{if (not @member.name) "gh-members-geolocation-noname"}}">
{{#if (and @member.geolocation @member.geolocation.country)}}
{{#if (and (eq @member.geolocation.country_code "US") @member.geolocation.region)}}
{{@member.geolocation.region}}, US
{{else}}
{{#if @member.geolocation.country}}
{{@member.geolocation.country}}
{{else}}
<span class="midlightgrey">Unknown</span>
{{/if}}
{{/if}}
{{else}}
<span class="midlightgrey">Unknown</span>
{{/if}}
</LinkTo>
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
{{#if @member.createdAtUTC}}
<div>{{moment-format @member.createdAtUTC "D MMM YYYY"}}</div>
<div class="midlightgrey gh-members-list-subscribed-moment">{{moment-from-now @member.createdAtUTC}}</div>
{{/if}}
</LinkTo>
{{#each @filterColumns as |filterColumn|}}
<GhMembersListItemColumnLabs @member={{@member}} @filterColumn={{filterColumn}} />
{{!-- <LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data middarkgrey f8">
<span class="midlightgrey">Unknown</span>
</LinkTo> --}}
{{/each}}
{{/if}}
</tr>

View File

@ -5,6 +5,7 @@ import ghostPaths from 'ghost-admin/utils/ghost-paths';
import moment from 'moment'; import moment from 'moment';
import {A} from '@ember/array'; import {A} from '@ember/array';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {capitalize} from '@ember/string';
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
@ -51,6 +52,7 @@ export default class MembersController extends Controller {
@tracked modalLabel = null; @tracked modalLabel = null;
@tracked showLabelModal = false; @tracked showLabelModal = false;
@tracked showDeleteMembersModal = false; @tracked showDeleteMembersModal = false;
@tracked filters = A([]);
@tracked _availableLabels = A([]); @tracked _availableLabels = A([]);
@ -151,6 +153,21 @@ export default class MembersController extends Controller {
return !!(this.label || this.paidParam || this.searchParam || this.filterParam); return !!(this.label || this.paidParam || this.searchParam || this.filterParam);
} }
get filterColumns() {
const defaultColumns = ['name', 'email'];
return this.filters.map((filter) => {
return filter.type;
}).filter((f, idx, arr) => {
return arr.indexOf(f) === idx;
}).filter(d => !defaultColumns.includes(d));
}
get filterColumnLabels() {
return this.filterColumns.map((d) => {
return capitalize(d.replace(/_/g, ' '));
});
}
getApiQueryObject({params, extraFilters = []} = {}) { getApiQueryObject({params, extraFilters = []} = {}) {
let {label, paidParam, searchParam, filterParam} = params ? params : this; let {label, paidParam, searchParam, filterParam} = params ? params : this;
@ -195,7 +212,8 @@ export default class MembersController extends Controller {
} }
@action @action
applyFilter(filterStr) { applyFilter(filterStr, filters) {
this.filters = filters;
this.filterParam = filterStr || null; this.filterParam = filterStr || null;
} }

View File

@ -9,7 +9,7 @@ export default class MembersRoute extends AuthenticatedRoute {
searchParam: {refreshModel: true, replace: true}, searchParam: {refreshModel: true, replace: true},
paidParam: {refreshModel: true}, paidParam: {refreshModel: true},
orderParam: {refreshModel: true}, orderParam: {refreshModel: true},
filterParam: {refreshModel: true, replace: true} filterParam: {refreshModel: true}
}; };
// redirect to posts screen if: // redirect to posts screen if:

View File

@ -88,30 +88,34 @@
{{#unless this.members.loading}} {{#unless this.members.loading}}
<section class="view-container"> <section class="view-container">
{{#if (feature "membersFiltering")}} {{#if (feature "membersFiltering")}}
<div class="gh-list-scrolling"> <div class="gh-list-scrolling">
<table class="gh-list"> <table class="gh-list">
<thead> <thead>
<tr> <tr>
<th>{{this.listHeader}}</th> <th>{{this.listHeader}}</th>
<th>Open rate</th> <th>Open rate</th>
<th>Location</th> <th>Location</th>
<th>Created</th> <th>Created</th>
<th>Open rate</th> {{#each this.filterColumnLabels as |filterColumn|}}
<th>Location</th> <th>{{filterColumn}}</th>
<th>Created</th> {{/each}}
<th>Open rate</th> {{!-- <th>Open rate</th>
<th>Location</th> <th>Location</th>
<th>Created</th> <th>Created</th>
</tr> <th>Open rate</th>
</thead> <th>Location</th>
<VerticalCollection @tagName="tbody" @items={{this.members}} @key="id" @containerSelector=".gh-main" @estimateHeight={{69}} @staticHeight={{true}} @bufferSize={{20}} as |member|> <th>Created</th> --}}
<GhMembersListItem </tr>
@member={{member}} </thead>
data-test-member={{member.id}} <VerticalCollection @tagName="tbody" @items={{this.members}} @key="id" @containerSelector=".gh-main" @estimateHeight={{69}} @staticHeight={{true}} @bufferSize={{20}} as |member|>
/> <GhMembersListItemLabs
</VerticalCollection> @member={{member}}
</table> @filterColumns={{this.filterColumns}}
</div> data-test-member={{member.id}}
/>
</VerticalCollection>
</table>
</div>
{{else}} {{else}}
<section class="content-list"> <section class="content-list">
<ol class="members-list gh-list {{unless this.members "no-posts"}}"> <ol class="members-list gh-list {{unless this.members "no-posts"}}">