diff --git a/ghost/admin/app/components/gh-member-avatar.js b/ghost/admin/app/components/gh-member-avatar.js index 0de0543ab2..f8351eeec3 100644 --- a/ghost/admin/app/components/gh-member-avatar.js +++ b/ghost/admin/app/components/gh-member-avatar.js @@ -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'; }) }); diff --git a/ghost/admin/app/components/gh-member-settings-form.js b/ghost/admin/app/components/gh-member-settings-form.js index 6b27fcc71b..ff63c652fd 100644 --- a/ghost/admin/app/components/gh-member-settings-form.js +++ b/ghost/admin/app/components/gh-member-settings-form.js @@ -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) { diff --git a/ghost/admin/app/components/gh-members-list-item.js b/ghost/admin/app/components/gh-members-list-item.js index e97775766b..619ed04ab0 100644 --- a/ghost/admin/app/components/gh-members-list-item.js +++ b/ghost/admin/app/components/gh-members-list-item.js @@ -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'); }) }); diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index 8be18affe6..9a7d0da2d4 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -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'); diff --git a/ghost/admin/app/controllers/member/new.js b/ghost/admin/app/controllers/member/new.js new file mode 100644 index 0000000000..6eb1e66719 --- /dev/null +++ b/ghost/admin/app/controllers/member/new.js @@ -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); + } +}); diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index c43ab6838d..0bb45dc04f 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -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; @@ -51,7 +51,7 @@ export default Controller.extend({ iframe.setAttribute('src', downloadURL); } }, - + fetchMembers: task(function* () { let newFetchDate = new Date(); diff --git a/ghost/admin/app/mixins/validation-engine.js b/ghost/admin/app/mixins/validation-engine.js index 449f2e5f7f..e4624c3557 100644 --- a/ghost/admin/app/mixins/validation-engine.js +++ b/ghost/admin/app/mixins/validation-engine.js @@ -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 }, diff --git a/ghost/admin/app/models/member.js b/ghost/admin/app/models/member.js index bb06e3b820..c7994c8252 100644 --- a/ghost/admin/app/models/member.js +++ b/ghost/admin/app/models/member.js @@ -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}) }); diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 0172115632..18db7bb18a 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -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'}); diff --git a/ghost/admin/app/routes/member/new.js b/ghost/admin/app/routes/member/new.js new file mode 100644 index 0000000000..87709594aa --- /dev/null +++ b/ghost/admin/app/routes/member/new.js @@ -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; + } + } + } + +}); diff --git a/ghost/admin/app/serializers/member.js b/ghost/admin/app/serializers/member.js index 76eff17cb9..0656523960 100644 --- a/ghost/admin/app/serializers/member.js +++ b/ghost/admin/app/serializers/member.js @@ -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); diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index ea73e800ee..60f49b761d 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -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,10 +195,14 @@ textarea.gh-member-details-textarea { max-width: 100%; } +.gh-new-member-avatar { + background: var(--midlightgrey-l1); +} + /* Import modal /* ---------------------------------------------------------- */ .gh-members-import-results { margin: 0; width: auto; -} \ No newline at end of file +} diff --git a/ghost/admin/app/templates/components/gh-member-settings-form.hbs b/ghost/admin/app/templates/components/gh-member-settings-form.hbs index 2eabf362ac..8e8e75192e 100644 --- a/ghost/admin/app/templates/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/templates/components/gh-member-settings-form.hbs @@ -1,46 +1,55 @@ -
Not visible to member. Maximum: 500 characters. You’ve used {{gh-count-down-characters scratchNote 500}} -
+ + {{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"}} +Not visible to member. Maximum: 500 characters. You’ve used {{gh-count-down-characters scratchNote 500}}
{{/gh-form-group}}{{this.email}}
{{/if}} diff --git a/ghost/admin/app/templates/member.hbs b/ghost/admin/app/templates/member.hbs index 196c4b02fb..8c6abbbfd0 100644 --- a/ghost/admin/app/templates/member.hbs +++ b/ghost/admin/app/templates/member.hbs @@ -20,8 +20,8 @@- {{#if member.name}} +
+ {{#if (and member.name member.email)}}
{{member.email}} –
{{/if}}
Created on {{this.subscribedAt}}
diff --git a/ghost/admin/app/templates/member/new.hbs b/ghost/admin/app/templates/member/new.hbs
new file mode 100644
index 0000000000..9b81ecc14e
--- /dev/null
+++ b/ghost/admin/app/templates/member/new.hbs
@@ -0,0 +1,67 @@
+