Added member "add" screen (#1411)

no issue

- We have a need to create a member manually, this changeset solves this problem.
- Added new member button to the member's screen
- Needed to be able to perform add member action
- Fixed inconsistent `createAt` naming. All models use consistent `createdAtUTC`, fixed it up so that members model follows the same pattern. If we want to change this pattern should probably happen for all models at once
- Fixed member avatar when creating a new member. If the values are completely empty the screen ends up being filled with empty space. Added some dummy initials which are recalculated once the member enters the name or an email
- Refactored DS naming for consistency. Nowhere else in the codebase 'DS' name is ever used, made this consistent
- Added missing validations in members form
- Simplified if conditions in the member list template. When using the if/esle statements unnecessary new-line symbols were inserted which made it hard to test. Also by using computed property view is much cleaner
- Updated member's model default value for `subscribed` to "true". It is turned on by default in the model layer  on the backend (ref: https://github.com/TryGhost/Ghost/blob/3.1.0/core/server/data/schema/schema.js#L330), this behavior is intended and should be the same on the frontend
This commit is contained in:
Naz Gargol 2019-11-28 18:30:21 +07:00 committed by GitHub
parent c376fc016a
commit c41c184762
21 changed files with 474 additions and 57 deletions

View File

@ -21,7 +21,7 @@ export default Component.extend({
}),
backgroundStyle: computed('member.{name,email}', function () {
let name = this.member.name || this.member.email;
let name = this.member.name || this.member.email || 'NM';
if (name) {
let color = stringToHslColor(name, 55, 55);
return htmlSafe(`background-color: ${color}`);
@ -37,6 +37,8 @@ export default Component.extend({
let intials = names.length > 1 ? [names[0][0], names[names.length - 1][0]] : [names[0][0]];
return intials.join('').toUpperCase();
}
return '';
// New Member initials
return 'NM';
})
});

View File

@ -2,6 +2,7 @@ import Component from '@ember/component';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import moment from 'moment';
import {computed} from '@ember/object';
import {reads} from '@ember/object/computed';
import {inject as service} from '@ember/service';
export default Component.extend({
@ -15,9 +16,12 @@ export default Component.extend({
setProperty: () => {},
showDeleteTagModal: () => {},
canEditEmail: reads('member.isNew'),
scratchName: boundOneWay('member.name'),
scratchEmail: boundOneWay('member.email'),
scratchNote: boundOneWay('member.note'),
subscriptions: computed('member.stripe', function () {
let subscriptions = this.member.get('stripe');
if (subscriptions && subscriptions.length > 0) {

View File

@ -1,6 +1,6 @@
import Component from '@ember/component';
import moment from 'moment';
import {alias} from '@ember/object/computed';
import {alias, or} from '@ember/object/computed';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
@ -17,10 +17,13 @@ export default Component.extend({
id: alias('member.id'),
email: alias('member.email'),
name: alias('member.name'),
memberSince: computed('member.createdAt', function () {
return moment(this.member.createdAt).from(moment());
displayName: or('member.name', 'member.email'),
memberSince: computed('member.createdAtUTC', function () {
return moment(this.member.createdAtUTC).from(moment());
}),
createdDate: computed('member.createdAt', function () {
return moment(this.member.createdAt).format('MMM DD, YYYY');
createdDate: computed('member.createdAtUTC', function () {
return moment(this.member.createdAtUTC).format('MMM DD, YYYY');
})
});

View File

@ -15,9 +15,9 @@ export default Controller.extend({
notifications: service(),
member: alias('model'),
subscribedAt: computed('member.createdAt', function () {
let memberSince = moment(this.member.createdAt).from(moment());
let createdDate = moment(this.member.createdAt).format('MMM DD, YYYY');
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})`;
}),
@ -29,7 +29,7 @@ export default Controller.extend({
this.toggleProperty('showDeleteMemberModal');
},
finaliseDeletion() {
// decrememnt the total member count manually so there's no flash
// decrement the total member count manually so there's no flash
// when transitioning back to the members list
if (this.members.memberCount) {
this.members.decrementProperty('memberCount');

View File

@ -0,0 +1,104 @@
import Controller from '@ember/controller';
import moment from 'moment';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {inject as controller} from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
members: controller(),
store: service(),
router: service(),
notifications: service(),
member: alias('model'),
displayName: computed('member.{name,email}', function () {
return this.member.name || this.member.email || 'New member';
}),
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');
},
finaliseDeletion() {
// decrement the total member count manually so there's no flash
// when transitioning back to the members list
if (this.members.memberCount) {
this.members.decrementProperty('memberCount');
}
this.router.transitionTo('members');
},
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() {
let transition = this.leaveScreenTransition;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.member.rollbackAttributes();
return transition.retry();
},
save() {
return this.save.perform();
}
},
save: task(function* () {
let member = this.member;
try {
return yield member.save();
} catch (error) {
if (error) {
this.notifications.showAPIError(error, {key: 'member.save'});
}
}
}).drop(),
_saveMemberProperty(propKey, newValue) {
let member = this.member;
member.set(propKey, newValue);
}
});

View File

@ -30,7 +30,7 @@ export default Controller.extend({
return (name && name.toLowerCase().indexOf(searchText) >= 0)
|| (email && email.toLowerCase().indexOf(searchText) >= 0);
}).sort((a, b) => {
return b.get('createdAt').valueOf() - a.get('createdAt').valueOf();
return b.get('createdAtUTC').valueOf() - a.get('createdAtUTC').valueOf();
});
return filtered;

View File

@ -1,6 +1,7 @@
import DS from 'ember-data';
import IntegrationValidator from 'ghost-admin/validators/integration';
import InviteUserValidator from 'ghost-admin/validators/invite-user';
import MemberValidator from 'ghost-admin/validators/member';
import Mixin from '@ember/object/mixin';
import Model from 'ember-data/model';
import NavItemValidator from 'ghost-admin/validators/nav-item';
@ -43,6 +44,7 @@ export default Mixin.create({
slackIntegration: SlackIntegrationValidator,
tag: TagSettingsValidator,
user: UserValidator,
member: MemberValidator,
integration: IntegrationValidator,
webhook: WebhookValidator
},

View File

@ -1,11 +1,14 @@
import DS from 'ember-data';
import Model from 'ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import attr from 'ember-data/attr';
export default DS.Model.extend({
export default Model.extend(ValidationEngine, {
validationType: 'member',
name: attr('string'),
email: attr('string'),
note: attr('string'),
createdAt: attr('moment-utc'),
createdAtUTC: attr('moment-utc'),
stripe: attr('member-subscription'),
subscribed: attr('boolean')
subscribed: attr('boolean', {defaultValue: true})
});

View File

@ -61,6 +61,8 @@ Router.map(function () {
this.route('members', function () {
this.route('import');
});
this.route('member.new', {path: '/members/new'});
this.route('member', {path: '/members/:member_id'});
this.route('error404', {path: '/*path'});

View File

@ -0,0 +1,45 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {isEmpty} from '@ember/utils';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
controllerName: 'member.new',
templateName: 'member/new',
init() {
this._super(...arguments);
this.router.on('routeWillChange', (transition) => {
this.showUnsavedChangesModal(transition);
});
},
model() {
return this.store.createRecord('member');
},
// reset the model so that mobile screens react to an empty selectedMember
deactivate() {
this._super(...arguments);
let {controller} = this;
controller.model.rollbackAttributes();
controller.set('model', null);
},
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name.match(/^members\.new/) && transition.targetName) {
let {controller} = this;
let isUnchanged = isEmpty(Object.keys(controller.member.changedAttributes()));
if (!controller.member.isDeleted && !isUnchanged) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
return;
}
}
}
});

View File

@ -2,6 +2,10 @@
import ApplicationSerializer from 'ghost-admin/serializers/application';
export default ApplicationSerializer.extend({
attrs: {
createdAtUTC: {key: 'created_at'}
},
serialize(/*snapshot, options*/) {
let json = this._super(...arguments);

View File

@ -48,6 +48,12 @@ p.gh-members-list-email {
pointer-events: none;
}
.members-header .gh-members-header-search {
margin-right: 12px;
border-right: 1px solid var(--lightgrey-d2);
padding-right: 12px;
}
@media (max-width: 1000px) {
.members-list .gh-list-header, .gh-list-hidecell-m {
display: table-cell;
@ -189,6 +195,10 @@ textarea.gh-member-details-textarea {
max-width: 100%;
}
.gh-new-member-avatar {
background: var(--midlightgrey-l1);
}
/* Import modal
/* ---------------------------------------------------------- */

View File

@ -1,46 +1,55 @@
<h4 class="midlightgrey f-small fw5 ttu mt12">Basic</h4>
<div class="br4 shadow-1 bg-grouped-table mt2 flex flex-column items-stretch gh-tag-basic-settings-form">
<h4 class="midlightgrey f-small fw5 ttu">Basic info</h4>
<div class="br4 shadow-1 bg-grouped-table mt2 flex flex-column items-stretch gh-member-basic-settings-form">
<div class="pa5 pb0 pt4 flex flex-column flex-row-ns justify-between">
<div class="flex flex-column items-start mr8 w-100 w-50-ns">
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="name"}}
<label for="member-name">Name</label>
{{gh-text-input
id="member-name"
name="name"
value=(readonly scratchName)
tabindex="1"
input=(action (mut scratchName) value="target.value")
focus-out=(action 'setProperty' 'name' scratchName)}}
id="member-name"
name="name"
value=(readonly scratchName)
tabindex="1"
input=(action (mut scratchName) value="target.value")
focus-out=(action 'setProperty' 'name' scratchName)}}
{{gh-error-message errors=member.errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="email"}}
<label for="member-email">Email</label>
{{gh-text-input
disabled=true
{{#if canEditEmail}}
{{gh-text-input
value=(readonly scratchEmail)
id="member-email"
name="email"
tabindex="2"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
focus-out=(action 'setProperty' 'email' scratchEmail)
input=(action (mut scratchEmail) value="target.value")}}
{{gh-error-message errors=member.errors property="email"}}
{{else}}
{{gh-text-input
name="email-disabled"
disabled=true
value=(readonly scratchEmail)}}
{{/if}}
{{/gh-form-group}}
</div>
<div class="mb6 mb0-ns w-100 w-50-ns">
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="note"}}
<label for="member-note">Note</label>
{{gh-textarea
id="member-note"
name="note"
class="gh-member-details-textarea"
tabindex="3"
value=(readonly scratchNote)
input=(action (mut scratchNote) value="target.value")
focus-out=(action 'setProperty' 'note' scratchNote)
}}
{{gh-error-message errors=member.errors property="note"}}
<p>Not visible to member. Maximum: <b>500</b> characters. Youve used {{gh-count-down-characters scratchNote 500}}
</p>
<label for="member-note">Note</label>
{{gh-textarea
id="member-note"
name="note"
class="gh-member-details-textarea"
tabindex="3"
value=(readonly scratchNote)
input=(action (mut scratchNote) value="target.value")
focus-out=(action 'setProperty' 'note' scratchNote)
}}
{{gh-error-message errors=member.errors property="note"}}
<p>Not visible to member. Maximum: <b>500</b> characters. Youve used {{gh-count-down-characters scratchNote 500}}</p>
{{/gh-form-group}}
</div>
</div>

View File

@ -2,13 +2,7 @@
<div class="flex items-center">
<GhMemberAvatar @member={{member}} class="w9 h9 mr3" />
<div>
<h3 class="ma0 pa0 gh-members-list-name {{if (not member.name) "gh-members-name-noname"}}">
{{#if this.name}}
{{this.name}}
{{else}}
{{this.email}}
{{/if}}
</h3>
<h3 class="ma0 pa0 gh-members-list-name {{if (not member.name) "gh-members-name-noname"}}">{{this.displayName}}</h3>
{{#if this.name}}
<p class="ma0 pa0 middarkgrey f8 gh-members-list-email">{{this.email}}</p>
{{/if}}

View File

@ -20,8 +20,8 @@
<h3 class="f2 fw5 ma0 pa0">
{{or member.name member.email}}
</h3>
<p class="f6 pa0 ma0 midgrey">
{{#if member.name}}
<p class="f6 pa0 ma0 midlightgrey-d1">
{{#if (and member.name member.email)}}
<span class="darkgrey fw5">{{member.email}}</span>
{{/if}}
Created on {{this.subscribedAt}}

View File

@ -0,0 +1,67 @@
<section class="gh-canvas">
<form class="mb10 member-basic-info-form">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
{{#link-to "members" data-test-link="members-back"}}Members{{/link-to}}
<span>{{svg-jar "arrow-right"}}</span>{{displayName}}
</h2>
<section class="view-actions">
{{gh-task-button type="button" task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}}
</section>
</GhCanvasHeader>
<div class="flex items-center mb10 bt b--lightgrey-d1 pt8">
{{#if (or member.name member.email)}}
<GhMemberAvatar @member={{member}} @sizeClass={{if member.name 'f-subheadline fw4 lh-zero tracked-1' 'f-headline fw4 lh-zero tracked-1'}} class="w18 h18 mr4" />
{{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 fw5 ma0 pa0">
{{#if (or member.name member.email)}}
{{or member.name member.email}}
{{else}}
<span class="midlightgrey-d1">New member</span>
{{/if}}
</h3>
<p class="f6 pa0 ma0 midlightgrey-d1">
{{#if (and member.name member.email)}}
<span class="darkgrey fw5">{{member.email}}</span>
{{/if}}
{{#unless member.isNew}}
{{if (and member.name member.email) ""}}
Created on {{this.subscribedAt}}
{{/unless}}
</p>
</div>
</div>
{{gh-member-settings-form member=member
setProperty=(action "setProperty")
isLoading=this.isLoading
showDeleteTagModal=(action "toggleDeleteMemberModal")}}
</form>
<button
type="button"
class="gh-btn gh-btn-red gh-btn-icon mt3"
{{action (toggle "showDeleteMemberModal" this)}}
data-test-button="delete-member"
>
<span>Delete member</span>
</button>
</section>
{{#if showUnsavedChangesModal}}
{{gh-fullscreen-modal "leave-settings"
confirm=(action "leaveScreen")
close=(action "toggleUnsavedChangesModal")
modifier="action wide"}}
{{/if}}
{{#if showDeleteMemberModal}}
{{gh-fullscreen-modal "delete-member"
model=(hash member=member onSuccess=(action "finaliseDeletion"))
close=(action (toggle "showDeleteMemberModal" this))
modifier="action wide"}}
{{/if}}

View File

@ -26,6 +26,7 @@
{{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>
{{#link-to "member.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"}}<span>New member</span>{{/link-to}}
</section>
</GhCanvasHeader>
<section class="view-container">

View File

@ -0,0 +1,43 @@
import BaseValidator from './base';
import validator from 'validator';
import {isBlank} from '@ember/utils';
export default BaseValidator.create({
properties: ['name', 'email', 'note'],
name(model) {
if (!validator.isLength(model.name || '', 0, 191)) {
model.errors.add('name', 'Name cannot be longer than 191 characters.');
this.invalidate();
}
},
email(model) {
let email = model.get('email');
if (isBlank(email)) {
model.get('errors').add('email', 'Please enter an email.');
this.invalidate();
} else if (!validator.isEmail(email)) {
model.get('errors').add('email', 'Invalid Email.');
this.invalidate();
}
if (!validator.isLength(model.name || '', 0, 191)) {
model.errors.add('email', 'Email cannot be longer than 191 characters.');
this.invalidate();
}
model.get('hasValidated').addObject('email');
},
note(model) {
let note = model.get('note');
if (!validator.isLength(note || '', 0, 500)) {
model.get('errors').add('note', 'Note is too long.');
this.invalidate();
}
model.get('hasValidated').addObject('note');
}
});

View File

@ -1,6 +1,12 @@
import {paginatedResponse} from '../utils';
export default function mockMembers(server) {
server.post('/members/', function ({members}) {
let attrs = this.normalizedRequestAttrs();
return members.create(Object.assign({}, attrs, {id: 99}));
});
server.get('/members/', paginatedResponse('members'));
server.get('/members/:id/', function ({members}, {params}) {
@ -15,5 +21,7 @@ export default function mockMembers(server) {
});
});
server.put('/members/:id/');
server.del('/members/:id/');
}

View File

@ -1,9 +1,12 @@
import moment from 'moment';
import wait from 'ember-test-helpers/wait';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click, currentRouteName, currentURL, find} from '@ember/test-helpers';
import {click, currentRouteName, currentURL, fillIn, find, findAll} from '@ember/test-helpers';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {timeout} from 'ember-concurrency';
import {visit} from '../helpers/visit';
describe('Acceptance: Members', function () {
@ -53,5 +56,113 @@ describe('Acceptance: Members', function () {
expect(currentRouteName()).to.equal('members.index');
expect(find('[data-test-screen-title]')).to.have.text('Members');
});
it('it renders, can be navigated, can edit member', async function () {
let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').valueOf()});
this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').valueOf()});
await visit('/members');
// second wait is needed for the vertical-collection to settle
await wait();
// lands on correct page
expect(currentURL(), 'currentURL').to.equal('/members');
// it has correct page title
expect(document.title, 'page title').to.equal('Members - Test Blog');
// it lists all members
expect(findAll('.members-list .gh-members-list-item').length, 'members list count')
.to.equal(2);
let member = find('.members-list .gh-members-list-item');
expect(member.querySelector('.gh-members-list-name').textContent, 'member list item title')
.to.equal(member1.name);
await visit(`/members/${member1.id}`);
// // second wait is needed for the member details to settle
await wait();
// it shows selected member form
expect(find('.gh-member-basic-settings-form input[name="name"]').value, 'loads correct member into form')
.to.equal(member1.name);
expect(find('.gh-member-basic-settings-form input[name="email-disabled"]').disabled, 'makes sure email is disabled')
.to.equal(true);
// trigger save
await fillIn('.gh-member-basic-settings-form input[name="name"]', 'New Name');
await blur('.gh-member-basic-settings-form input[name="name"]');
await click('[data-test-button="save"]');
// // extra timeout needed for Travis - sometimes it doesn't update
// // quick enough and an extra wait() call doesn't help
await timeout(100);
await click('[data-test-link="members-back"]');
await wait();
// lands on correct page
expect(currentURL(), 'currentURL').to.equal('/members');
});
it('can create a new member', async function () {
this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').valueOf()});
await visit('/members');
// second wait is needed for the vertical-collection to settle
await wait();
// lands on correct page
expect(currentURL(), 'currentURL').to.equal('/members');
// it has correct page title
expect(document.title, 'page title').to.equal('Members - Test Blog');
// it lists all members
expect(findAll('.members-list .gh-members-list-item').length, 'members list count')
.to.equal(1);
// start new member
await click('[data-test-new-member-button="true"]');
// it navigates to the new member route
expect(currentURL(), 'new member URL').to.equal('/members/new');
// it displays the new member form
expect(find('.member-basic-info-form .gh-canvas-header h2').textContent, 'settings pane title')
.to.contain('New member');
// // all fields start blank
findAll('.gh-member-basic-settings-form input, .gh-member-basic-settings-form textarea').forEach(function (elem) {
expect(elem.value, `input field for ${elem.getAttribute('name')}`)
.to.be.empty;
});
expect(find('.gh-member-basic-settings-form input[name="email"]').disabled, 'makes sure email is disabled')
.to.equal(false);
// save new member
await fillIn('.gh-member-basic-settings-form input[name="name"]', 'New Name');
await blur('.gh-member-basic-settings-form input[name="name"]');
await fillIn('.gh-member-basic-settings-form input[name="email"]', 'example@domain.com');
await blur('.gh-member-basic-settings-form input[name="email"]');
await click('[data-test-button="save"]');
// extra timeout needed for FF on Linux - sometimes it doesn't update
// quick enough, especially on Travis, and an extra wait() call
// doesn't help
await timeout(200);
// disables email field after member has been created
expect(find('.gh-member-basic-settings-form input[name="email-disabled"]').disabled, 'makes sure email is disabled')
.to.equal(true);
});
});
});

View File

@ -5,10 +5,15 @@ import {setupTest} from 'ember-mocha';
describe('Unit: Model: member', function () {
setupTest();
// Replace this with your real tests.
it('exists', function () {
let store = this.owner.lookup('service:store');
let model = store.createRecord('member', {});
expect(model).to.be.ok;
let store;
beforeEach(function () {
store = this.owner.lookup('service:store');
});
it('has a validation type of "member"', function () {
let model = store.createRecord('member');
expect(model.get('validationType')).to.equal('member');
});
});