mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 19:48:50 +03:00
Duplicated members screens for development experiments
no issue - members screens will be undergoing heavy development to change how underlying data loading works - duplicated all related screens and components so that development can occur behind the `enableDeveloperExperiments` flag without breaking the existing screens - added "Members (dev)" link to the duplicate screens in nav bar when `enableDeveloperExperiments` is on
This commit is contained in:
parent
6ab565e14e
commit
598a327d6e
56
ghost/admin/app/components/gh-members-chart-dev.hbs
Normal file
56
ghost/admin/app/components/gh-members-chart-dev.hbs
Normal file
@ -0,0 +1,56 @@
|
||||
<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">
|
||||
<PowerSelect
|
||||
@selected={{this.selectedRange}}
|
||||
@options={{this.availableRange}}
|
||||
@searchEnabled={{false}}
|
||||
@onChange={{action "changeDateRange"}}
|
||||
@tagName="div"
|
||||
@classNames="gh-contentfilter-menu gh-contentfilter-type"
|
||||
@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>
|
249
ghost/admin/app/components/gh-members-chart-dev.js
Normal file
249
ghost/admin/app/components/gh-members-chart-dev.js
Normal file
@ -0,0 +1,249 @@
|
||||
/* 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
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
35
ghost/admin/app/components/gh-members-list-item-dev.hbs
Normal file
35
ghost/admin/app/components/gh-members-list-item-dev.hbs
Normal file
@ -0,0 +1,35 @@
|
||||
<li class="gh-list-row gh-members-list-item" ...attributes>
|
||||
<LinkTo @route="member-dev" @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-dev" @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-dev" @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-dev" @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>
|
8
ghost/admin/app/components/gh-members-list-item-dev.js
Normal file
8
ghost/admin/app/components/gh-members-list-item-dev.js
Normal file
@ -0,0 +1,8 @@
|
||||
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());
|
||||
}
|
||||
}
|
16
ghost/admin/app/components/gh-members-no-members-dev.hbs
Normal file
16
ghost/admin/app/components/gh-members-no-members-dev.hbs
Normal file
@ -0,0 +1,16 @@
|
||||
<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-dev.new" class="gh-btn gh-btn-outline mb3">
|
||||
<span>Manually add a member</span>
|
||||
</LinkTo>
|
||||
|
||||
<LinkTo @route="members-dev.import" class="gh-btn gh-btn-outline">
|
||||
<span>Import members from CSV</span>
|
||||
</LinkTo>
|
||||
</div>
|
||||
</div>
|
38
ghost/admin/app/components/gh-members-no-members-dev.js
Normal file
38
ghost/admin/app/components/gh-members-no-members-dev.js
Normal file
@ -0,0 +1,38 @@
|
||||
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()
|
||||
});
|
@ -79,6 +79,11 @@
|
||||
<li>
|
||||
<LinkTo @route="members" @current-when="members member" @query={{hash label=null}} data-test-nav="members">{{svg-jar "members"}}Members</LinkTo>
|
||||
</li>
|
||||
{{#if this.config.enableDeveloperExperiments}}
|
||||
<li>
|
||||
<LinkTo @route="members-dev" @current-when="members-dev member-dev" @query={{hash label=null}} data-test-nav="members-dev">{{svg-jar "members"}}Members (dev)</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<li><LinkTo @route="staff" data-test-nav="staff">{{svg-jar "staff"}}Staff</LinkTo></li>
|
||||
</ul>
|
||||
|
139
ghost/admin/app/controllers/member-dev.js
Normal file
139
ghost/admin/app/controllers/member-dev.js
Normal file
@ -0,0 +1,139 @@
|
||||
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(),
|
||||
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-dev');
|
||||
}, (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', 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);
|
||||
}
|
||||
});
|
182
ghost/admin/app/controllers/members-dev.js
Normal file
182
ghost/admin/app/controllers/members-dev.js
Normal file
@ -0,0 +1,182 @@
|
||||
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;
|
||||
})
|
||||
});
|
19
ghost/admin/app/controllers/members-dev/import.js
Normal file
19
ghost/admin/app/controllers/members-dev/import.js
Normal file
@ -0,0 +1,19 @@
|
||||
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(),
|
||||
router: service(),
|
||||
|
||||
actions: {
|
||||
fetchNewMembers() {
|
||||
this.members.fetchMembers.perform();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.router.transitionTo('members-dev');
|
||||
}
|
||||
}
|
||||
});
|
@ -64,6 +64,12 @@ Router.map(function () {
|
||||
this.route('member.new', {path: '/members/new'});
|
||||
this.route('member', {path: '/members/:member_id'});
|
||||
|
||||
this.route('members-dev', function () {
|
||||
this.route('import');
|
||||
});
|
||||
this.route('member-dev.new', {path: '/members-dev/new'});
|
||||
this.route('member-dev', {path: '/members-dev/:member_id'});
|
||||
|
||||
this.route('error404', {path: '/*path'});
|
||||
});
|
||||
|
||||
|
73
ghost/admin/app/routes/member-dev.js
Normal file
73
ghost/admin/app/routes/member-dev.js
Normal file
@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
6
ghost/admin/app/routes/member-dev/new.js
Normal file
6
ghost/admin/app/routes/member-dev/new.js
Normal file
@ -0,0 +1,6 @@
|
||||
import MemberRoute from '../member';
|
||||
|
||||
export default MemberRoute.extend({
|
||||
controllerName: 'member-dev',
|
||||
templateName: 'member-dev'
|
||||
});
|
45
ghost/admin/app/routes/members-dev.js
Normal file
45
ghost/admin/app/routes/members-dev.js
Normal file
@ -0,0 +1,45 @@
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
});
|
4
ghost/admin/app/routes/members-dev/import.js
Normal file
4
ghost/admin/app/routes/members-dev/import.js
Normal file
@ -0,0 +1,4 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
});
|
109
ghost/admin/app/templates/member-dev.hbs
Normal file
109
ghost/admin/app/templates/member-dev.hbs
Normal file
@ -0,0 +1,109 @@
|
||||
<section class="gh-canvas">
|
||||
<GhCanvasHeader class="gh-canvas-header">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
<LinkTo @route="members-dev" 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}}
|
99
ghost/admin/app/templates/members-dev.hbs
Normal file
99
ghost/admin/app/templates/members-dev.hbs
Normal file
@ -0,0 +1,99 @@
|
||||
<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-dev.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-dev.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>
|
||||
<GhMembersChartDev @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|>
|
||||
<GhMembersListItemDev @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}}
|
4
ghost/admin/app/templates/members-dev/import.hbs
Normal file
4
ghost/admin/app/templates/members-dev/import.hbs
Normal file
@ -0,0 +1,4 @@
|
||||
<GhFullscreenModal @modal="import-members"
|
||||
@close={{action "close"}}
|
||||
@confirm={{action "fetchNewMembers"}}
|
||||
@modifier="action wide" />
|
Loading…
Reference in New Issue
Block a user