1
0
Fork 0
mirror of https://github.com/TryGhost/Ghost-Admin.git synced 2023-12-14 02:33:04 +01:00

Subscribers: Admin User Interface v1

Initial Subscribers screen
- set up mocked api endpoints
- basic subscribers screen with data loading, infinite scroll

"Add Subscriber" screen
- uses modal to display a new subscriber form
- validates subscriber e-mail address
- moves pagination from route into controller to use filtered/sorted CPs on top of a live-query so that new subscribers are added to the list and the total can be properly managed

TODO:
- there is currently a pretty serious performance issue where the whole table is re-rendered when the live-query is updated. `ember-light-table` doesn't allow for live-binding and has no options to easily manipulate it's rows using an external interface - it's possible to move the page loading into the component so we only render new rows but that leaves it difficult to react to new subscribers being added through the UI. I believe the number of components used within the table is also adding to the performance problems.
  - most likely solution is to drop `ember-light-table` in favour of rendering the table directly - glimmer should do a good job of fast updates even though the underlying array will be completely swapped out

"Import subscribers" screen
- uses modal to display an import subscribers CSV file upload form
- displays upload progress
- displays import stats and reloads subscribers table once import has completed
- adds `gh-file-uploader` component (NB. pared down copy of `gh-image-uploader`, ripe for some refactoring)
- fixes subscribers acceptance test failing because fixtures did not have the labs flag enabled

Unfortunately this doesn't have 100% test coverage as we're limited in how we can simulate file uploads 😞

Fix performance issues with subscribers table
- moves the table definition from the component up to the controller
- switches back to manually manipulating table rows instead of using a live-query

This is a quick-fix in that it allows us to continue using the `ember-light-table` component but it does mean that we lose some flexibility that the live-query gave us. For now it's not much of an issue and it allows us to defer deeper performance/flexibility work until we have a concrete need and requirements.

Hook up Export CSV button
- use a hidden iFrame to trigger the browser to hit the CSV export endpoint and download the file

Re-order subscribers table by clicking column headers
- displays currently sorted column and sort direction
- clicking a column header re-fetches the data from the server with the appropriate query params

Fix scroll triggers for infinite pagination + icon change
- adds a debounce as well as the throttle so that we always get a final scroll trigger once scrolling has stopped
- changes the subscribers icon from the temporary team icon to the mail icon
This commit is contained in:
Kevin Ansfield 2016-04-15 15:45:50 +01:00 committed by Hannah Wolfe
parent b7f5b00e10
commit 67765897b2
48 changed files with 1804 additions and 33 deletions

View file

@ -0,0 +1,150 @@
import Ember from 'ember';
import { invoke, invokeAction } from 'ember-invoke-action';
import {
RequestEntityTooLargeError,
UnsupportedMediaTypeError
} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-image-uploader'],
classNameBindings: ['dragClass'],
labelText: 'Select or drag-and-drop a file',
url: null,
paramName: 'file',
file: null,
response: null,
dragClass: null,
failureMessage: null,
uploadPercentage: 0,
ajax: service(),
formData: computed('file', function () {
let paramName = this.get('paramName');
let file = this.get('file');
let formData = new FormData();
formData.append(paramName, file);
return formData;
}),
progressStyle: computed('uploadPercentage', function () {
let percentage = this.get('uploadPercentage');
let width = '';
if (percentage > 0) {
width = `${percentage}%`;
} else {
width = '0';
}
return Ember.String.htmlSafe(`width: ${width}`);
}),
dragOver(event) {
event.preventDefault();
this.set('dragClass', '--drag-over');
},
dragLeave(event) {
event.preventDefault();
this.set('dragClass', null);
},
drop(event) {
event.preventDefault();
this.set('dragClass', null);
if (event.dataTransfer.files) {
invoke(this, 'fileSelected', event.dataTransfer.files);
}
},
generateRequest() {
let ajax = this.get('ajax');
let formData = this.get('formData');
let url = this.get('url');
invokeAction(this, 'uploadStarted');
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
this._uploadProgress(event);
}, false);
return xhr;
}
}).then((response) => {
this._uploadSuccess(JSON.parse(response));
}).catch((error) => {
this._uploadFailed(error);
}).finally(() => {
invokeAction(this, 'uploadFinished');
});
},
_uploadProgress(event) {
if (event.lengthComputable) {
run(() => {
let percentage = Math.round((event.loaded / event.total) * 100);
this.set('uploadPercentage', percentage);
});
}
},
_uploadSuccess(response) {
invokeAction(this, 'uploadSuccess', response);
invoke(this, 'reset');
},
_uploadFailed(error) {
let message;
if (error instanceof UnsupportedMediaTypeError) {
message = 'The file type you uploaded is not supported.';
} else if (error instanceof RequestEntityTooLargeError) {
message = 'The file you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
} else {
message = 'Something went wrong :(';
}
this.set('failureMessage', message);
invokeAction(this, 'uploadFailed', error);
},
actions: {
fileSelected(fileList) {
this.set('file', fileList[0]);
run.schedule('actions', this, function () {
this.generateRequest();
});
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);
this.set('failureMessage', null);
}
}
});

View file

@ -28,7 +28,6 @@ export default Component.extend({
ajax: service(),
config: service(),
session: service(),
// TODO: this wouldn't be necessary if the server could accept direct
// file uploads

View file

@ -0,0 +1,27 @@
import Ember from 'ember';
import LightTable from 'ember-light-table/components/light-table';
const {$, run} = Ember;
export default LightTable.extend({
// HACK: infinite pagination was not triggering when scrolling very fast
// as the throttle triggers before scrolling into the buffer area but
// the scroll finishes before the throttle timeout. Adding a debounce that
// does the same thing means that we are guaranteed a final trigger when
// scrolling stops
//
// An issue has been opened upstream, this can be removed if it gets fixed
// https://github.com/offirgolan/ember-light-table/issues/15
_setupScrollEvents() {
$(this.get('touchMoveContainer')).on('touchmove.light-table', run.bind(this, this._scrollHandler, '_touchmoveTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollDebounce'));
},
_scrollHandler(timer) {
this.set(timer, run.debounce(this, this._onScroll, 100));
this.set(timer, run.throttle(this, this._onScroll, 100));
}
});

View file

@ -3,7 +3,8 @@ import Ember from 'ember';
const {
Component,
inject: {service},
computed
computed,
observer
} = Ember;
export default Component.extend({
@ -12,6 +13,7 @@ export default Component.extend({
classNameBindings: ['open'],
open: false,
subscribersEnabled: false,
navMenuIcon: computed('ghostPaths.subdir', function () {
let url = `${this.get('ghostPaths.subdir')}/ghost/img/ghosticon.jpg`;
@ -22,6 +24,23 @@ export default Component.extend({
config: service(),
session: service(),
ghostPaths: service(),
feature: service(),
// TODO: the features service should offer some way to propogate raw values
// rather than promises so we don't need to jump through the hoops below
didInsertElement() {
this.updateSubscribersEnabled();
},
updateFeatures: observer('feature.labs.subscribers', function () {
this.updateSubscribersEnabled();
}),
updateSubscribersEnabled() {
this.get('feature.subscribers').then((enabled) => {
this.set('subscribersEnabled', enabled);
});
},
mouseEnter() {
this.sendAction('onMouseEnter');

View file

@ -0,0 +1,17 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['subscribers-table'],
table: null,
actions: {
onScrolledToBottom() {
let loadNextPage = this.get('loadNextPage');
if (!this.get('isLoading')) {
loadNextPage();
}
}
}
});

View file

@ -0,0 +1,43 @@
import Ember from 'ember';
import { invokeAction } from 'ember-invoke-action';
import ModalComponent from 'ghost/components/modals/base';
import ghostPaths from 'ghost/utils/ghost-paths';
const {computed} = Ember;
export default ModalComponent.extend({
labelText: 'Select or drag-and-drop a CSV File',
response: null,
closeDisabled: false,
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/subscribers/csv/`;
}),
actions: {
uploadStarted() {
this.set('closeDisabled', true);
},
uploadFinished() {
this.set('closeDisabled', false);
},
uploadSuccess(response) {
this.set('response', response);
// invoke the passed in confirm action
invokeAction(this, 'confirm');
},
confirm() {
// noop - we don't want the enter key doing anything
},
closeModal() {
if (!this.get('closeDisabled')) {
this._super(...arguments);
}
}
}
});

View file

@ -0,0 +1,26 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
export default ModalComponent.extend({
actions: {
updateEmail(newEmail) {
this.set('model.email', newEmail);
this.set('model.hasValidated', Ember.A());
this.get('model.errors').clear();
},
confirm() {
let confirmAction = this.get('confirm');
this.set('submitting', true);
confirmAction().then(() => {
this.send('closeModal');
}).finally(() => {
if (!this.get('isDestroying') && !this.get('isDestroyed')) {
this.set('submitting', false);
}
});
}
}
});

View file

@ -0,0 +1,140 @@
import Ember from 'ember';
import Table from 'ember-light-table';
import PaginationMixin from 'ghost/mixins/pagination';
import ghostPaths from 'ghost/utils/ghost-paths';
const {
$,
assign,
computed,
inject: {service}
} = Ember;
export default Ember.Controller.extend(PaginationMixin, {
queryParams: ['order', 'direction'],
order: 'created_at',
direction: 'desc',
paginationModel: 'subscriber',
total: 0,
table: null,
session: service(),
// paginationSettings is replaced by the pagination mixin so we need a
// getter/setter CP here so that we don't lose the dynamic order param
paginationSettings: computed('order', 'direction', {
get() {
let order = this.get('order');
let direction = this.get('direction');
let currentSettings = this._paginationSettings || {
limit: 30
};
return assign({}, currentSettings, {
order: `${order} ${direction}`
});
},
set(key, value) {
this._paginationSettings = value;
return value;
}
}),
columns: computed('order', 'direction', function () {
let order = this.get('order');
let direction = this.get('direction');
return [{
label: 'Subscriber',
valuePath: 'email',
sorted: order === 'email',
ascending: direction === 'asc'
}, {
label: 'Subscription Date',
valuePath: 'createdAt',
format(value) {
return value.format('MMMM DD, YYYY');
},
sorted: order === 'created_at',
ascending: direction === 'asc'
}, {
label: 'Status',
valuePath: 'status',
sorted: order === 'status',
ascending: direction === 'asc'
}];
}),
initializeTable() {
this.set('table', new Table(this.get('columns'), this.get('subscribers')));
},
// capture the total from the server any time we fetch a new page
didReceivePaginationMeta(meta) {
if (meta && meta.pagination) {
this.set('total', meta.pagination.total);
}
},
actions: {
loadFirstPage() {
let table = this.get('table');
console.log('loadFirstPage', this.get('paginationSettings'));
return this._super(...arguments).then((results) => {
table.addRows(results);
return results;
});
},
loadNextPage() {
let table = this.get('table');
return this._super(...arguments).then((results) => {
table.addRows(results);
return results;
});
},
sortByColumn(column) {
let table = this.get('table');
if (column.sorted) {
this.setProperties({
order: column.get('valuePath').trim().underscore(),
direction: column.ascending ? 'asc' : 'desc'
});
table.setRows([]);
this.send('loadFirstPage');
}
},
addSubscriber(subscriber) {
this.get('table').insertRowAt(0, subscriber);
this.incrementProperty('total');
},
reset() {
this.get('table').setRows([]);
this.send('loadFirstPage');
},
exportData() {
let exportUrl = ghostPaths().url.api('subscribers/csv');
let accessToken = this.get('session.data.authenticated.access_token');
let downloadURL = `${exportUrl}?access_token=${accessToken}`;
let iframe = $('#iframeDownload');
if (iframe.length === 0) {
iframe = $('<iframe>', {id: 'iframeDownload'}).hide().appendTo('body');
}
iframe.attr('src', downloadURL);
}
}
});

View file

@ -47,12 +47,64 @@ function paginatedResponse(modelName, allModels, request) {
};
}
function mockSubscribers(server) {
server.get('/subscribers/', function (db, request) {
let response = paginatedResponse('subscribers', db.subscribers, request);
return response;
});
server.post('/subscribers/', function (db, request) {
let [attrs] = JSON.parse(request.requestBody).subscribers;
let subscriber;
attrs.created_at = new Date();
attrs.created_by = 0;
subscriber = db.subscribers.insert(attrs);
return {
subscriber
};
});
server.put('/subscribers/:id/', function (db, request) {
let {id} = request.params;
let [attrs] = JSON.parse(request.requestBody).subscribers;
let subscriber = db.subscribers.update(id, attrs);
return {
subscriber
};
});
server.del('/subscribers/:id/', function (db, request) {
db.subscribers.remove(request.params.id);
return new Mirage.Response(204, {}, {});
});
server.post('/subscribers/csv/', function (/*db, request*/) {
// NB: we get a raw FormData object with no way to inspect it in Chrome
// until version 50 adds the additional read methods
// https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility
server.createList('subscriber', 50);
return {
imported: 50,
duplicates: 3,
invalid: 2
};
});
}
export default function () {
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server
this.namespace = 'ghost/api/v0.1'; // make this `api`, for example, if your API is namespaced
// this.timing = 400; // delay for each request, automatically set to 0 during testing
this.timing = 400; // delay for each request, automatically set to 0 during testing
// Mock endpoints here to override real API requests during development
mockSubscribers(this);
// keep this line, it allows all other API requests to hit the real server
this.passthrough();
@ -251,6 +303,10 @@ export function testConfig() {
};
});
/* Subscribers ---------------------------------------------------------- */
mockSubscribers(this);
/* Tags ----------------------------------------------------------------- */
this.post('/tags/', function (db, request) {

View file

@ -0,0 +1,21 @@
import Mirage, {faker} from 'ember-cli-mirage';
let randomDate = function randomDate(start = moment().subtract(30, 'days').toDate(), end = new Date()) {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
};
let statuses = ['pending', 'subscribed'];
// jscs:disable requireBlocksOnNewline
// jscs:disable requireCamelCaseOrUpperCaseIdentifiers
export default Mirage.Factory.extend({
uuid(i) { return `subscriber-${i}`; },
name() { return `${faker.name.firstName()} ${faker.name.lastName()}`; },
email() { return faker.internet.email(); },
status() { return statuses[Math.floor(Math.random() * statuses.length)]; },
created_at() { return randomDate(); },
updated_at: null,
created_by: 0,
updated_by: null,
unsubscribed_at: null
});

View file

@ -125,7 +125,7 @@ export default [
id: 12,
uuid: 'd806f358-7996-4c74-b153-8876959c4b70',
key: 'labs',
value: '{"codeInjectionUI":true}',
value: '{"codeInjectionUI":true,"subscribers":true}',
type: 'blog',
created_at: '2015-01-12T18:29:01.000Z',
created_by: 1,

View file

@ -1,6 +1,8 @@
export default function (/* server */) {
export default function (server) {
// Seed your development database using your factories. This
// data will not be loaded in your tests.
// server.createList('contact', 10);
server.createList('subscriber', 125);
}

View file

@ -4,6 +4,7 @@ import getRequestErrorMessage from 'ghost/utils/ajax';
const {
Mixin,
computed,
RSVP,
inject: {service}
} = Ember;
@ -34,7 +35,7 @@ export default Mixin.create({
init() {
let paginationSettings = this.get('paginationSettings');
let settings = Ember.$.extend({}, defaultPaginationSettings, paginationSettings);
let settings = Ember.assign({}, defaultPaginationSettings, paginationSettings);
this._super(...arguments);
this.set('paginationSettings', settings);
@ -63,7 +64,7 @@ export default Mixin.create({
let paginationSettings = this.get('paginationSettings');
let modelName = this.get('paginationModel');
paginationSettings.page = 1;
this.set('paginationSettings.page', 1);
this.set('isLoading', true);
@ -93,7 +94,7 @@ export default Mixin.create({
let nextPage = metadata.pagination && metadata.pagination.next;
let paginationSettings = this.get('paginationSettings');
if (nextPage) {
if (nextPage && !this.get('isLoading')) {
this.set('isLoading', true);
this.set('paginationSettings.page', nextPage);
@ -105,6 +106,8 @@ export default Mixin.create({
}).finally(() => {
this.set('isLoading', false);
});
} else {
return RSVP.resolve([]);
}
},

View file

@ -3,18 +3,20 @@ import DS from 'ember-data';
import Model from 'ember-data/model';
import getRequestErrorMessage from 'ghost/utils/ajax';
import ValidatorExtensions from 'ghost/utils/validator-extensions';
import PostValidator from 'ghost/validators/post';
import SetupValidator from 'ghost/validators/setup';
import SignupValidator from 'ghost/validators/signup';
import SigninValidator from 'ghost/validators/signin';
import SettingValidator from 'ghost/validators/setting';
import ResetValidator from 'ghost/validators/reset';
import UserValidator from 'ghost/validators/user';
import TagSettingsValidator from 'ghost/validators/tag-settings';
import NavItemValidator from 'ghost/validators/nav-item';
import InviteUserValidator from 'ghost/validators/invite-user';
import NavItemValidator from 'ghost/validators/nav-item';
import PostValidator from 'ghost/validators/post';
import ResetValidator from 'ghost/validators/reset';
import SettingValidator from 'ghost/validators/setting';
import SetupValidator from 'ghost/validators/setup';
import SigninValidator from 'ghost/validators/signin';
import SignupValidator from 'ghost/validators/signup';
import SlackIntegrationValidator from 'ghost/validators/slack-integration';
import SubscriberValidator from 'ghost/validators/subscriber';
import TagSettingsValidator from 'ghost/validators/tag-settings';
import UserValidator from 'ghost/validators/user';
import ValidatorExtensions from 'ghost/utils/validator-extensions';
const {Mixin, RSVP, isArray} = Ember;
const {Errors} = DS;
@ -36,17 +38,18 @@ export default Mixin.create({
// in that case the model will be the class that the ValidationEngine
// was mixed into, i.e. the controller or Ember Data model.
validators: {
post: PostValidator,
setup: SetupValidator,
signup: SignupValidator,
signin: SigninValidator,
setting: SettingValidator,
reset: ResetValidator,
user: UserValidator,
tag: TagSettingsValidator,
navItem: NavItemValidator,
inviteUser: InviteUserValidator,
slackIntegration: SlackIntegrationValidator
navItem: NavItemValidator,
post: PostValidator,
reset: ResetValidator,
setting: SettingValidator,
setup: SetupValidator,
signin: SigninValidator,
signup: SignupValidator,
slackIntegration: SlackIntegrationValidator,
subscriber: SubscriberValidator,
tag: TagSettingsValidator,
user: UserValidator
},
// This adds the Errors object to the validation engine, and shouldn't affect

23
app/models/subscriber.js Normal file
View file

@ -0,0 +1,23 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import {belongsTo} from 'ember-data/relationships';
import ValidationEngine from 'ghost/mixins/validation-engine';
export default Model.extend(ValidationEngine, {
validationType: 'subscriber',
uuid: attr('string'),
name: attr('string'),
email: attr('string'),
status: attr('string'),
subscribedUrl: attr('string'),
subscribedReferrer: attr('string'),
unsubscribedUrl: attr('string'),
unsubscribedAt: attr('moment-date'),
createdAt: attr('moment-date'),
updatedAt: attr('moment-date'),
createdBy: attr('number'),
updatedBy: attr('number'),
post: belongsTo('post')
});

View file

@ -59,6 +59,11 @@ Router.map(function () {
this.route('slack', {path: 'slack'});
});
this.route('subscribers', function() {
this.route('new');
this.route('import');
});
this.route('error404', {path: '/*path'});
});

54
app/routes/subscribers.js Normal file
View file

@ -0,0 +1,54 @@
import Ember from 'ember';
import AuthenticatedRoute from 'ghost/routes/authenticated';
const {
RSVP,
inject: {service}
} = Ember;
export default AuthenticatedRoute.extend({
titleToken: 'Subscribers',
feature: service(),
// redirect if subscribers is disabled or user isn't owner/admin
beforeModel() {
this._super(...arguments);
let promises = {
user: this.get('session.user'),
subscribers: this.get('feature.subscribers')
};
return RSVP.hash(promises).then((hash) => {
let {user, subscribers} = hash;
if (!subscribers || !(user.get('isOwner') || user.get('isAdmin'))) {
return this.transitionTo('posts');
}
});
},
setupController(controller) {
this._super(...arguments);
controller.initializeTable();
controller.send('loadFirstPage');
},
resetController(controller, isExiting) {
this._super(...arguments);
if (isExiting) {
controller.set('order', 'created_at');
controller.set('direction', 'desc');
}
},
actions: {
addSubscriber(subscriber) {
this.get('controller').send('addSubscriber', subscriber);
},
reset() {
this.get('controller').send('reset');
}
}
});

View file

@ -0,0 +1,9 @@
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
cancel() {
this.transitionTo('subscribers');
}
}
});

View file

@ -0,0 +1,37 @@
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.get('store').createRecord('subscriber');
},
deactivate() {
let subscriber = this.controller.get('model');
this._super(...arguments);
if (subscriber.get('isNew')) {
this.rollbackModel();
}
},
rollbackModel() {
let subscriber = this.controller.get('model');
subscriber.rollbackAttributes();
},
actions: {
save() {
let subscriber = this.controller.get('model');
return subscriber.save().then((saved) => {
this.send('addSubscriber', saved);
return saved;
});
},
cancel() {
this.rollbackModel();
this.transitionTo('subscribers');
}
}
});

View file

@ -31,6 +31,7 @@ export default Service.extend({
notifications: service(),
publicAPI: feature('publicAPI'),
subscribers: feature('subscribers'),
_settings: null,

View file

@ -45,3 +45,4 @@
@import "layouts/error.css";
@import "layouts/apps.css";
@import "layouts/packages.css";
@import "layouts/subscribers.css";

View file

@ -61,10 +61,6 @@
/* The modal
/* ---------------------------------------------------------- */
.fullscreen-modal .gh-image-uploader {
margin: 0;
}
/* Modal content
/* ---------------------------------------------------------- */
@ -143,6 +139,10 @@
margin-left: 0;
}
.modal-body .gh-image-uploader {
margin: 0;
}
/* Content Modifiers
/* ---------------------------------------------------------- */

View file

@ -0,0 +1,55 @@
/* Subscribers Management /ghost/subscribers/
/* ---------------------------------------------------------- */
.view-subscribers .view-container {
display: flex;
flex-direction: row;
min-height: 100%;
}
/* Table (left pane)
/* ---------------------------------------------------------- */
.subscribers-table {
flex-grow: 1;
overflow-y: auto;
padding: 0 12px; /* ember-light-table has 8px padding on cells */
max-height: 100%;
}
.subscribers-table table {
margin: 0;
}
/* Sidebar (right pane)
/* ---------------------------------------------------------- */
.subscribers-sidebar {
width: 350px;
border-left: 1px solid #dfe1e3;
}
.subscribers-import-buttons {
display: flex;
flex-direction: row;
}
.subscribers-import-buttons .btn {
flex-grow: 1;
margin-right: 10px;
}
.subscribers-import-buttons .btn:last-of-type {
margin-right: 0;
}
/* Import modal
/* ---------------------------------------------------------- */
.subscribers-import-results {
margin: 0;
width: auto;
}

View file

@ -59,3 +59,21 @@ table td,
.table.plain tbody > tr:nth-child(odd) > th {
background: transparent;
}
/* Ember Light Table
/* ---------------------------------------------------------- */
.ember-light-table .lt-column .lt-sort-icon {
float: none;
margin-left: 0.5em;
}
.lt-sort-icon.icon-ascending:before {
content: "▲";
font-size: 0.7em;
}
.lt-sort-icon.icon-descending:before {
content: "▼";
font-size: 0.5em;
}

View file

@ -0,0 +1,20 @@
{{#if file}}
{{!-- Upload in progress! --}}
{{#if failureMessage}}
<div class="failed">{{failureMessage}}</div>
{{/if}}
<div class="progress-container">
<div class="progress">
<div class="bar {{if failureMessage "fail"}}" style={{progressStyle}}></div>
</div>
</div>
{{#if failureMessage}}
<button class="btn btn-green" {{action "reset"}}>Try Again</button>
{{/if}}
{{else}}
<div class="upload-form">
{{#x-file-input multiple=false alt=labelText action=(action 'fileSelected') accept="text/csv"}}
<div class="description">{{labelText}}</div>
{{/x-file-input}}
</div>
{{/if}}

View file

@ -25,6 +25,11 @@
{{!<li><a href="#"><i class="icon-user"></i>My Posts</a></li>}}
<li>{{#link-to "team" classNames="gh-nav-main-users"}}<i class="icon-team"></i>Team{{/link-to}}</li>
{{!<li><a href="#"><i class="icon-idea"></i>Ideas</a></li>}}
{{#if subscribersEnabled}}
{{#if (gh-user-can-admin session.user)}}
<li>{{#link-to "subscribers" classNames="gh-nav-main-subscribers"}}<i class="icon-mail"></i>Subscribers{{/link-to}}</li>
{{/if}}
{{/if}}
</ul>
{{#if (gh-user-can-admin session.user)}}
<ul class="gh-nav-list gh-nav-settings">

View file

@ -0,0 +1,17 @@
{{#gh-light-table table scrollContainer=".subscribers-table" scrollBuffer=100 onScrolledToBottom=(action 'onScrolledToBottom') as |t|}}
{{t.head onColumnClick=(action sortByColumn) iconAscending="icon-ascending" iconDescending="icon-descending"}}
{{#t.body canSelect=false as |body|}}
{{#if isLoading}}
{{#body.loader}}
Loading...
{{/body.loader}}
{{else}}
{{#if table.isEmpty}}
{{#body.no-data}}
No subscribers found.
{{/body.no-data}}
{{/if}}
{{/if}}
{{/t.body}}
{{/gh-light-table}}

View file

@ -0,0 +1,49 @@
<header class="modal-header">
<h1>
{{#if response}}
Import Successful
{{else}}
Import Subscribers
{{/if}}
</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
{{#liquid-if response class="fade-transition"}}
<table class="subscribers-import-results">
<tr>
<td>Imported:</td>
<td align="left">{{response.imported}}</td>
</tr>
{{#if response.duplicates}}
<tr>
<td>Duplicates:</td>
<td align="left">{{response.duplicates}}</td>
</tr>
{{/if}}
{{#if response.invalid}}
<tr>
<td>Invalid:</td>
<td align="left">{{response.invalid}}</td>
</tr>
{{/if}}
</table>
{{else}}
{{gh-file-uploader
url=uploadUrl
paramName="subscribersfile"
labelText="Select or drag-and-drop a CSV file."
uploadStarted=(action 'uploadStarted')
uploadFinished=(action 'uploadFinished')
uploadSuccess=(action 'uploadSuccess')}}
<span>Ensure the CSV has a column named <code>email</code>.</span>
{{/liquid-if}}
</div>
<div class="modal-footer">
<button {{action "closeModal"}} disabled={{closeDisabled}} class="btn btn-default btn-minor">
{{#if response}}Close{{else}}Cancel{{/if}}
</button>
</div>

View file

@ -0,0 +1,29 @@
<header class="modal-header">
<h1>Add a Subscriber</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
<fieldset>
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="email"}}
<label for="new-subscriber-email">Email Address</label>
<input type="email"
value={{model.email}}
oninput={{action "updateEmail" value="target.value"}}
id="new-subscriber-email"
class="gh-input email"
placeholder="Email Address"
name="email"
autofocus="autofocus"
autocapitalize="off"
autocorrect="off">
{{gh-error-message errors=model.errors property="email"}}
{{/gh-form-group}}
</fieldset>
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="btn btn-default btn-minor">Cancel</button>
{{#gh-spin-button action="confirm" class="btn btn-green" submitting=submitting}}Add{{/gh-spin-button}}
</div>

View file

@ -50,6 +50,9 @@
{{#gh-feature-flag "publicAPI"}}
Public API - For full instructions, read the <a href="http://support.ghost.org/public-api-beta/">developer guide</a>.
{{/gh-feature-flag}}
{{#gh-feature-flag "subscribers"}}
Subscribers - Allow visitors to subscribe to e-mail updates of your new posts
{{/gh-feature-flag}}
</div>
</fieldset>
</form>

View file

@ -0,0 +1,40 @@
<section class="gh-view view-subscribers">
<header class="view-header">
{{#gh-view-title openMobileMenu="openMobileMenu"}}<span>Subscribers</span>{{/gh-view-title}}
<div class="view-actions">
{{#link-to "subscribers.new" class="btn btn-green"}}Add Subscriber{{/link-to}}
</div>
</header>
<section class="view-container">
{{gh-subscribers-table
table=table
isLoading=isLoading
loadNextPage=(action 'loadNextPage')
sortByColumn=(action 'sortByColumn')}}
<div class="subscribers-sidebar">
<div class="settings-menu-header">
<h4>Import Subscribers</h4>
</div>
<div class="settings-menu-content subscribers-import-buttons">
{{#link-to "subscribers.import" class="btn"}}Import CSV{{/link-to}}
<a {{action 'exportData'}} class="btn">Export CSV</a>
</div>
<div class="settings-menu-header">
<h4>Quick Stats</h4>
</div>
<div class="settings-menu-content">
<ul>
<li>
Total Subscribers:
<span id="total-subscribers">{{total}}</span>
</li>
</ul>
</div>
</div>
</section>
</section>
{{outlet}}

View file

@ -0,0 +1,3 @@
{{gh-fullscreen-modal "import-subscribers"
confirm=(route-action "reset")
close=(route-action "cancel")}}

View file

@ -0,0 +1,4 @@
{{gh-fullscreen-modal "new-subscriber"
model=model
confirm=(route-action "save")
close=(route-action "cancel")}}

View file

@ -8,4 +8,9 @@ export default function () {
this.use('tether', ['fade', {duration: 150}], ['fade', {duration: 150}]),
this.reverse('tether', ['fade', {duration: 80}], ['fade', {duration: 150}])
);
this.transition(
this.hasClass('fade-transition'),
this.use('crossFade', {duration: 100})
);
}

View file

@ -0,0 +1,19 @@
import BaseValidator from './base';
export default BaseValidator.create({
properties: ['email'],
email(model) {
let email = model.get('email');
if (validator.empty(email)) {
model.get('errors').add('email', 'Please enter an email.');
model.get('hasValidated').pushObject('email');
this.invalidate();
} else if (!validator.isEmail(email)) {
model.get('errors').add('email', 'Invalid email.');
model.get('hasValidated').pushObject('email');
this.invalidate();
}
}
});

View file

@ -0,0 +1,200 @@
/* jshint expr:true */
import {
describe,
it,
beforeEach,
afterEach
} from 'mocha';
import { expect } from 'chai';
import startApp from '../helpers/start-app';
import destroyApp from '../helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost/tests/helpers/ember-simple-auth';
describe('Acceptance: Subscribers', function() {
let application;
beforeEach(function() {
application = startApp();
});
afterEach(function() {
destroyApp(application);
});
it('redirects to signin when not authenticated', function () {
invalidateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/signin');
});
});
it('redirects editors to posts', function () {
let role = server.create('role', {name: 'Editor'});
let user = server.create('user', {roles: [role]});
authenticateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/');
expect(find('.gh-nav-main a:contains("Subscribers")').length, 'sidebar link is visible')
.to.equal(0);
});
});
it('redirects authors to posts', function () {
let role = server.create('role', {name: 'Author'});
let user = server.create('user', {roles: [role]});
authenticateSession(application);
visit('/subscribers');
andThen(function () {
expect(currentURL()).to.equal('/');
expect(find('.gh-nav-main a:contains("Subscribers")').length, 'sidebar link is visible')
.to.equal(0);
});
});
describe('an admin', function () {
beforeEach(function () {
let role = server.create('role', {name: 'Administrator'});
let user = server.create('user', {roles: [role]});
server.loadFixtures();
return authenticateSession(application);
});
it('can manage subscribers', function () {
server.createList('subscriber', 40);
authenticateSession(application);
visit('/');
click('.gh-nav-main a:contains("Subscribers")');
andThen(function() {
// it navigates to the correct page
expect(currentPath()).to.equal('subscribers.index');
// it has correct page title
expect(document.title, 'page title')
.to.equal('Subscribers - Test Blog');
// it loads the first page
expect(find('.subscribers-table .lt-body .lt-row').length, 'number of subscriber rows')
.to.equal(30);
// it shows the total number of subscribers
expect(find('#total-subscribers').text().trim(), 'displayed subscribers total')
.to.equal('40');
// it defaults to sorting by created_at desc
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.order).to.equal('created_at desc');
let createdAtHeader = find('.subscribers-table th:contains("Subscription Date")');
expect(createdAtHeader.hasClass('is-sorted'), 'createdAt column is sorted')
.to.be.true;
expect(createdAtHeader.find('.icon-descending').length, 'createdAt column has descending icon')
.to.equal(1);
});
// click the column to re-order
click('th:contains("Subscription Date")');
andThen(function () {
// it flips the directions and re-fetches
let [lastRequest] = server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.order).to.equal('created_at asc');
let createdAtHeader = find('.subscribers-table th:contains("Subscription Date")');
expect(createdAtHeader.find('.icon-ascending').length, 'createdAt column has ascending icon')
.to.equal(1);
// scroll to the bottom of the table to simulate infinite scroll
find('.subscribers-table').scrollTop(find('.subscribers-table .ember-light-table').height());
});
// trigger infinite scroll
triggerEvent('.subscribers-table', 'scroll');
andThen(function () {
// it loads the next page
expect(find('.subscribers-table .lt-body .lt-row').length, 'number of subscriber rows after infinite-scroll')
.to.equal(40);
});
// click the add subscriber button
click('.btn:contains("Add Subscriber")');
andThen(function () {
// it displays the add subscriber modal
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed')
.to.equal(1);
});
// cancel the modal
click('.fullscreen-modal .btn:contains("Cancel")');
andThen(function () {
// it closes the add subscriber modal
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed after cancel')
.to.equal(0);
});
// save a new subscriber
click('.btn:contains("Add Subscriber")');
fillIn('.fullscreen-modal input[name="email"]', 'test@example.com');
click('.fullscreen-modal .btn:contains("Add")');
andThen(function () {
// the add subscriber modal is closed
expect(find('.fullscreen-modal').length, 'add subscriber modal displayed after save')
.to.equal(0);
// the subscriber is added to the table
expect(find('.subscribers-table .lt-body .lt-row:first-of-type .lt-cell:first-of-type').text().trim(), 'first email in list after addition')
.to.equal('test@example.com');
// the table is scrolled to the top
// TODO: implement scroll to new record after addition
// expect(find('.subscribers-table').scrollTop(), 'scroll position after addition')
// .to.equal(0);
// the subscriber total is updated
expect(find('#total-subscribers').text().trim(), 'subscribers total after addition')
.to.equal('41');
});
// click the import subscribers button
click('.btn:contains("Import CSV")');
andThen(function () {
// it displays the import subscribers modal
expect(find('.fullscreen-modal').length, 'import subscribers modal displayed')
.to.equal(1);
});
// cancel the modal
click('.fullscreen-modal .btn:contains("Cancel")');
andThen(function () {
// it closes the import subscribers modal
expect(find('.fullscreen-modal').length, 'import subscribers modal displayed after cancel')
.to.equal(0);
});
// TODO: how to simulate file upload?
// re-open import modal
// upload a file
// modal title changes
// modal button changes
// table is reset
// close modal
});
});
});

View file

@ -0,0 +1,93 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Ember from 'ember';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-file-uploader',
'Integration: Component: gh-file-uploader',
{
integration: true
},
function() {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
this.render(hbs`{{gh-file-uploader}}`);
expect(this.$('label').text().trim(), 'default label')
.to.equal('Select or drag-and-drop a file');
});
it('renders form with supplied label text', function () {
this.set('labelText', 'My label');
this.render(hbs`{{gh-file-uploader labelText=labelText}}`);
expect(this.$('label').text().trim(), 'label')
.to.equal('My label');
});
it('generates request to supplied endpoint', function (done) {
stubSuccessfulUpload(server);
this.set('uploadUrl', '/ghost/api/v0.1/uploads/');
this.render(hbs`{{gh-file-uploader url=uploadUrl}}`);
this.$('input[type="file"]').trigger('change');
wait().then(() => {
expect(server.handledRequests.length).to.equal(1);
expect(server.handledRequests[0].url).to.equal('/ghost/api/v0.1/uploads/');
done();
});
});
it('handles drag over/leave', function () {
this.render(hbs`{{gh-file-uploader}}`);
run(() => {
this.$('.gh-image-uploader').trigger('dragover');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.true;
run(() => {
this.$('.gh-image-uploader').trigger('dragleave');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.false;
});
}
);

View file

@ -0,0 +1,25 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Table from 'ember-light-table';
describeComponent(
'gh-subscribers-table',
'Integration: Component: gh-subscribers-table',
{
integration: true
},
function() {
it('renders', function() {
this.set('table', new Table([], []));
this.set('sortByColumn', function () {});
this.render(hbs`{{gh-subscribers-table table=table sortByColumn=(action sortByColumn)}}`);
expect(this.$()).to.have.length(1);
});
}
);

View file

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/import-subscribers',
'Integration: Component: modals/import-subscribers',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/import-subscribers}}
// template content
// {{/modals/import-subscribers}}
// `);
this.render(hbs`{{modals/import-subscribers}}`);
expect(this.$()).to.have.length(1);
});
}
);

View file

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/new-subscriber',
'Integration: Component: modals/new-subscriber',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/new-subscriber}}
// template content
// {{/modals/new-subscriber}}
// `);
this.render(hbs`{{modals/new-subscriber}}`);
expect(this.$()).to.have.length(1);
});
}
);

View file

@ -0,0 +1,312 @@
/* jshint expr:true */
/* global Blob */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import Ember from 'ember';
import sinon from 'sinon';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const createFile = function (content = ['test'], options = {}) {
let {
name,
type,
lastModifiedDate
} = options;
let file = new Blob(content, {type: type ? type : 'text/plain'});
file.name = name ? name : 'text.txt';
return file;
};
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-file-uploader',
'Unit: Component: gh-file-uploader',
{
needs: [
'service:ajax',
'service:session', // used by ajax service
'component:x-file-input'
],
unit: true
},
function() {
let server, url;
beforeEach(function () {
server = new Pretender();
url = '/ghost/api/v0.1/uploads/';
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
// creates the component instance
let component = this.subject();
// renders the component on the page
this.render();
expect(component).to.be.ok;
expect(this.$()).to.have.length(1);
});
it('fires uploadSuccess action on successful upload', function (done) {
let uploadSuccess = sinon.spy();
let component = this.subject({url, uploadSuccess});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadSuccess.calledOnce).to.be.true;
expect(uploadSuccess.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
it('fires uploadStarted action on upload start', function (done) {
let uploadStarted = sinon.spy();
let component = this.subject({url, uploadStarted});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadStarted.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on successful upload', function (done) {
let uploadFinished = sinon.spy();
let component = this.subject({url, uploadFinished});
let file = createFile();
stubSuccessfulUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on failed upload', function (done) {
let uploadFinished = sinon.spy();
let component = this.subject({url, uploadFinished});
let file = createFile();
stubFailedUpload(server);
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('displays invalid file type error', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 415, 'UnsupportedMediaTypeError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file type you uploaded is not supported/);
expect(this.$('.btn-green').length, 'reset button is displayed').to.equal(1);
expect(this.$('.btn-green').text()).to.equal('Try Again');
done();
});
});
it('displays file too large for server error', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 413, 'RequestEntityTooLargeError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file you uploaded was larger/);
done();
});
});
it('handles file too large error directly from the web server', function (done) {
let component = this.subject({url});
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [413, {}, ''];
});
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The file you uploaded was larger/);
done();
});
});
it('displays other server-side error with message', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Error: UnknownError/);
done();
});
});
it('handles unknown failure', function (done) {
let component = this.subject({url});
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [500, {'Content-Type': 'application/json'}, ''];
});
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Something went wrong/);
done();
});
});
it('can be reset after a failed upload', function (done) {
let component = this.subject({url});
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
run(() => {
this.$('.btn-green').click();
});
});
wait().then(() => {
expect(this.$('input[type="file"]').length).to.equal(1);
done();
});
});
it('displays upload progress', function (done) {
let component = this.subject({url, uploadFinished: done});
let file = createFile();
// pretender fires a progress event every 50ms
stubSuccessfulUpload(server, 150);
this.render();
run(() => {
component.send('fileSelected', [file]);
});
// after 75ms we should have had one progress event
run.later(this, function () {
expect(this.$('.progress .bar').length).to.equal(1);
let [_, percentageWidth] = this.$('.progress .bar').attr('style').match(/width: (\d+)%?/);
expect(percentageWidth).to.be.above(0);
expect(percentageWidth).to.be.below(100);
}, 75);
});
it('triggers file upload on file drop', function (done) {
let uploadSuccess = sinon.spy();
let component = this.subject({url, uploadSuccess});
let file = createFile();
let drop = Ember.$.Event('drop', {
dataTransfer: {
files: [file]
}
});
stubSuccessfulUpload(server);
this.render();
run(() => {
this.$().trigger(drop);
});
wait().then(() => {
expect(uploadSuccess.calledOnce).to.be.true;
expect(uploadSuccess.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
}
);

View file

@ -0,0 +1,21 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'controller:subscribers',
'Unit: Controller: subscribers',
{
needs: ['service:notifications']
},
function() {
// Replace this with your real tests.
it('exists', function() {
let controller = this.subject();
expect(controller).to.be.ok;
});
}
);

View file

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import { describeModel, it } from 'ember-mocha';
describeModel(
'subscriber',
'Unit: Model: subscriber',
{
// Specify the other units that are required for this test.
needs: ['model:post']
},
function() {
// Replace this with your real tests.
it('exists', function() {
let model = this.subject();
// var store = this.store();
expect(model).to.be.ok;
});
}
);

View file

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers',
'Unit: Route: subscribers',
{
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View file

@ -0,0 +1,21 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers/import',
'SubscribersImportRoute',
{
// Specify the other units that are required for this test.
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View file

@ -0,0 +1,20 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
describeModule(
'route:subscribers/new',
'Unit: Route: subscribers/new',
{
needs: ['service:notifications']
},
function() {
it('exists', function() {
let route = this.subject();
expect(route).to.be.ok;
});
}
);

View file

@ -0,0 +1,77 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describe,
it
} from 'mocha';
import Ember from 'ember';
import ValidationEngine from 'ghost/mixins/validation-engine';
const {run} = Ember;
const Subscriber = Ember.Object.extend(ValidationEngine, {
validationType: 'subscriber',
email: null
});
describe('Unit: Validator: subscriber', function () {
it('validates email by default', function () {
let subscriber = Subscriber.create({});
let properties = subscriber.get('validators.subscriber.properties');
console.log(subscriber);
expect(properties, 'properties').to.include('email');
});
it('passes with a valid email', function () {
let subscriber = Subscriber.create({email: 'test@example.com'});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
expect(passed, 'passed').to.be.true;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
it('validates email presence', function () {
let subscriber = Subscriber.create({});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
let emailErrors = subscriber.get('errors').errorsFor('email').get(0);
expect(emailErrors.attribute, 'errors.email.attribute').to.equal('email');
expect(emailErrors.message, 'errors.email.message').to.equal('Please enter an email.');
expect(passed, 'passed').to.be.false;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
it('validates email', function () {
let subscriber = Subscriber.create({email: 'foo'});
let passed = false;
run(() => {
subscriber.validate({property: 'email'}).then(() => {
passed = true;
});
});
let emailErrors = subscriber.get('errors').errorsFor('email').get(0);
expect(emailErrors.attribute, 'errors.email.attribute').to.equal('email');
expect(emailErrors.message, 'errors.email.message').to.equal('Invalid email.');
expect(passed, 'passed').to.be.false;
expect(subscriber.get('hasValidated'), 'hasValidated').to.include('email');
});
});

View file

@ -1,4 +1,3 @@
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
/* jshint expr:true */
import { expect } from 'chai';
import {