From c41c18476217d83a0acf18eba42aa643e1a6355e Mon Sep 17 00:00:00 2001 From: Naz Gargol Date: Thu, 28 Nov 2019 18:30:21 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20member=20"add"=20screen=20(?= =?UTF-8?q?#1411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/app/components/gh-member-avatar.js | 6 +- .../app/components/gh-member-settings-form.js | 4 + .../app/components/gh-members-list-item.js | 13 +- ghost/admin/app/controllers/member.js | 8 +- ghost/admin/app/controllers/member/new.js | 104 ++++++++++++++++ ghost/admin/app/controllers/members.js | 4 +- ghost/admin/app/mixins/validation-engine.js | 2 + ghost/admin/app/models/member.js | 11 +- ghost/admin/app/router.js | 2 + ghost/admin/app/routes/member/new.js | 45 +++++++ ghost/admin/app/serializers/member.js | 4 + ghost/admin/app/styles/layouts/members.css | 12 +- .../components/gh-member-settings-form.hbs | 57 +++++---- .../components/gh-members-list-item.hbs | 8 +- ghost/admin/app/templates/member.hbs | 4 +- ghost/admin/app/templates/member/new.hbs | 67 +++++++++++ ghost/admin/app/templates/members.hbs | 1 + ghost/admin/app/validators/member.js | 43 +++++++ ghost/admin/mirage/config/members.js | 8 ++ ghost/admin/tests/acceptance/members-test.js | 113 +++++++++++++++++- ghost/admin/tests/unit/models/member-test.js | 15 ++- 21 files changed, 474 insertions(+), 57 deletions(-) create mode 100644 ghost/admin/app/controllers/member/new.js create mode 100644 ghost/admin/app/routes/member/new.js create mode 100644 ghost/admin/app/templates/member/new.hbs create mode 100644 ghost/admin/app/validators/member.js 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 @@ -

Basic

-
+

Basic info

+
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="name"}} {{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"}} - {{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}}
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="note"}} - - {{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-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}}
diff --git a/ghost/admin/app/templates/components/gh-members-list-item.hbs b/ghost/admin/app/templates/components/gh-members-list-item.hbs index 5136bb8fc6..828d6d6344 100644 --- a/ghost/admin/app/templates/components/gh-members-list-item.hbs +++ b/ghost/admin/app/templates/components/gh-members-list-item.hbs @@ -2,13 +2,7 @@
-

- {{#if this.name}} - {{this.name}} - {{else}} - {{this.email}} - {{/if}} -

+

{{this.displayName}}

{{#if this.name}}

{{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 @@

{{or member.name member.email}}

-

- {{#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 @@ +

+
+ +

+ {{#link-to "members" data-test-link="members-back"}}Members{{/link-to}} + {{svg-jar "arrow-right"}}{{displayName}} +

+
+ {{gh-task-button type="button" task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}} +
+
+
+ {{#if (or member.name member.email)}} + + {{else}} +
+ N +
+ {{/if}} +
+

+ {{#if (or member.name member.email)}} + {{or member.name member.email}} + {{else}} + New member + {{/if}} +

+

+ {{#if (and member.name member.email)}} + {{member.email}} + {{/if}} + {{#unless member.isNew}} + {{if (and member.name member.email) "–"}} + Created on {{this.subscribedAt}} + {{/unless}} +

+
+
+ {{gh-member-settings-form member=member + setProperty=(action "setProperty") + isLoading=this.isLoading + showDeleteTagModal=(action "toggleDeleteMemberModal")}} +
+ + +
+ +{{#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}} diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index f403dc7c64..661d41d720 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -26,6 +26,7 @@ {{svg-jar "search" class="gh-input-search-icon"}}
+ {{#link-to "member.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"}}New member{{/link-to}}
diff --git a/ghost/admin/app/validators/member.js b/ghost/admin/app/validators/member.js new file mode 100644 index 0000000000..460bca4521 --- /dev/null +++ b/ghost/admin/app/validators/member.js @@ -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'); + } +}); diff --git a/ghost/admin/mirage/config/members.js b/ghost/admin/mirage/config/members.js index e6c5f4f94e..c9c1c4ea36 100644 --- a/ghost/admin/mirage/config/members.js +++ b/ghost/admin/mirage/config/members.js @@ -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/'); } diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js index cfe67c6119..9b1603249b 100644 --- a/ghost/admin/tests/acceptance/members-test.js +++ b/ghost/admin/tests/acceptance/members-test.js @@ -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); + }); }); }); diff --git a/ghost/admin/tests/unit/models/member-test.js b/ghost/admin/tests/unit/models/member-test.js index 4d697af0bd..490ac9a585 100644 --- a/ghost/admin/tests/unit/models/member-test.js +++ b/ghost/admin/tests/unit/models/member-test.js @@ -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'); }); });