mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
✨ Added open-rate column and ordering to the members list (#1790)
closes https://github.com/TryGhost/Ghost/issues/12421 - added `emailOpenRate` property to member model - added open-rate column to the members list - hidden when email analytics is disabled - added `{{feature "flag"}}` helper so feature flags can be checked in templates without injecting the feature service into the backing class - added `order` query param to the members controller/route and wired it into the data fetching routine - added order dropdown to the filter bar with "Newest" (default) and "Open rate" as the two options - whole dropdown is hidden if email analytics is disabled Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
This commit is contained in:
parent
1372c0ddbc
commit
3d3173ca90
9 changed files with 108 additions and 16 deletions
|
@ -53,4 +53,20 @@
|
|||
>
|
||||
{{#if paidParam.name}}{{paidParam.name}}{{else}}<span class="red">Unknown paid status</span>{{/if}}
|
||||
</PowerSelect>
|
||||
</div>
|
||||
|
||||
<div class="gh-contenfilter-menu gh-contentfilter-sort" data-test-select="members-order">
|
||||
<PowerSelect
|
||||
@selected={{@selectedOrder}}
|
||||
@options={{@availableOrders}}
|
||||
@searchEnabled={{false}}
|
||||
@onChange={{@onOrderChange}}
|
||||
@triggerComponent="gh-power-select/trigger"
|
||||
@triggerClass="gh-contentfilter-menu-trigger"
|
||||
@dropdownClass="gh-contentfilter-menu-dropdown"
|
||||
@matchTriggerWidth={{false}}
|
||||
as |order|
|
||||
>
|
||||
{{#if order.name}}{{order.name}}{{else}}<span class="red">Unknown</span>{{/if}}
|
||||
</PowerSelect>
|
||||
</div>
|
|
@ -4,8 +4,9 @@
|
|||
<div class="gh-list-loading-title"></div>
|
||||
<div class="gh-list-loading-detail"></div>
|
||||
</div>
|
||||
<div class="gh-list-data gh-members-list-geolocation gh-list-cellwidth-20"></div>
|
||||
<div class="gh-list-data gh-members-list-subscribed-at gh-list-cellwidth-20"></div>
|
||||
<div class="gh-list-data gh-members-list-open-rate gh-list-cellwidth-10"></div>
|
||||
<div class="gh-list-data gh-members-list-geolocation gh-list-cellwidth-10"></div>
|
||||
<div class="gh-list-data gh-members-list-subscribed-at gh-list-cellwidth-10"></div>
|
||||
<div class="gh-list-data gh-members-list-chevron gh-list-cellwidth-chevron"></div>
|
||||
{{else}}
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-basic">
|
||||
|
@ -20,7 +21,15 @@
|
|||
</div>
|
||||
</LinkTo>
|
||||
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-geolocation gh-list-cellwidth-20 middarkgrey f8 {{if (not @member.name) "gh-members-geolocation-noname"}}">
|
||||
{{#if (feature "emailAnalytics")}}
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-open-rate middarkgrey f8">
|
||||
{{#if (not (is-empty @member.emailOpenRate))}}
|
||||
<span>{{@member.emailOpenRate}}%</span>
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-geolocation 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
|
||||
|
@ -32,7 +41,7 @@
|
|||
{{/if}}
|
||||
</LinkTo>
|
||||
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-subscribed-at gh-list-cellwidth-20 middarkgrey f8 {{if (not @member.name) "gh-members-subscribed-noname"}}">
|
||||
<LinkTo @route="member" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-subscribed-at middarkgrey f8 {{if (not @member.name) "gh-members-subscribed-noname"}}">
|
||||
{{#if @member.createdAtUTC}}
|
||||
{{moment-format @member.createdAtUTC "D MMM YYYY"}}
|
||||
<span class="midlightgrey">({{moment-from-now @member.createdAtUTC}})</span>
|
||||
|
|
|
@ -32,7 +32,8 @@ export default class MembersController extends Controller {
|
|||
queryParams = [
|
||||
'label',
|
||||
{paidParam: 'paid'},
|
||||
{searchParam: 'search'}
|
||||
{searchParam: 'search'},
|
||||
{orderParam: 'order'}
|
||||
];
|
||||
|
||||
@tracked members = A([]);
|
||||
|
@ -40,6 +41,7 @@ export default class MembersController extends Controller {
|
|||
@tracked searchParam = '';
|
||||
@tracked paidParam = null;
|
||||
@tracked label = null;
|
||||
@tracked orderParam = null;
|
||||
@tracked modalLabel = null;
|
||||
@tracked showLabelModal = false;
|
||||
@tracked showDeleteMembersModal = false;
|
||||
|
@ -83,6 +85,27 @@ export default class MembersController extends Controller {
|
|||
return !this.searchParam && !this.paidParam && !this.label;
|
||||
}
|
||||
|
||||
get availableOrders() {
|
||||
// don't return anything if email analytics is disabled because
|
||||
// we don't want to show an order dropdown with only a single option
|
||||
|
||||
if (this.feature.get('emailAnalytics')) {
|
||||
return [{
|
||||
name: 'Newest',
|
||||
value: null
|
||||
}, {
|
||||
name: 'Open rate',
|
||||
value: 'email_open_rate'
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get selectedOrder() {
|
||||
return this.availableOrders.find(order => order.value === this.orderParam);
|
||||
}
|
||||
|
||||
get availableLabels() {
|
||||
let labels = this._availableLabels
|
||||
.filter(label => !label.isNew)
|
||||
|
@ -124,6 +147,11 @@ export default class MembersController extends Controller {
|
|||
this.membersStats.fetch();
|
||||
}
|
||||
|
||||
@action
|
||||
changeOrder(order) {
|
||||
this.orderParam = order.value;
|
||||
}
|
||||
|
||||
@action
|
||||
search(e) {
|
||||
this.searchTask.perform(e.target.value);
|
||||
|
@ -221,7 +249,7 @@ export default class MembersController extends Controller {
|
|||
@task({restartable: true})
|
||||
*fetchMembersTask(params) {
|
||||
// params is undefined when called as a "refresh" of the model
|
||||
let {label, paidParam, searchParam} = typeof params === 'undefined' ? this : params;
|
||||
let {label, paidParam, searchParam, orderParam} = typeof params === 'undefined' ? this : params;
|
||||
|
||||
if (!searchParam) {
|
||||
this.resetSearch();
|
||||
|
@ -234,10 +262,12 @@ export default class MembersController extends Controller {
|
|||
let forceReload = !params
|
||||
|| label !== this._lastLabel
|
||||
|| paidParam !== this._lastPaidParam
|
||||
|| searchParam !== this._lastSearchParam;
|
||||
|| searchParam !== this._lastSearchParam
|
||||
|| orderParam !== this._orderParam;
|
||||
this._lastLabel = label;
|
||||
this._lastPaidParam = paidParam;
|
||||
this._lastSearchParam = searchParam;
|
||||
this._lastOrderParam = orderParam;
|
||||
|
||||
// unless we have a forced reload, do not re-fetch the members list unless it's more than a minute old
|
||||
// keeps navigation between list->details->list snappy
|
||||
|
@ -251,11 +281,12 @@ export default class MembersController extends Controller {
|
|||
const labelFilter = label ? `label:'${label}'+` : '';
|
||||
const paidQuery = paidParam ? {paid: paidParam} : {};
|
||||
const searchQuery = searchParam ? {search: searchParam} : {};
|
||||
const order = orderParam ? `${orderParam} desc` : `created_at desc`;
|
||||
|
||||
query = Object.assign({
|
||||
order,
|
||||
limit: range.length,
|
||||
page: range.page,
|
||||
order: 'created_at desc',
|
||||
filter: `${labelFilter}created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`
|
||||
}, paidQuery, searchQuery, query);
|
||||
|
||||
|
|
10
app/helpers/feature.js
Normal file
10
app/helpers/feature.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import Helper from '@ember/component/helper';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class EnableDeveloperExperimentsHelper extends Helper {
|
||||
@service feature;
|
||||
|
||||
compute([featureFlag]) {
|
||||
return this.feature.get(featureFlag);
|
||||
}
|
||||
}
|
|
@ -18,7 +18,8 @@ export const DEFAULT_QUERY_PARAMS = {
|
|||
'members.index': {
|
||||
label: null,
|
||||
paid: null,
|
||||
search: ''
|
||||
search: '',
|
||||
order: null
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ export default Model.extend(ValidationEngine, {
|
|||
labels: hasMany('label', {embedded: 'always', async: false}),
|
||||
comped: attr('boolean', {defaultValue: false}),
|
||||
geolocation: attr('json-string'),
|
||||
emailOpenRate: attr('number'),
|
||||
|
||||
ghostPaths: service(),
|
||||
ajax: service(),
|
||||
|
|
|
@ -7,7 +7,8 @@ export default class MembersRoute extends AuthenticatedRoute {
|
|||
queryParams = {
|
||||
label: {refreshModel: true},
|
||||
searchParam: {refreshModel: true, replace: true},
|
||||
paidParam: {refreshModel: true}
|
||||
paidParam: {refreshModel: true},
|
||||
orderParam: {refreshModel: true}
|
||||
};
|
||||
|
||||
// redirect to posts screen if:
|
||||
|
|
|
@ -121,7 +121,13 @@ p.gh-members-list-email {
|
|||
margin: -2px 0 -1px;
|
||||
}
|
||||
|
||||
.gh-members-list-open-rate,
|
||||
.gh-members-list-geolocation {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.gh-members-list-subscribed-at {
|
||||
width: 240px;
|
||||
margin-right: -8px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
@ -324,15 +330,26 @@ p.gh-members-list-email {
|
|||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.gh-members-list-open-rate {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
margin-top: -16px;
|
||||
padding: 0 0 0 64px;
|
||||
}
|
||||
|
||||
.gh-members-list-open-rate::after {
|
||||
content: "open rate •";
|
||||
}
|
||||
|
||||
.gh-members-list-geolocation {
|
||||
display: inline-block;
|
||||
padding: 0 0 0 64px;
|
||||
margin-top: -16px;
|
||||
width: auto;
|
||||
margin-top: -16px;
|
||||
padding: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.gh-members-list-geolocation::after {
|
||||
content: "–";
|
||||
content: "•";
|
||||
}
|
||||
|
||||
.gh-members-geolocation-noname {
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
@selectedPaidParam={{this.selectedPaidParam}}
|
||||
@availablePaidParams={{this.paidParams}}
|
||||
@onPaidParamChange={{this.changePaidParam}}
|
||||
@availableOrders={{this.availableOrders}}
|
||||
@selectedOrder={{this.selectedOrder}}
|
||||
@onOrderChange={{this.changeOrder}}
|
||||
/>
|
||||
<div class="relative gh-members-header-search">
|
||||
{{svg-jar "search" class="gh-input-search-icon"}}
|
||||
|
@ -62,7 +65,7 @@
|
|||
<section class="view-container">
|
||||
{{#if (and this.members this.showingAll)}}
|
||||
<section>
|
||||
<GhMembersChart nightShift={{this.feature.nightShift}} />
|
||||
<GhMembersChart nightShift={{feature "nightShift"}} />
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
|
@ -71,8 +74,11 @@
|
|||
{{#if this.members}}
|
||||
<li class="gh-list-row header relative">
|
||||
<div class="gh-list-header" data-test-list-header>{{this.listHeader}}</div>
|
||||
<div class="gh-list-header gh-members-list-geolocation gh-list-cellwidth-20 nowrap">Location</div>
|
||||
<div class="gh-list-header gh-members-list-subscribed-at gh-list-cellwidth-20 nowrap">Created</div>
|
||||
{{#if (feature "emailAnalytics")}}
|
||||
<div class="gh-list-header gh-members-list-open-rate nowrap">Open rate</div>
|
||||
{{/if}}
|
||||
<div class="gh-list-header gh-members-list-geolocation nowrap">Location</div>
|
||||
<div class="gh-list-header gh-members-list-subscribed-at nowrap">Created</div>
|
||||
<div class="gh-list-header gh-members-list-chevron gh-list-cellwidth-chevron"></div>
|
||||
</li>
|
||||
<VerticalCollection @items={{this.members}} @key="id" @containerSelector=".gh-main" @estimateHeight={{69}} @staticHeight={{true}} @bufferSize={{20}} as |member|>
|
||||
|
|
Loading…
Reference in a new issue