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:
Kevin Ansfield 2020-05-19 16:18:14 +01:00
parent 6ab565e14e
commit 598a327d6e
18 changed files with 1093 additions and 0 deletions

View 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>

View 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
}
}]
}
}
};
}
});

View 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>

View 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());
}
}

View 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>

View 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()
});

View File

@ -79,6 +79,11 @@
<li> <li>
<LinkTo @route="members" @current-when="members member" @query={{hash label=null}} data-test-nav="members">{{svg-jar "members"}}Members</LinkTo> <LinkTo @route="members" @current-when="members member" @query={{hash label=null}} data-test-nav="members">{{svg-jar "members"}}Members</LinkTo>
</li> </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}} {{/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>

View 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);
}
});

View 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;
})
});

View 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');
}
}
});

View File

@ -64,6 +64,12 @@ Router.map(function () {
this.route('member.new', {path: '/members/new'}); this.route('member.new', {path: '/members/new'});
this.route('member', {path: '/members/:member_id'}); 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'}); this.route('error404', {path: '/*path'});
}); });

View 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;
}
}
}
});

View File

@ -0,0 +1,6 @@
import MemberRoute from '../member';
export default MemberRoute.extend({
controllerName: 'member-dev',
templateName: 'member-dev'
});

View 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'
};
}
});

View File

@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default Route.extend({
});

View 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}}

View 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}}

View File

@ -0,0 +1,4 @@
<GhFullscreenModal @modal="import-members"
@close={{action "close"}}
@confirm={{action "fetchNewMembers"}}
@modifier="action wide" />