mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 19:48:50 +03:00
✨ Improved performance of members admin screens
no issue - removes the "old" members screens - swaps route names and links to point at the new members screens that were behind the experiments flag Why are the new screens faster? - only loads 50 members at once rather than every member in the database - loads pages of members in as-needed whilst scrolling - fetches member stats from the API rather than calculating locally - caches members list and stats data for 60 seconds to avoid re-fetching when navigating to/from the members list - moves search and filtering duties to the API rather than calculating locally
This commit is contained in:
parent
a76465b5ee
commit
9942fb337e
@ -1,54 +0,0 @@
|
|||||||
<div class="flex justify-between mb6 items-stretch gh-members-chart-wrapper">
|
|
||||||
|
|
||||||
{{!-- Chart title/filter graph --}}
|
|
||||||
<div class="flex-auto bg-white br3 shadow-1 bg-grouped-table mr6 gh-members-chart-box">
|
|
||||||
<div class="flex justify-between items-center gh-members-chart-header">
|
|
||||||
<h2 class="f-small ttu midgrey fw5 mb0">Total members</h2>
|
|
||||||
<div class="view-actions">
|
|
||||||
<div class="gh-contentfilter gh-contentfilter-menu gh-contentfilter-type">
|
|
||||||
<PowerSelect
|
|
||||||
@selected={{this.selectedRange}}
|
|
||||||
@options={{this.availableRange}}
|
|
||||||
@searchEnabled={{false}}
|
|
||||||
@onChange={{action "changeDateRange"}}
|
|
||||||
@triggerComponent="gh-power-select/trigger"
|
|
||||||
@triggerClass="gh-contentfilter-menu-trigger"
|
|
||||||
@dropdownClass="gh-contentfilter-menu-dropdown gh-members-chart-dropdown"
|
|
||||||
@matchTriggerWidth={{false}}
|
|
||||||
data-test-type-select="true"
|
|
||||||
as |range|
|
|
||||||
>
|
|
||||||
{{range.name}}
|
|
||||||
</PowerSelect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="gh-members-chart-container">
|
|
||||||
<EmberChart @type="LineWithLine" @options={{this.subData.chartData.options}} @data={{this.subData.chartData.data}} @height={{300}} />
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between pa4 pt0 pb2 nt1">
|
|
||||||
<span class="f8 midlightgrey">{{this.subData.startDateLabel}}</span>
|
|
||||||
<span class="f8 midlightgrey">Today</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{!-- Summary --}}
|
|
||||||
<div class="flex flex-column justify-between gh-members-chart-summary bg-white br3 shadow-1 bg-grouped-table">
|
|
||||||
<div class="flex-auto flex flex-column justify-center items-start pa4 bb b--whitegrey">
|
|
||||||
<h3 class="f-small ttu midgrey fw5">Total Members</h3>
|
|
||||||
<div class="gh-members-chart-summary-data">{{this.subData.totalSubs}}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-auto flex flex-column justify-center items-start pa4 bb b--whitegrey">
|
|
||||||
{{#if (eq this.range "all-time")}}
|
|
||||||
<h3 class="f-small ttu midgrey fw5">All time signups</h3>
|
|
||||||
{{else}}
|
|
||||||
<h3 class="f-small ttu midgrey fw5">Signed up in the last {{this.range}} days</h3>
|
|
||||||
{{/if}}
|
|
||||||
<div class="gh-members-chart-summary-data">{{this.subData.totalSubsInRange}}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-auto flex flex-column justify-center items-start pa4">
|
|
||||||
<h3 class="f-small ttu midgrey fw5">Signed up today</h3>
|
|
||||||
<div class="gh-members-chart-summary-data">{{this.subData.totalSubsToday}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,249 +0,0 @@
|
|||||||
/* global Chart */
|
|
||||||
import Component from '@ember/component';
|
|
||||||
import moment from 'moment';
|
|
||||||
import {computed, get} from '@ember/object';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
|
|
||||||
export default Component.extend({
|
|
||||||
feature: service(),
|
|
||||||
members: null,
|
|
||||||
range: '30',
|
|
||||||
selectedRange: computed('range', function () {
|
|
||||||
const availableRange = this.get('availableRange');
|
|
||||||
return availableRange.findBy('days', this.get('range'));
|
|
||||||
}),
|
|
||||||
availableRange: computed(function () {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: '30 days',
|
|
||||||
days: '30'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '90 days',
|
|
||||||
days: '90'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '365 days',
|
|
||||||
days: '365'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'All time',
|
|
||||||
days: 'all-time'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
|
|
||||||
subData: computed('members.[]', 'range', 'feature.nightShift', function () {
|
|
||||||
let isNightShiftEnabled = this.feature.nightShift;
|
|
||||||
let {members, range} = this;
|
|
||||||
let rangeInDays, rangeStartDate, rangeEndDate;
|
|
||||||
if (range === 'last-year') {
|
|
||||||
rangeStartDate = moment().startOf('year').subtract(1, 'year');
|
|
||||||
rangeEndDate = moment().endOf('year').subtract(1, 'year').subtract(1, 'day');
|
|
||||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
|
||||||
} else if (range === 'all-time') {
|
|
||||||
let firstMemberCreatedDate = members.length ? members.lastObject.get('createdAtUTC') : moment().subtract(365, 'days');
|
|
||||||
rangeStartDate = moment(firstMemberCreatedDate);
|
|
||||||
rangeEndDate = moment();
|
|
||||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
|
||||||
if (rangeInDays < 5) {
|
|
||||||
rangeStartDate = moment().subtract(6, 'days');
|
|
||||||
rangeInDays = rangeEndDate.diff(rangeStartDate, 'days');
|
|
||||||
}
|
|
||||||
let step = this.getTicksForRange(rangeInDays);
|
|
||||||
rangeInDays = Math.ceil(rangeInDays / step) * step;
|
|
||||||
rangeStartDate = moment().subtract(rangeInDays, 'days');
|
|
||||||
} else {
|
|
||||||
rangeInDays = parseInt(range);
|
|
||||||
rangeStartDate = moment().subtract((rangeInDays), 'days');
|
|
||||||
rangeEndDate = moment();
|
|
||||||
}
|
|
||||||
let totalSubs = members.length || 0;
|
|
||||||
let totalSubsLastMonth = members.filter((member) => {
|
|
||||||
let isValid = moment(member.createdAtUTC).isSameOrAfter(rangeStartDate, 'day');
|
|
||||||
return isValid;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
let totalSubsToday = members.filter((member) => {
|
|
||||||
let isValid = moment(member.createdAtUTC).isSame(moment(), 'day');
|
|
||||||
return isValid;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDateLabel: moment(rangeStartDate).format('MMM DD, YYYY'),
|
|
||||||
chartData: this.getChartData(members, moment(rangeStartDate), moment(rangeEndDate), isNightShiftEnabled),
|
|
||||||
totalSubs: totalSubs,
|
|
||||||
totalSubsToday: totalSubsToday,
|
|
||||||
totalSubsInRange: totalSubsLastMonth
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super(...arguments);
|
|
||||||
this.setChartJSDefaults();
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
changeDateRange(range) {
|
|
||||||
this.set('range', get(range, 'days'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setChartJSDefaults() {
|
|
||||||
let isNightShiftEnabled = this.feature.nightShift;
|
|
||||||
Chart.defaults.LineWithLine = Chart.defaults.line;
|
|
||||||
Chart.controllers.LineWithLine = Chart.controllers.line.extend({
|
|
||||||
draw: function (ease) {
|
|
||||||
Chart.controllers.line.prototype.draw.call(this, ease);
|
|
||||||
|
|
||||||
if (this.chart.tooltip._active && this.chart.tooltip._active.length) {
|
|
||||||
var activePoint = this.chart.tooltip._active[0],
|
|
||||||
ctx = this.chart.ctx,
|
|
||||||
x = activePoint.tooltipPosition().x,
|
|
||||||
topY = this.chart.scales['y-axis-0'].top,
|
|
||||||
bottomY = this.chart.scales['y-axis-0'].bottom;
|
|
||||||
|
|
||||||
// draw line
|
|
||||||
ctx.save();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, topY);
|
|
||||||
ctx.lineTo(x, bottomY);
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeStyle = (isNightShiftEnabled ? 'rgba(62, 176, 239, 0.65)' : 'rgba(62, 176, 239, 0.8)');
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getTicksForRange(rangeInDays) {
|
|
||||||
if (rangeInDays <= 30) {
|
|
||||||
return 6;
|
|
||||||
} else if (rangeInDays <= 90) {
|
|
||||||
return 18;
|
|
||||||
} else {
|
|
||||||
return 24;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getChartData(members, startDate, endDate, isNightShiftEnabled) {
|
|
||||||
this.setChartJSDefaults();
|
|
||||||
let dateFormat = 'MMM DD, YYYY';
|
|
||||||
let monthData = [];
|
|
||||||
let dateLabel = [];
|
|
||||||
let rangeInDays = endDate.diff(startDate, 'days');
|
|
||||||
for (var m = moment(startDate); m.isSameOrBefore(endDate, 'day'); m.add(1, 'days')) {
|
|
||||||
dateLabel.push(m.format(dateFormat));
|
|
||||||
let membersTillDate = members.filter((member) => {
|
|
||||||
let isValid = moment(member.createdAtUTC).isSameOrBefore(m, 'day');
|
|
||||||
return isValid;
|
|
||||||
}).length;
|
|
||||||
monthData.push(membersTillDate);
|
|
||||||
}
|
|
||||||
let maxTicksAllowed = this.getTicksForRange(rangeInDays);
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
labels: dateLabel,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Total members',
|
|
||||||
cubicInterpolationMode: 'monotone',
|
|
||||||
data: monthData,
|
|
||||||
fill: false,
|
|
||||||
backgroundColor: 'rgba(62,176,239,.9)',
|
|
||||||
pointRadius: 0,
|
|
||||||
pointHitRadius: 10,
|
|
||||||
borderColor: 'rgba(62,176,239,.9)',
|
|
||||||
borderJoinStyle: 'round'
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
layout: {
|
|
||||||
padding: {
|
|
||||||
top: 5, // Needed otherwise the top dot is cut
|
|
||||||
right: 10,
|
|
||||||
bottom: 5,
|
|
||||||
left: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
tooltips: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'index',
|
|
||||||
displayColors: false,
|
|
||||||
backgroundColor: '#343f44',
|
|
||||||
xPadding: 7,
|
|
||||||
yPadding: 7,
|
|
||||||
cornerRadius: 5,
|
|
||||||
caretSize: 7,
|
|
||||||
caretPadding: 5,
|
|
||||||
bodyFontSize: 13,
|
|
||||||
titleFontStyle: 'normal',
|
|
||||||
titleFontColor: 'rgba(255, 255, 255, 0.7)',
|
|
||||||
titleMarginBottom: 4
|
|
||||||
},
|
|
||||||
hover: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
animationDuration: 120
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
xAxes: [{
|
|
||||||
labelString: 'Date',
|
|
||||||
gridLines: {
|
|
||||||
drawTicks: false,
|
|
||||||
color: (isNightShiftEnabled ? '#333F44' : '#E5EFF5'),
|
|
||||||
zeroLineColor: (isNightShiftEnabled ? '#333F44' : '#E5EFF5')
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
display: false,
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
padding: 6,
|
|
||||||
autoSkip: false,
|
|
||||||
maxTicksLimit: 10,
|
|
||||||
callback: function (value, index, values) {
|
|
||||||
let step = (values.length - 1) / (maxTicksAllowed);
|
|
||||||
let steps = [];
|
|
||||||
for (let i = 0; i < maxTicksAllowed; i++) {
|
|
||||||
steps.push(Math.round(i * step));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index === 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (index === (values.length - 1)) {
|
|
||||||
return 'Today';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (steps.includes(index)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
yAxes: [{
|
|
||||||
gridLines: {
|
|
||||||
drawTicks: false,
|
|
||||||
display: false,
|
|
||||||
drawBorder: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
maxTicksLimit: 5,
|
|
||||||
fontColor: '#9baeb8',
|
|
||||||
padding: 8,
|
|
||||||
precision: 0
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,35 +0,0 @@
|
|||||||
<li class="gh-list-row gh-members-list-item" ...attributes>
|
|
||||||
<LinkTo @route="member-old" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-basic">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<GhMemberAvatar @member={{@member}} @containerClass="w9 h9 mr3" />
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<LinkTo @route="member-old" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-geolocation gh-list-cellwidth-20 nowrap middarkgrey f8 {{if (not @member.name) "gh-members-geolocation-noname"}}">
|
|
||||||
{{#if @member.geolocation}}
|
|
||||||
{{#if (eq @member.geolocation.country_code "US")}}
|
|
||||||
{{@member.geolocation.region}}, US
|
|
||||||
{{else}}
|
|
||||||
{{@member.geolocation.country}}
|
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
|
||||||
<span class="midlightgrey">Unknown</span>
|
|
||||||
{{/if}}
|
|
||||||
</LinkTo>
|
|
||||||
|
|
||||||
<LinkTo @route="member-old" @model={{@member}} title="Member details" class="gh-list-data gh-members-list-subscribed-at gh-list-cellwidth-20 nowrap middarkgrey f8 {{if (not @member.name) "gh-members-subscribed-noname"}}">
|
|
||||||
{{moment-format @member.createdAtUTC "MMM DD, YYYY"}} <span class="midlightgrey">({{this.memberSince}})</span>
|
|
||||||
</LinkTo>
|
|
||||||
|
|
||||||
<LinkTo @route="member-old" @model={{@member}} title="Member details" class="gh-list-data gh-list-cellwidth-chevron gh-members-list-chevron">
|
|
||||||
<div class="flex items-center justify-end w-100 h-100">
|
|
||||||
<span class="nr2">{{svg-jar "arrow-right" class="w6 h6 fill-midgrey pa1"}}</span>
|
|
||||||
</div>
|
|
||||||
</LinkTo>
|
|
||||||
</li>
|
|
@ -1,8 +0,0 @@
|
|||||||
import Component from '@glimmer/component';
|
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
export default class GhMembersListItemComponent extends Component {
|
|
||||||
get memberSince() {
|
|
||||||
return moment(this.args.member.createdAtUTC).from(moment());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
<div class="flex flex-column items-stretch">
|
|
||||||
{{!-- <p class="">Get started with one of the following options</p> --}}
|
|
||||||
<button class="gh-btn gh-btn-green" onclick={{action "addYourself"}}>
|
|
||||||
<span>Add yourself as a member to test</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex flex-column items-stretch mt8 pt8 pb10 bt b--lightgrey-d1">
|
|
||||||
<LinkTo @route="member-old.new" class="gh-btn gh-btn-outline mb3">
|
|
||||||
<span>Manually add a member</span>
|
|
||||||
</LinkTo>
|
|
||||||
|
|
||||||
<LinkTo @route="members-old.import" class="gh-btn gh-btn-outline">
|
|
||||||
<span>Import members from CSV</span>
|
|
||||||
</LinkTo>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,38 +0,0 @@
|
|||||||
import Component from '@ember/component';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
import {task} from 'ember-concurrency';
|
|
||||||
|
|
||||||
export default Component.extend({
|
|
||||||
session: service(),
|
|
||||||
store: service(),
|
|
||||||
notifications: service(),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
addYourself() {
|
|
||||||
return this.add.perform();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
add: task(function* () {
|
|
||||||
const member = this.store.createRecord('member', {
|
|
||||||
email: this.get('session.user.email'),
|
|
||||||
name: this.get('session.user.name')
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// NOTE: has to be before member.save() is performed otherwise component is
|
|
||||||
// destroyed before notification is shown
|
|
||||||
this.notifications.showNotification('Member added'.htmlSafe(),
|
|
||||||
{
|
|
||||||
description: 'You\'ve successfully added yourself as a member.'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return yield member.save();
|
|
||||||
} catch (error) {
|
|
||||||
if (error) {
|
|
||||||
this.notifications.showAPIError(error, {key: 'member.save'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).drop()
|
|
||||||
});
|
|
@ -71,17 +71,12 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if (and this.feature.members (gh-user-can-admin this.session.user))}}
|
{{#if (and this.feature.members (gh-user-can-admin this.session.user))}}
|
||||||
<li>
|
<li>
|
||||||
<LinkTo @route="members-old" @current-when="members-old member-old" @query={{hash label=null}} data-test-nav="members">{{svg-jar "members"}}Members</LinkTo>
|
{{#if (eq this.router.currentRouteName "members.index")}}
|
||||||
|
<LinkTo @route="members" @current-when="members member" @query={{reset-query-params "members.index"}} data-test-nav="members">{{svg-jar "members"}}Members</LinkTo>
|
||||||
|
{{else}}
|
||||||
|
<LinkTo @route="members" @current-when="members member" data-test-nav="members">{{svg-jar "members"}}Members</LinkTo>
|
||||||
|
{{/if}}
|
||||||
</li>
|
</li>
|
||||||
{{#if this.config.enableDeveloperExperiments}}
|
|
||||||
<li>
|
|
||||||
{{#if (eq this.router.currentRouteName "members.index")}}
|
|
||||||
<LinkTo @route="members" @current-when="members member" @query={{reset-query-params "members.index"}} data-test-nav="members-old">{{svg-jar "members"}}Members (dev)</LinkTo>
|
|
||||||
{{else}}
|
|
||||||
<LinkTo @route="members" @current-when="members member" data-test-nav="members-old">{{svg-jar "members"}}Members (dev)</LinkTo>
|
|
||||||
{{/if}}
|
|
||||||
</li>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<li><LinkTo @route="staff" data-test-nav="staff">{{svg-jar "staff"}}Staff</LinkTo></li>
|
<li><LinkTo @route="staff" data-test-nav="staff">{{svg-jar "staff"}}Staff</LinkTo></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -1,139 +0,0 @@
|
|||||||
import Controller from '@ember/controller';
|
|
||||||
import EmberObject from '@ember/object';
|
|
||||||
import boundOneWay from 'ghost-admin/utils/bound-one-way';
|
|
||||||
import moment from 'moment';
|
|
||||||
import {alias} from '@ember/object/computed';
|
|
||||||
import {computed, defineProperty} from '@ember/object';
|
|
||||||
import {inject as controller} from '@ember/controller';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
import {task} from 'ember-concurrency';
|
|
||||||
|
|
||||||
const SCRATCH_PROPS = ['name', 'email', 'note'];
|
|
||||||
|
|
||||||
export default Controller.extend({
|
|
||||||
members: controller('members-old'),
|
|
||||||
session: service(),
|
|
||||||
dropdown: service(),
|
|
||||||
notifications: service(),
|
|
||||||
router: service(),
|
|
||||||
store: service(),
|
|
||||||
|
|
||||||
showImpersonateMemberModal: false,
|
|
||||||
|
|
||||||
member: alias('model'),
|
|
||||||
|
|
||||||
scratchMember: computed('member', function () {
|
|
||||||
let scratchMember = EmberObject.create({member: this.member});
|
|
||||||
SCRATCH_PROPS.forEach(prop => defineProperty(scratchMember, prop, boundOneWay(`member.${prop}`)));
|
|
||||||
return scratchMember;
|
|
||||||
}),
|
|
||||||
|
|
||||||
subscribedAt: computed('member.createdAtUTC', function () {
|
|
||||||
let memberSince = moment(this.member.createdAtUTC).from(moment());
|
|
||||||
let createdDate = moment(this.member.createdAtUTC).format('MMM DD, YYYY');
|
|
||||||
return `${createdDate} (${memberSince})`;
|
|
||||||
}),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
setProperty(propKey, value) {
|
|
||||||
this._saveMemberProperty(propKey, value);
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleDeleteMemberModal() {
|
|
||||||
this.toggleProperty('showDeleteMemberModal');
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleImpersonateMemberModal() {
|
|
||||||
this.toggleProperty('showImpersonateMemberModal');
|
|
||||||
},
|
|
||||||
|
|
||||||
save() {
|
|
||||||
return this.save.perform();
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteMember() {
|
|
||||||
return this.member.destroyRecord().then(() => {
|
|
||||||
return this.transitionToRoute('members-old');
|
|
||||||
}, (error) => {
|
|
||||||
return this.notifications.showAPIError(error, {key: 'member.delete'});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleUnsavedChangesModal(transition) {
|
|
||||||
let leaveTransition = this.leaveScreenTransition;
|
|
||||||
|
|
||||||
if (!transition && this.showUnsavedChangesModal) {
|
|
||||||
this.set('leaveScreenTransition', null);
|
|
||||||
this.set('showUnsavedChangesModal', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
|
|
||||||
this.set('leaveScreenTransition', transition);
|
|
||||||
|
|
||||||
// if a save is running, wait for it to finish then transition
|
|
||||||
if (this.save.isRunning) {
|
|
||||||
return this.save.last.then(() => {
|
|
||||||
transition.retry();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// we genuinely have unsaved data, show the modal
|
|
||||||
this.set('showUnsavedChangesModal', true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
leaveScreen() {
|
|
||||||
this.member.rollbackAttributes();
|
|
||||||
return this.leaveScreenTransition.retry();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
save: task(function* () {
|
|
||||||
let {member, scratchMember} = this;
|
|
||||||
|
|
||||||
// if Cmd+S is pressed before the field loses focus make sure we're
|
|
||||||
// saving the intended property values
|
|
||||||
let scratchProps = scratchMember.getProperties(SCRATCH_PROPS);
|
|
||||||
member.setProperties(scratchProps);
|
|
||||||
|
|
||||||
try {
|
|
||||||
yield member.save();
|
|
||||||
member.updateLabels();
|
|
||||||
// replace 'member.new' route with 'member' route
|
|
||||||
this.replaceRoute('member-old', member);
|
|
||||||
|
|
||||||
return member;
|
|
||||||
} catch (error) {
|
|
||||||
if (error) {
|
|
||||||
this.notifications.showAPIError(error, {key: 'member.save'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).drop(),
|
|
||||||
|
|
||||||
fetchMember: task(function* (memberId) {
|
|
||||||
this.set('isLoading', true);
|
|
||||||
|
|
||||||
let member = yield this.store.findRecord('member', memberId, {
|
|
||||||
reload: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.set('member', member);
|
|
||||||
this.set('isLoading', false);
|
|
||||||
}),
|
|
||||||
|
|
||||||
_saveMemberProperty(propKey, newValue) {
|
|
||||||
let currentValue = this.member.get(propKey);
|
|
||||||
|
|
||||||
if (newValue) {
|
|
||||||
newValue = newValue.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// avoid modifying empty values and triggering inadvertant unsaved changes modals
|
|
||||||
if (newValue !== false && !newValue && !currentValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.member.set(propKey, newValue);
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,182 +0,0 @@
|
|||||||
import Controller from '@ember/controller';
|
|
||||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
||||||
import moment from 'moment';
|
|
||||||
import {computed} from '@ember/object';
|
|
||||||
import {get} from '@ember/object';
|
|
||||||
import {pluralize} from 'ember-inflector';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
import {task} from 'ember-concurrency';
|
|
||||||
|
|
||||||
/* eslint-disable ghost/ember/alias-model-in-controller */
|
|
||||||
export default Controller.extend({
|
|
||||||
store: service(),
|
|
||||||
|
|
||||||
queryParams: ['label'],
|
|
||||||
|
|
||||||
label: null,
|
|
||||||
members: null,
|
|
||||||
searchText: '',
|
|
||||||
modalLabel: null,
|
|
||||||
showLabelModal: false,
|
|
||||||
|
|
||||||
_hasLoadedLabels: false,
|
|
||||||
_availableLabels: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super(...arguments);
|
|
||||||
this.set('members', this.store.peekAll('member'));
|
|
||||||
this._availableLabels = this.store.peekAll('label');
|
|
||||||
},
|
|
||||||
|
|
||||||
showLoader: computed('filteredMembers.length', 'fetchMembers.isRunning', function () {
|
|
||||||
return (!this.get('filteredMembers.length') && this.get('fetchMembers.isRunning'));
|
|
||||||
}),
|
|
||||||
|
|
||||||
listHeader: computed('selectedLabel', 'searchText', function () {
|
|
||||||
let {searchText, selectedLabel, filteredMembers} = this;
|
|
||||||
if (searchText) {
|
|
||||||
return 'Search result';
|
|
||||||
}
|
|
||||||
if (this.fetchMembers.lastSuccessful) {
|
|
||||||
let count = pluralize(filteredMembers.length, 'member');
|
|
||||||
if (selectedLabel && selectedLabel.slug) {
|
|
||||||
if (filteredMembers.length > 1) {
|
|
||||||
return `${count} match current filter`;
|
|
||||||
} else {
|
|
||||||
return `${count} matches current filter`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 'Loading...';
|
|
||||||
}),
|
|
||||||
|
|
||||||
showingAll: computed('label', 'searchText', function () {
|
|
||||||
let {searchText, label} = this;
|
|
||||||
|
|
||||||
return !searchText && !label;
|
|
||||||
}),
|
|
||||||
|
|
||||||
availableLabels: computed('_availableLabels.@each.isNew', function () {
|
|
||||||
let labels = this._availableLabels
|
|
||||||
.filter(label => !label.get('isNew'))
|
|
||||||
.filter(label => label.get('id') !== null)
|
|
||||||
.sort((labelA, labelB) => labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}));
|
|
||||||
let options = labels.toArray();
|
|
||||||
|
|
||||||
options.unshiftObject({name: 'All labels', slug: null});
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}),
|
|
||||||
|
|
||||||
selectedLabel: computed('label', 'availableLabels', function () {
|
|
||||||
let label = this.get('label');
|
|
||||||
let labels = this.get('availableLabels');
|
|
||||||
|
|
||||||
return labels.findBy('slug', label);
|
|
||||||
}),
|
|
||||||
|
|
||||||
labelModalData: computed('modalLabel', 'availableLabels', function () {
|
|
||||||
let label = this.get('modalLabel');
|
|
||||||
let labels = this.get('availableLabels');
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
labels
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
filteredMembers: computed('members.@each.{name,email}', 'searchText', 'label', function () {
|
|
||||||
let {members, searchText, label} = this;
|
|
||||||
searchText = searchText.toLowerCase();
|
|
||||||
|
|
||||||
let filtered = members.filter((member) => {
|
|
||||||
if (!searchText) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {name, email} = member;
|
|
||||||
return (name && name.toLowerCase().indexOf(searchText) >= 0)
|
|
||||||
|| (email && email.toLowerCase().indexOf(searchText) >= 0);
|
|
||||||
}).filter((member) => {
|
|
||||||
if (!label) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return !!member.labels.find((_label) => {
|
|
||||||
return _label.slug === label;
|
|
||||||
});
|
|
||||||
}).sort((a, b) => {
|
|
||||||
return b.get('createdAtUTC').valueOf() - a.get('createdAtUTC').valueOf();
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
exportData() {
|
|
||||||
let exportUrl = ghostPaths().url.api('members/csv');
|
|
||||||
let downloadURL = `${exportUrl}?limit=all`;
|
|
||||||
let iframe = document.getElementById('iframeDownload');
|
|
||||||
|
|
||||||
if (!iframe) {
|
|
||||||
iframe = document.createElement('iframe');
|
|
||||||
iframe.id = 'iframeDownload';
|
|
||||||
iframe.style.display = 'none';
|
|
||||||
document.body.append(iframe);
|
|
||||||
}
|
|
||||||
iframe.setAttribute('src', downloadURL);
|
|
||||||
},
|
|
||||||
changeLabel(label, e) {
|
|
||||||
if (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
this.set('label', get(label, 'slug'));
|
|
||||||
},
|
|
||||||
addLabel(e) {
|
|
||||||
if (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
const newLabel = this.store.createRecord('label');
|
|
||||||
this.set('modalLabel', newLabel);
|
|
||||||
this.toggleProperty('showLabelModal');
|
|
||||||
},
|
|
||||||
editLabel(label, e) {
|
|
||||||
if (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
let labels = this.get('availableLabels');
|
|
||||||
|
|
||||||
let modalLabel = labels.findBy('slug', label);
|
|
||||||
this.set('modalLabel', modalLabel);
|
|
||||||
this.toggleProperty('showLabelModal');
|
|
||||||
},
|
|
||||||
toggleLabelModal() {
|
|
||||||
this.toggleProperty('showLabelModal');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchMembers: task(function* () {
|
|
||||||
let newFetchDate = new Date();
|
|
||||||
|
|
||||||
if (this._hasFetchedAll) {
|
|
||||||
// fetch any records modified since last fetch
|
|
||||||
yield this.store.query('member', {
|
|
||||||
limit: 'all',
|
|
||||||
filter: `updated_at:>='${moment.utc(this._lastFetchDate).format('YYYY-MM-DD HH:mm:ss')}'`,
|
|
||||||
order: 'created_at desc'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// fetch all records
|
|
||||||
yield this.store.query('member', {
|
|
||||||
limit: 'all',
|
|
||||||
order: 'created_at desc'
|
|
||||||
});
|
|
||||||
this._hasFetchedAll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastFetchDate = newFetchDate;
|
|
||||||
})
|
|
||||||
});
|
|
@ -1,19 +0,0 @@
|
|||||||
import Controller from '@ember/controller';
|
|
||||||
import {inject as controller} from '@ember/controller';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
|
|
||||||
/* eslint-disable ghost/ember/alias-model-in-controller */
|
|
||||||
export default Controller.extend({
|
|
||||||
members: controller('members-old'),
|
|
||||||
router: service(),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
fetchNewMembers() {
|
|
||||||
this.members.fetchMembers.perform();
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.router.transitionTo('members-old');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@ -61,17 +61,11 @@ Router.map(function () {
|
|||||||
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
|
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
|
||||||
this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'});
|
this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'});
|
||||||
|
|
||||||
this.route('members', {path: '/members-dev'}, function () {
|
this.route('members', function () {
|
||||||
this.route('import');
|
this.route('import');
|
||||||
});
|
});
|
||||||
this.route('member.new', {path: '/members-dev/new'});
|
this.route('member.new', {path: '/members/new'});
|
||||||
this.route('member', {path: '/members-dev/:member_id'});
|
this.route('member', {path: '/members/:member_id'});
|
||||||
|
|
||||||
this.route('members-old', {path: '/members'}, function () {
|
|
||||||
this.route('import');
|
|
||||||
});
|
|
||||||
this.route('member-old.new', {path: '/members/new'});
|
|
||||||
this.route('member-old', {path: '/members/:member_id'});
|
|
||||||
|
|
||||||
this.route('error404', {path: '/*path'});
|
this.route('error404', {path: '/*path'});
|
||||||
});
|
});
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
|
||||||
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
|
|
||||||
export default AuthenticatedRoute.extend(CurrentUserSettings, {
|
|
||||||
router: service(),
|
|
||||||
|
|
||||||
_requiresBackgroundRefresh: true,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super(...arguments);
|
|
||||||
this.router.on('routeWillChange', (transition) => {
|
|
||||||
this.showUnsavedChangesModal(transition);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeModel() {
|
|
||||||
this._super(...arguments);
|
|
||||||
return this.get('session.user')
|
|
||||||
.then(this.transitionAuthor());
|
|
||||||
},
|
|
||||||
|
|
||||||
model(params) {
|
|
||||||
this._requiresBackgroundRefresh = false;
|
|
||||||
|
|
||||||
if (params.member_id) {
|
|
||||||
return this.store.findRecord('member', params.member_id, {reload: true});
|
|
||||||
} else {
|
|
||||||
return this.store.createRecord('member');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setupController(controller, member) {
|
|
||||||
this._super(...arguments);
|
|
||||||
if (this._requiresBackgroundRefresh) {
|
|
||||||
controller.fetchMember.perform(member.get('id'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deactivate() {
|
|
||||||
this._super(...arguments);
|
|
||||||
|
|
||||||
// clean up newly created records and revert unsaved changes to existing
|
|
||||||
this.controller.member.rollbackAttributes();
|
|
||||||
|
|
||||||
this._requiresBackgroundRefresh = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
save() {
|
|
||||||
this.controller.send('save');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
titleToken() {
|
|
||||||
return this.controller.get('member.name');
|
|
||||||
},
|
|
||||||
|
|
||||||
showUnsavedChangesModal(transition) {
|
|
||||||
if (transition.from && transition.from.name === this.routeName && transition.targetName) {
|
|
||||||
let {controller} = this;
|
|
||||||
|
|
||||||
// member.changedAttributes is always true for new members but number of changed attrs is reliable
|
|
||||||
let isChanged = Object.keys(controller.member.changedAttributes()).length > 0;
|
|
||||||
|
|
||||||
if (!controller.member.isDeleted && isChanged) {
|
|
||||||
transition.abort();
|
|
||||||
controller.send('toggleUnsavedChangesModal', transition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,6 +0,0 @@
|
|||||||
import MemberRoute from '../member-old';
|
|
||||||
|
|
||||||
export default MemberRoute.extend({
|
|
||||||
controllerName: 'member-old',
|
|
||||||
templateName: 'member-old'
|
|
||||||
});
|
|
@ -1,45 +0,0 @@
|
|||||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
|
||||||
import {inject as service} from '@ember/service';
|
|
||||||
|
|
||||||
export default AuthenticatedRoute.extend({
|
|
||||||
config: service(),
|
|
||||||
|
|
||||||
queryParams: {
|
|
||||||
label: {refreshModel: true}
|
|
||||||
},
|
|
||||||
|
|
||||||
// redirect to posts screen if:
|
|
||||||
// - TODO: members is disabled?
|
|
||||||
// - logged in user isn't owner/admin
|
|
||||||
beforeModel() {
|
|
||||||
this._super(...arguments);
|
|
||||||
|
|
||||||
return this.session.user.then((user) => {
|
|
||||||
if (!user.isOwnerOrAdmin) {
|
|
||||||
return this.transitionTo('home');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// trigger a background load of labels for filter dropdown
|
|
||||||
setupController(controller) {
|
|
||||||
this._super(...arguments);
|
|
||||||
controller.fetchMembers.perform();
|
|
||||||
if (!controller._hasLoadedLabels) {
|
|
||||||
this.store.query('label', {limit: 'all'}).then(() => {
|
|
||||||
controller._hasLoadedLabels = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deactivate() {
|
|
||||||
this._super(...arguments);
|
|
||||||
this.controller.modalLabel && this.controller.modalLabel.rollbackAttributes();
|
|
||||||
},
|
|
||||||
buildRouteInfoMetadata() {
|
|
||||||
return {
|
|
||||||
titleToken: 'Members'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
@ -1,4 +0,0 @@
|
|||||||
import Route from '@ember/routing/route';
|
|
||||||
|
|
||||||
export default Route.extend({
|
|
||||||
});
|
|
@ -1,109 +0,0 @@
|
|||||||
<section class="gh-canvas">
|
|
||||||
<GhCanvasHeader class="gh-canvas-header">
|
|
||||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
|
||||||
<LinkTo @route="members-old" data-test-link="members-back">Members</LinkTo>
|
|
||||||
<span>{{svg-jar "arrow-right"}}</span>
|
|
||||||
{{#if this.member.isNew}}
|
|
||||||
New member
|
|
||||||
{{else}}
|
|
||||||
{{or this.member.name this.member.email}}
|
|
||||||
{{/if}}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<section class="view-actions">
|
|
||||||
{{#if this.session.user.isOwner}}
|
|
||||||
{{#unless this.member.isNew}}
|
|
||||||
<button
|
|
||||||
class="gh-btn gh-btn-white gh-btn-icon mr2"
|
|
||||||
{{on "click" (action "toggleImpersonateMemberModal")}}>
|
|
||||||
<span>Impersonate</span>
|
|
||||||
</button>
|
|
||||||
{{/unless}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<GhTaskButton @class="gh-btn gh-btn-blue gh-btn-icon" @type="button" @task={{this.save}} @autoReset={{true}} @data-test-button="save" />
|
|
||||||
</section>
|
|
||||||
</GhCanvasHeader>
|
|
||||||
|
|
||||||
<form class="mb10 member-basic-info-form">
|
|
||||||
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
|
|
||||||
{{#if (or this.member.name this.member.email)}}
|
|
||||||
<GhMemberAvatar
|
|
||||||
@member={{this.member}}
|
|
||||||
@sizeClass={{if this.member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}}
|
|
||||||
@containerClass="w20 h20 mr4 gh-member-detail-avatar"
|
|
||||||
/>
|
|
||||||
{{else}}
|
|
||||||
<div class="flex items-center justify-center br-100 w18 h18 mr4 gh-new-member-avatar">
|
|
||||||
<span class="gh-member-avatar-label f-subheadline fw4 lh-zero tracked-1">N</span>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
<div>
|
|
||||||
<h3 class="f2 fw6 ma0 pa0">
|
|
||||||
{{or this.member.name this.member.email}}
|
|
||||||
</h3>
|
|
||||||
<p class="f7 pa0 ma0 midlightgrey-d1">
|
|
||||||
{{#if (and this.member.name this.member.email)}}
|
|
||||||
<span class="darkgrey fw5">{{this.member.email}}</span>
|
|
||||||
{{/if}}
|
|
||||||
</p>
|
|
||||||
{{#unless this.member.isNew}}
|
|
||||||
<p class="f7 pa0 ma0 midgrey-d1 {{if this.member.name "nudge-bottom--2"}}">
|
|
||||||
{{#if this.member.geolocation}}
|
|
||||||
{{#if (eq this.member.geolocation.country_code "US")}}
|
|
||||||
{{this.member.geolocation.region}}, US
|
|
||||||
{{else}}
|
|
||||||
{{this.member.geolocation.country}}
|
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
|
||||||
Unknown location
|
|
||||||
{{/if}}
|
|
||||||
– Created on {{this.subscribedAt}}
|
|
||||||
</p>
|
|
||||||
{{/unless}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GhMemberSettingsForm
|
|
||||||
@member={{this.member}}
|
|
||||||
@scratchMember={{this.scratchMember}}
|
|
||||||
@setProperty={{action "setProperty"}}
|
|
||||||
@isLoading={{this.isLoading}} />
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{{#unless this.member.isNew}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="gh-btn gh-btn-red gh-btn-icon mt3"
|
|
||||||
{{on "click" (action "toggleDeleteMemberModal")}}
|
|
||||||
data-test-button="delete-member"
|
|
||||||
>
|
|
||||||
<span>Delete member</span>
|
|
||||||
</button>
|
|
||||||
{{/unless}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{#if this.showUnsavedChangesModal}}
|
|
||||||
<GhFullscreenModal
|
|
||||||
@modal="leave-settings"
|
|
||||||
@confirm={{action "leaveScreen"}}
|
|
||||||
@close={{action "toggleUnsavedChangesModal"}}
|
|
||||||
@modifier="action wide" />
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if this.showDeleteMemberModal}}
|
|
||||||
<GhFullscreenModal
|
|
||||||
@modal="delete-member"
|
|
||||||
@model={{this.member}}
|
|
||||||
@confirm={{action "deleteMember"}}
|
|
||||||
@close={{action "toggleDeleteMemberModal"}}
|
|
||||||
@modifier="action wide" />
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if this.showImpersonateMemberModal}}
|
|
||||||
<GhFullscreenModal
|
|
||||||
@modal="impersonate-member"
|
|
||||||
@model={{this.member}}
|
|
||||||
@close={{action "toggleImpersonateMemberModal"}}
|
|
||||||
@modifier="action wide" />
|
|
||||||
{{/if}}
|
|
@ -1,99 +0,0 @@
|
|||||||
<section class="gh-canvas">
|
|
||||||
<GhCanvasHeader class="gh-canvas-header members-header">
|
|
||||||
<h2 class="gh-canvas-title" data-test-screen-title>Members</h2>
|
|
||||||
<section class="view-actions">
|
|
||||||
<GhMembersContentfilter
|
|
||||||
@selectedLabel={{this.selectedLabel}}
|
|
||||||
@availableLabels={{this.availableLabels}}
|
|
||||||
@onLabelChange={{action "changeLabel"}}
|
|
||||||
@onLabelAdd={{action "addLabel"}}
|
|
||||||
@onLabelEdit={{action "editLabel"}}
|
|
||||||
/>
|
|
||||||
<div class="relative gh-members-header-search">
|
|
||||||
{{svg-jar "search" class="gh-input-search-icon"}}
|
|
||||||
<GhTextInput
|
|
||||||
placeholder="Search members..."
|
|
||||||
@value={{this.searchText}}
|
|
||||||
@input={{action (mut this.searchText) value="target.value"}}
|
|
||||||
class="gh-members-list-searchfield {{if this.searchText "active"}}" />
|
|
||||||
</div>
|
|
||||||
<span class="dropdown">
|
|
||||||
<GhDropdownButton @dropdownName="members-actions-menu"
|
|
||||||
@classNames="gh-btn gh-btn-white gh-btn-icon only-has-icon gh-actions-cog" @title="Members Actions"
|
|
||||||
@data-test-user-actions="true">
|
|
||||||
<span>
|
|
||||||
{{svg-jar "settings"}}
|
|
||||||
<span class="hidden">Actions</span>
|
|
||||||
</span>
|
|
||||||
</GhDropdownButton>
|
|
||||||
<GhDropdown @name="members-actions-menu" @tagName="ul"
|
|
||||||
@classNames="gh-member-actions-menu dropdown-menu dropdown-triangle-top-right">
|
|
||||||
<li>
|
|
||||||
<LinkTo @route="members-old.import" class="mr2" data-test-link="import-csv">
|
|
||||||
<span>Import members</span>
|
|
||||||
</LinkTo>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" {{action 'exportData'}} class="mr2">
|
|
||||||
<span>Export all members</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</GhDropdown>
|
|
||||||
</span>
|
|
||||||
<LinkTo @route="member-old.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"><span>New member</span></LinkTo>
|
|
||||||
</section>
|
|
||||||
</GhCanvasHeader>
|
|
||||||
{{#if this.showLoader}}
|
|
||||||
<div class="gh-content">
|
|
||||||
<GhLoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<section class="view-container">
|
|
||||||
{{#if this.filteredMembers}}
|
|
||||||
{{#if this.showingAll}}
|
|
||||||
<section>
|
|
||||||
<GhMembersChartOld @members={{members}} />
|
|
||||||
</section>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
<section class="content-list">
|
|
||||||
<ol class="members-list gh-list {{unless this.filteredMembers "no-posts"}}">
|
|
||||||
{{#if this.filteredMembers}}
|
|
||||||
<li class="gh-list-row header">
|
|
||||||
<div class="gh-list-header">{{listHeader}}</div>
|
|
||||||
<div class="gh-list-header gh-list-cellwidth-20 nowrap">Location</div>
|
|
||||||
<div class="gh-list-header gh-list-cellwidth-20 nowrap">Created</div>
|
|
||||||
<div class="gh-list-header gh-list-cellwidth-chevron"></div>
|
|
||||||
</li>
|
|
||||||
<VerticalCollection @items={{this.filteredMembers}} @key="id" @containerSelector=".gh-main" @estimateHeight={{69}} @staticHeight={{true}} @bufferSize={{20}} as |member|>
|
|
||||||
<GhMembersListItemOld @member={{member}} @data-test-member-id={{member.id}} />
|
|
||||||
</VerticalCollection>
|
|
||||||
{{else}}
|
|
||||||
<li class="no-posts-box">
|
|
||||||
<div class="no-posts">
|
|
||||||
{{svg-jar "members-placeholder" class="gh-members-placeholder"}}
|
|
||||||
{{#if this.showingAll}}
|
|
||||||
<h3>No members yet</h3>
|
|
||||||
<GhMembersNoMembers />
|
|
||||||
{{else}}
|
|
||||||
<h3>No members match the current filter</h3>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{{/if}}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
{{/if}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{outlet}}
|
|
||||||
|
|
||||||
{{#if this.showLabelModal}}
|
|
||||||
<GhFullscreenModal
|
|
||||||
@modal="members-label-form"
|
|
||||||
@model={{this.labelModalData}}
|
|
||||||
@close={{action "toggleLabelModal"}}
|
|
||||||
@modifier="action wide"
|
|
||||||
/>
|
|
||||||
{{/if}}
|
|
@ -1,4 +0,0 @@
|
|||||||
<GhFullscreenModal @modal="import-members"
|
|
||||||
@close={{action "close"}}
|
|
||||||
@confirm={{action "fetchNewMembers"}}
|
|
||||||
@modifier="action wide" />
|
|
@ -53,7 +53,7 @@ describe('Acceptance: Members', function () {
|
|||||||
await click('[data-test-nav="members"]');
|
await click('[data-test-nav="members"]');
|
||||||
|
|
||||||
expect(currentURL()).to.equal('/members');
|
expect(currentURL()).to.equal('/members');
|
||||||
expect(currentRouteName()).to.equal('members-old.index');
|
expect(currentRouteName()).to.equal('members.index');
|
||||||
expect(find('[data-test-screen-title]')).to.have.text('Members');
|
expect(find('[data-test-screen-title]')).to.have.text('Members');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user