mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
✨ 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:
parent
c376fc016a
commit
c41c184762
@ -21,7 +21,7 @@ export default Component.extend({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
backgroundStyle: computed('member.{name,email}', function () {
|
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) {
|
if (name) {
|
||||||
let color = stringToHslColor(name, 55, 55);
|
let color = stringToHslColor(name, 55, 55);
|
||||||
return htmlSafe(`background-color: ${color}`);
|
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]];
|
let intials = names.length > 1 ? [names[0][0], names[names.length - 1][0]] : [names[0][0]];
|
||||||
return intials.join('').toUpperCase();
|
return intials.join('').toUpperCase();
|
||||||
}
|
}
|
||||||
return '';
|
|
||||||
|
// New Member initials
|
||||||
|
return 'NM';
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import Component from '@ember/component';
|
|||||||
import boundOneWay from 'ghost-admin/utils/bound-one-way';
|
import boundOneWay from 'ghost-admin/utils/bound-one-way';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {computed} from '@ember/object';
|
import {computed} from '@ember/object';
|
||||||
|
import {reads} from '@ember/object/computed';
|
||||||
import {inject as service} from '@ember/service';
|
import {inject as service} from '@ember/service';
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
@ -15,9 +16,12 @@ export default Component.extend({
|
|||||||
setProperty: () => {},
|
setProperty: () => {},
|
||||||
showDeleteTagModal: () => {},
|
showDeleteTagModal: () => {},
|
||||||
|
|
||||||
|
canEditEmail: reads('member.isNew'),
|
||||||
|
|
||||||
scratchName: boundOneWay('member.name'),
|
scratchName: boundOneWay('member.name'),
|
||||||
scratchEmail: boundOneWay('member.email'),
|
scratchEmail: boundOneWay('member.email'),
|
||||||
scratchNote: boundOneWay('member.note'),
|
scratchNote: boundOneWay('member.note'),
|
||||||
|
|
||||||
subscriptions: computed('member.stripe', function () {
|
subscriptions: computed('member.stripe', function () {
|
||||||
let subscriptions = this.member.get('stripe');
|
let subscriptions = this.member.get('stripe');
|
||||||
if (subscriptions && subscriptions.length > 0) {
|
if (subscriptions && subscriptions.length > 0) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {alias} from '@ember/object/computed';
|
import {alias, or} from '@ember/object/computed';
|
||||||
import {computed} from '@ember/object';
|
import {computed} from '@ember/object';
|
||||||
import {inject as service} from '@ember/service';
|
import {inject as service} from '@ember/service';
|
||||||
|
|
||||||
@ -17,10 +17,13 @@ export default Component.extend({
|
|||||||
id: alias('member.id'),
|
id: alias('member.id'),
|
||||||
email: alias('member.email'),
|
email: alias('member.email'),
|
||||||
name: alias('member.name'),
|
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 () {
|
createdDate: computed('member.createdAtUTC', function () {
|
||||||
return moment(this.member.createdAt).format('MMM DD, YYYY');
|
return moment(this.member.createdAtUTC).format('MMM DD, YYYY');
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -15,9 +15,9 @@ export default Controller.extend({
|
|||||||
notifications: service(),
|
notifications: service(),
|
||||||
|
|
||||||
member: alias('model'),
|
member: alias('model'),
|
||||||
subscribedAt: computed('member.createdAt', function () {
|
subscribedAt: computed('member.createdAtUTC', function () {
|
||||||
let memberSince = moment(this.member.createdAt).from(moment());
|
let memberSince = moment(this.member.createdAtUTC).from(moment());
|
||||||
let createdDate = moment(this.member.createdAt).format('MMM DD, YYYY');
|
let createdDate = moment(this.member.createdAtUTC).format('MMM DD, YYYY');
|
||||||
return `${createdDate} (${memberSince})`;
|
return `${createdDate} (${memberSince})`;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ export default Controller.extend({
|
|||||||
this.toggleProperty('showDeleteMemberModal');
|
this.toggleProperty('showDeleteMemberModal');
|
||||||
},
|
},
|
||||||
finaliseDeletion() {
|
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
|
// when transitioning back to the members list
|
||||||
if (this.members.memberCount) {
|
if (this.members.memberCount) {
|
||||||
this.members.decrementProperty('memberCount');
|
this.members.decrementProperty('memberCount');
|
||||||
|
104
ghost/admin/app/controllers/member/new.js
Normal file
104
ghost/admin/app/controllers/member/new.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
@ -30,7 +30,7 @@ export default Controller.extend({
|
|||||||
return (name && name.toLowerCase().indexOf(searchText) >= 0)
|
return (name && name.toLowerCase().indexOf(searchText) >= 0)
|
||||||
|| (email && email.toLowerCase().indexOf(searchText) >= 0);
|
|| (email && email.toLowerCase().indexOf(searchText) >= 0);
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
return b.get('createdAt').valueOf() - a.get('createdAt').valueOf();
|
return b.get('createdAtUTC').valueOf() - a.get('createdAtUTC').valueOf();
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
@ -51,7 +51,7 @@ export default Controller.extend({
|
|||||||
iframe.setAttribute('src', downloadURL);
|
iframe.setAttribute('src', downloadURL);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchMembers: task(function* () {
|
fetchMembers: task(function* () {
|
||||||
let newFetchDate = new Date();
|
let newFetchDate = new Date();
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
import IntegrationValidator from 'ghost-admin/validators/integration';
|
import IntegrationValidator from 'ghost-admin/validators/integration';
|
||||||
import InviteUserValidator from 'ghost-admin/validators/invite-user';
|
import InviteUserValidator from 'ghost-admin/validators/invite-user';
|
||||||
|
import MemberValidator from 'ghost-admin/validators/member';
|
||||||
import Mixin from '@ember/object/mixin';
|
import Mixin from '@ember/object/mixin';
|
||||||
import Model from 'ember-data/model';
|
import Model from 'ember-data/model';
|
||||||
import NavItemValidator from 'ghost-admin/validators/nav-item';
|
import NavItemValidator from 'ghost-admin/validators/nav-item';
|
||||||
@ -43,6 +44,7 @@ export default Mixin.create({
|
|||||||
slackIntegration: SlackIntegrationValidator,
|
slackIntegration: SlackIntegrationValidator,
|
||||||
tag: TagSettingsValidator,
|
tag: TagSettingsValidator,
|
||||||
user: UserValidator,
|
user: UserValidator,
|
||||||
|
member: MemberValidator,
|
||||||
integration: IntegrationValidator,
|
integration: IntegrationValidator,
|
||||||
webhook: WebhookValidator
|
webhook: WebhookValidator
|
||||||
},
|
},
|
||||||
|
@ -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';
|
import attr from 'ember-data/attr';
|
||||||
|
|
||||||
export default DS.Model.extend({
|
export default Model.extend(ValidationEngine, {
|
||||||
|
validationType: 'member',
|
||||||
|
|
||||||
name: attr('string'),
|
name: attr('string'),
|
||||||
email: attr('string'),
|
email: attr('string'),
|
||||||
note: attr('string'),
|
note: attr('string'),
|
||||||
createdAt: attr('moment-utc'),
|
createdAtUTC: attr('moment-utc'),
|
||||||
stripe: attr('member-subscription'),
|
stripe: attr('member-subscription'),
|
||||||
subscribed: attr('boolean')
|
subscribed: attr('boolean', {defaultValue: true})
|
||||||
});
|
});
|
||||||
|
@ -61,6 +61,8 @@ Router.map(function () {
|
|||||||
this.route('members', function () {
|
this.route('members', function () {
|
||||||
this.route('import');
|
this.route('import');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route('member.new', {path: '/members/new'});
|
||||||
this.route('member', {path: '/members/:member_id'});
|
this.route('member', {path: '/members/:member_id'});
|
||||||
|
|
||||||
this.route('error404', {path: '/*path'});
|
this.route('error404', {path: '/*path'});
|
||||||
|
45
ghost/admin/app/routes/member/new.js
Normal file
45
ghost/admin/app/routes/member/new.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
@ -2,6 +2,10 @@
|
|||||||
import ApplicationSerializer from 'ghost-admin/serializers/application';
|
import ApplicationSerializer from 'ghost-admin/serializers/application';
|
||||||
|
|
||||||
export default ApplicationSerializer.extend({
|
export default ApplicationSerializer.extend({
|
||||||
|
attrs: {
|
||||||
|
createdAtUTC: {key: 'created_at'}
|
||||||
|
},
|
||||||
|
|
||||||
serialize(/*snapshot, options*/) {
|
serialize(/*snapshot, options*/) {
|
||||||
let json = this._super(...arguments);
|
let json = this._super(...arguments);
|
||||||
|
|
||||||
|
@ -48,6 +48,12 @@ p.gh-members-list-email {
|
|||||||
pointer-events: none;
|
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) {
|
@media (max-width: 1000px) {
|
||||||
.members-list .gh-list-header, .gh-list-hidecell-m {
|
.members-list .gh-list-header, .gh-list-hidecell-m {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
@ -189,10 +195,14 @@ textarea.gh-member-details-textarea {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gh-new-member-avatar {
|
||||||
|
background: var(--midlightgrey-l1);
|
||||||
|
}
|
||||||
|
|
||||||
/* Import modal
|
/* Import modal
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
.gh-members-import-results {
|
.gh-members-import-results {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
@ -1,46 +1,55 @@
|
|||||||
<h4 class="midlightgrey f-small fw5 ttu mt12">Basic</h4>
|
<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-tag-basic-settings-form">
|
<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="pa5 pb0 pt4 flex flex-column flex-row-ns justify-between">
|
||||||
<div class="flex flex-column items-start mr8 w-100 w-50-ns">
|
<div class="flex flex-column items-start mr8 w-100 w-50-ns">
|
||||||
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="name"}}
|
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="name"}}
|
||||||
<label for="member-name">Name</label>
|
<label for="member-name">Name</label>
|
||||||
{{gh-text-input
|
{{gh-text-input
|
||||||
id="member-name"
|
id="member-name"
|
||||||
name="name"
|
name="name"
|
||||||
value=(readonly scratchName)
|
value=(readonly scratchName)
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
input=(action (mut scratchName) value="target.value")
|
input=(action (mut scratchName) value="target.value")
|
||||||
focus-out=(action 'setProperty' 'name' scratchName)}}
|
focus-out=(action 'setProperty' 'name' scratchName)}}
|
||||||
{{gh-error-message errors=member.errors property="name"}}
|
{{gh-error-message errors=member.errors property="name"}}
|
||||||
{{/gh-form-group}}
|
{{/gh-form-group}}
|
||||||
|
|
||||||
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="email"}}
|
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="email"}}
|
||||||
<label for="member-email">Email</label>
|
<label for="member-email">Email</label>
|
||||||
{{gh-text-input
|
{{#if canEditEmail}}
|
||||||
disabled=true
|
{{gh-text-input
|
||||||
value=(readonly scratchEmail)
|
value=(readonly scratchEmail)
|
||||||
id="member-email"
|
id="member-email"
|
||||||
name="email"
|
name="email"
|
||||||
tabindex="2"
|
tabindex="2"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
focus-out=(action 'setProperty' 'email' scratchEmail)
|
focus-out=(action 'setProperty' 'email' scratchEmail)
|
||||||
input=(action (mut scratchEmail) value="target.value")}}
|
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}}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb6 mb0-ns w-100 w-50-ns">
|
<div class="mb6 mb0-ns w-100 w-50-ns">
|
||||||
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="note"}}
|
{{#gh-form-group errors=member.errors hasValidated=member.hasValidated property="note"}}
|
||||||
<label for="member-note">Note</label>
|
<label for="member-note">Note</label>
|
||||||
{{gh-textarea
|
{{gh-textarea
|
||||||
id="member-note"
|
id="member-note"
|
||||||
name="note"
|
name="note"
|
||||||
class="gh-member-details-textarea"
|
class="gh-member-details-textarea"
|
||||||
tabindex="3"
|
tabindex="3"
|
||||||
value=(readonly scratchNote)
|
value=(readonly scratchNote)
|
||||||
input=(action (mut scratchNote) value="target.value")
|
input=(action (mut scratchNote) value="target.value")
|
||||||
focus-out=(action 'setProperty' 'note' scratchNote)
|
focus-out=(action 'setProperty' 'note' scratchNote)
|
||||||
}}
|
}}
|
||||||
{{gh-error-message errors=member.errors property="note"}}
|
{{gh-error-message errors=member.errors property="note"}}
|
||||||
<p>Not visible to member. Maximum: <b>500</b> characters. You’ve used {{gh-count-down-characters scratchNote 500}}
|
<p>Not visible to member. Maximum: <b>500</b> characters. You’ve used {{gh-count-down-characters scratchNote 500}}</p>
|
||||||
</p>
|
|
||||||
{{/gh-form-group}}
|
{{/gh-form-group}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,13 +2,7 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<GhMemberAvatar @member={{member}} class="w9 h9 mr3" />
|
<GhMemberAvatar @member={{member}} class="w9 h9 mr3" />
|
||||||
<div>
|
<div>
|
||||||
<h3 class="ma0 pa0 gh-members-list-name {{if (not member.name) "gh-members-name-noname"}}">
|
<h3 class="ma0 pa0 gh-members-list-name {{if (not member.name) "gh-members-name-noname"}}">{{this.displayName}}</h3>
|
||||||
{{#if this.name}}
|
|
||||||
{{this.name}}
|
|
||||||
{{else}}
|
|
||||||
{{this.email}}
|
|
||||||
{{/if}}
|
|
||||||
</h3>
|
|
||||||
{{#if this.name}}
|
{{#if this.name}}
|
||||||
<p class="ma0 pa0 middarkgrey f8 gh-members-list-email">{{this.email}}</p>
|
<p class="ma0 pa0 middarkgrey f8 gh-members-list-email">{{this.email}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
@ -20,8 +20,8 @@
|
|||||||
<h3 class="f2 fw5 ma0 pa0">
|
<h3 class="f2 fw5 ma0 pa0">
|
||||||
{{or member.name member.email}}
|
{{or member.name member.email}}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="f6 pa0 ma0 midgrey">
|
<p class="f6 pa0 ma0 midlightgrey-d1">
|
||||||
{{#if member.name}}
|
{{#if (and member.name member.email)}}
|
||||||
<span class="darkgrey fw5">{{member.email}}</span> –
|
<span class="darkgrey fw5">{{member.email}}</span> –
|
||||||
{{/if}}
|
{{/if}}
|
||||||
Created on {{this.subscribedAt}}
|
Created on {{this.subscribedAt}}
|
||||||
|
67
ghost/admin/app/templates/member/new.hbs
Normal file
67
ghost/admin/app/templates/member/new.hbs
Normal 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}}
|
@ -26,6 +26,7 @@
|
|||||||
{{svg-jar "search" class="gh-input-search-icon"}}
|
{{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"}}" />
|
<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>
|
</div>
|
||||||
|
{{#link-to "member.new" class="gh-btn gh-btn-green" data-test-new-member-button="true"}}<span>New member</span>{{/link-to}}
|
||||||
</section>
|
</section>
|
||||||
</GhCanvasHeader>
|
</GhCanvasHeader>
|
||||||
<section class="view-container">
|
<section class="view-container">
|
||||||
|
43
ghost/admin/app/validators/member.js
Normal file
43
ghost/admin/app/validators/member.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
@ -1,6 +1,12 @@
|
|||||||
import {paginatedResponse} from '../utils';
|
import {paginatedResponse} from '../utils';
|
||||||
|
|
||||||
export default function mockMembers(server) {
|
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/', paginatedResponse('members'));
|
||||||
|
|
||||||
server.get('/members/:id/', function ({members}, {params}) {
|
server.get('/members/:id/', function ({members}, {params}) {
|
||||||
@ -15,5 +21,7 @@ export default function mockMembers(server) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.put('/members/:id/');
|
||||||
|
|
||||||
server.del('/members/:id/');
|
server.del('/members/:id/');
|
||||||
}
|
}
|
||||||
|
@ -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 {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
|
||||||
import {beforeEach, describe, it} from 'mocha';
|
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 {expect} from 'chai';
|
||||||
import {setupApplicationTest} from 'ember-mocha';
|
import {setupApplicationTest} from 'ember-mocha';
|
||||||
import {setupMirage} from 'ember-cli-mirage/test-support';
|
import {setupMirage} from 'ember-cli-mirage/test-support';
|
||||||
|
import {timeout} from 'ember-concurrency';
|
||||||
import {visit} from '../helpers/visit';
|
import {visit} from '../helpers/visit';
|
||||||
|
|
||||||
describe('Acceptance: Members', function () {
|
describe('Acceptance: Members', function () {
|
||||||
@ -53,5 +56,113 @@ describe('Acceptance: Members', function () {
|
|||||||
expect(currentRouteName()).to.equal('members.index');
|
expect(currentRouteName()).to.equal('members.index');
|
||||||
expect(find('[data-test-screen-title]')).to.have.text('Members');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -5,10 +5,15 @@ import {setupTest} from 'ember-mocha';
|
|||||||
describe('Unit: Model: member', function () {
|
describe('Unit: Model: member', function () {
|
||||||
setupTest();
|
setupTest();
|
||||||
|
|
||||||
// Replace this with your real tests.
|
let store;
|
||||||
it('exists', function () {
|
|
||||||
let store = this.owner.lookup('service:store');
|
beforeEach(function () {
|
||||||
let model = store.createRecord('member', {});
|
store = this.owner.lookup('service:store');
|
||||||
expect(model).to.be.ok;
|
});
|
||||||
|
|
||||||
|
it('has a validation type of "member"', function () {
|
||||||
|
let model = store.createRecord('member');
|
||||||
|
|
||||||
|
expect(model.get('validationType')).to.equal('member');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user