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:
parent
b7f5b00e10
commit
67765897b2
48 changed files with 1804 additions and 33 deletions
150
app/components/gh-file-uploader.js
Normal file
150
app/components/gh-file-uploader.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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
|
||||
|
|
27
app/components/gh-light-table.js
Normal file
27
app/components/gh-light-table.js
Normal 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));
|
||||
}
|
||||
});
|
|
@ -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');
|
||||
|
|
17
app/components/gh-subscribers-table.js
Normal file
17
app/components/gh-subscribers-table.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
43
app/components/modals/import-subscribers.js
Normal file
43
app/components/modals/import-subscribers.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
26
app/components/modals/new-subscriber.js
Normal file
26
app/components/modals/new-subscriber.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
140
app/controllers/subscribers.js
Normal file
140
app/controllers/subscribers.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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) {
|
||||
|
|
21
app/mirage/factories/subscriber.js
Normal file
21
app/mirage/factories/subscriber.js
Normal 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
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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([]);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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
23
app/models/subscriber.js
Normal 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')
|
||||
});
|
|
@ -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
54
app/routes/subscribers.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
9
app/routes/subscribers/import.js
Normal file
9
app/routes/subscribers/import.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Ember from 'ember';
|
||||
|
||||
export default Ember.Route.extend({
|
||||
actions: {
|
||||
cancel() {
|
||||
this.transitionTo('subscribers');
|
||||
}
|
||||
}
|
||||
});
|
37
app/routes/subscribers/new.js
Normal file
37
app/routes/subscribers/new.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
|
@ -31,6 +31,7 @@ export default Service.extend({
|
|||
notifications: service(),
|
||||
|
||||
publicAPI: feature('publicAPI'),
|
||||
subscribers: feature('subscribers'),
|
||||
|
||||
_settings: null,
|
||||
|
||||
|
|
|
@ -45,3 +45,4 @@
|
|||
@import "layouts/error.css";
|
||||
@import "layouts/apps.css";
|
||||
@import "layouts/packages.css";
|
||||
@import "layouts/subscribers.css";
|
||||
|
|
|
@ -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
|
||||
/* ---------------------------------------------------------- */
|
||||
|
|
55
app/styles/layouts/subscribers.css
Normal file
55
app/styles/layouts/subscribers.css
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
20
app/templates/components/gh-file-uploader.hbs
Normal file
20
app/templates/components/gh-file-uploader.hbs
Normal 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}}
|
|
@ -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">
|
||||
|
|
17
app/templates/components/gh-subscribers-table.hbs
Normal file
17
app/templates/components/gh-subscribers-table.hbs
Normal 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}}
|
49
app/templates/components/modals/import-subscribers.hbs
Normal file
49
app/templates/components/modals/import-subscribers.hbs
Normal 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>
|
29
app/templates/components/modals/new-subscriber.hbs
Normal file
29
app/templates/components/modals/new-subscriber.hbs
Normal 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>
|
|
@ -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>
|
||||
|
|
40
app/templates/subscribers.hbs
Normal file
40
app/templates/subscribers.hbs
Normal 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}}
|
3
app/templates/subscribers/import.hbs
Normal file
3
app/templates/subscribers/import.hbs
Normal file
|
@ -0,0 +1,3 @@
|
|||
{{gh-fullscreen-modal "import-subscribers"
|
||||
confirm=(route-action "reset")
|
||||
close=(route-action "cancel")}}
|
4
app/templates/subscribers/new.hbs
Normal file
4
app/templates/subscribers/new.hbs
Normal file
|
@ -0,0 +1,4 @@
|
|||
{{gh-fullscreen-modal "new-subscriber"
|
||||
model=model
|
||||
confirm=(route-action "save")
|
||||
close=(route-action "cancel")}}
|
|
@ -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})
|
||||
);
|
||||
}
|
||||
|
|
19
app/validators/subscriber.js
Normal file
19
app/validators/subscriber.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
200
tests/acceptance/subscribers-test.js
Normal file
200
tests/acceptance/subscribers-test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
93
tests/integration/components/gh-file-uploader-test.js
Normal file
93
tests/integration/components/gh-file-uploader-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
25
tests/integration/components/gh-subscribers-table-test.js
Normal file
25
tests/integration/components/gh-subscribers-table-test.js
Normal 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);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
30
tests/integration/components/modals/new-subscriber-test.js
Normal file
30
tests/integration/components/modals/new-subscriber-test.js
Normal 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);
|
||||
});
|
||||
}
|
||||
);
|
312
tests/unit/components/gh-file-uploader-test.js
Normal file
312
tests/unit/components/gh-file-uploader-test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
21
tests/unit/controllers/subscribers-test.js
Normal file
21
tests/unit/controllers/subscribers-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
20
tests/unit/models/subscriber-test.js
Normal file
20
tests/unit/models/subscriber-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
20
tests/unit/routes/subscribers-test.js
Normal file
20
tests/unit/routes/subscribers-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
21
tests/unit/routes/subscribers/import-test.js
Normal file
21
tests/unit/routes/subscribers/import-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
20
tests/unit/routes/subscribers/new-test.js
Normal file
20
tests/unit/routes/subscribers/new-test.js
Normal 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;
|
||||
});
|
||||
}
|
||||
);
|
77
tests/unit/validators/subscriber-test.js
Normal file
77
tests/unit/validators/subscriber-test.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -1,4 +1,3 @@
|
|||
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
|
||||
/* jshint expr:true */
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
|
|
Loading…
Reference in a new issue