Merge pull request #2718 from jgable/emberData

Ember Data with Posts
This commit is contained in:
Hannah Wolfe 2014-05-29 15:27:19 +01:00
commit 0d04357e4e
25 changed files with 257 additions and 206 deletions

View File

@ -548,8 +548,10 @@ var path = require('path'),
'bower_components/jquery/dist/jquery.js',
'bower_components/handlebars/handlebars.js',
'bower_components/ember/ember.js',
'bower_components/ember-data/ember-data.js',
'bower_components/ember-resolver/dist/ember-resolver.js',
'bower_components/ic-ajax/dist/globals/main.js',
'bower_components/ember-load-initializers/ember-load-initializers.js',
'bower_components/validator-js/validator.js',
'bower_components/codemirror/lib/codemirror.js',
'bower_components/codemirror/addon/mode/overlay.js',

View File

@ -5,6 +5,8 @@
"codemirror": "4.0.1",
"Countable": "2.0.2",
"ember": "1.5.0",
"ember-data": "~1.0.0-beta.7",
"ember-load-initializers": "git://github.com/stefanpenner/ember-load-initializers.git#0.0.1",
"ember-resolver": "git://github.com/stefanpenner/ember-jj-abrams-resolver.git#181251821cf513bb58d3e192faa13245a816f75e",
"fastclick": "1.0.0",
"ghost-ui": "0.1.3",

View File

@ -0,0 +1,22 @@
import ghostPaths from 'ghost/utils/ghost-paths';
// export default DS.FixtureAdapter.extend({});
export default DS.RESTAdapter.extend({
host: window.location.origin,
namespace: ghostPaths().apiRoot.slice(1),
headers: {
'X-CSRF-Token': $('meta[name="csrf-param"]').attr('content')
},
buildURL: function (type, id) {
// Ensure trailing slashes
var url = this._super(type, id);
if (url.slice(-1) !== '/') {
url += '/';
}
return url;
}
});

View File

@ -1,13 +1,11 @@
import Resolver from 'ember/resolver';
import initFixtures from 'ghost/fixtures/init';
import injectCurrentUser from 'ghost/initializers/current-user';
import injectCsrf from 'ghost/initializers/csrf';
import {registerNotifications, injectNotifications} from 'ghost/initializers/notifications';
import registerTrailingLocationHistory from 'ghost/initializers/trailing-history';
import injectGhostPaths from 'ghost/initializers/ghost-paths';
import loadInitializers from 'ember/load-initializers';
import 'ghost/utils/link-view';
import 'ghost/utils/text-field';
Ember.MODEL_FACTORY_INJECTIONS = true;
var App = Ember.Application.extend({
/**
* These are debugging flags, they are useful during development
@ -23,11 +21,6 @@ var App = Ember.Application.extend({
initFixtures();
App.initializer(injectCurrentUser);
App.initializer(injectCsrf);
App.initializer(injectGhostPaths);
App.initializer(registerNotifications);
App.initializer(injectNotifications);
App.initializer(registerTrailingLocationHistory);
loadInitializers(App, 'ghost');
export default App;

View File

@ -13,49 +13,26 @@ var PostController = Ember.ObjectController.extend({
isDraft: equal('status', 'draft'),
willPublish: Ember.computed.oneWay('isPublished'),
isStaticPage: function (key, val) {
var self = this;
if (arguments.length > 1) {
this.set('model.page', val ? 1 : 0);
this.get('model').save('page').then(function () {
this.notifications.showSuccess('Succesfully converted ' + (val ? 'to static page' : 'to post'));
this.set('page', val ? 1 : 0);
return this.get('model').save().then(function () {
self.notifications.showSuccess('Succesfully converted to ' + (val ? 'static page' : 'post'));
return !!self.get('page');
}, this.notifications.showErrors);
}
return !!this.get('model.page');
}.property('model.page'),
isOnServer: function () {
return this.get('model.id') !== undefined;
}.property('model.id'),
return !!this.get('page');
}.property('page'),
newSlugBinding: Ember.Binding.oneWay('model.slug'),
slugPlaceholder: null,
// Requests a new slug when the title was changed
updateSlugPlaceholder: function () {
var model,
self = this,
title = this.get('title');
newSlugBinding: Ember.computed.oneWay('slug'),
// If there's a title present we want to
// validate it against existing slugs in the db
// and then update the placeholder value.
if (title) {
model = self.get('model');
model.generateSlug().then(function (slug) {
self.set('slugPlaceholder', slug);
}, function () {
self.notifications.showWarn('Unable to generate a slug for "' + title + '"');
});
} else {
// If there's no title set placeholder to blank
// and don't make an ajax request to server
// for a proper slug (as there won't be any).
self.set('slugPlaceholder', '');
}
}.observes('model.title'),
publishedAt: null,
publishedAtChanged: function () {
this.set('publishedAt', formatDate(this.get('model.published_at')));
}.observes('model.published_at'),
slugPlaceholder: function () {
return this.get('model').generateSlug();
}.property('title'),
actions: {
save: function () {
@ -78,6 +55,11 @@ var PostController = Ember.ObjectController.extend({
console.warn('Received invalid save type; ignoring.');
}
},
toggleFeatured: function () {
this.set('featured', !this.get('featured'));
this.get('model').save();
},
editSettings: function () {
var isEditing = this.toggleProperty('isEditingSettings');
if (isEditing) {
@ -91,9 +73,10 @@ var PostController = Ember.ObjectController.extend({
});
}
},
updateSlug: function () {
var newSlug = this.get('newSlug'),
slug = this.get('model.slug'),
slug = this.get('slug'),
placeholder = this.get('slugPlaceholder'),
self = this;
@ -110,17 +93,17 @@ var PostController = Ember.ObjectController.extend({
}
//Validation complete
this.set('model.slug', newSlug);
this.set('slug', newSlug);
// If the model doesn't currently
// exist on the server
// then just update the model's value
if (!this.get('isOnServer')) {
if (!this.get('isNew')) {
return;
}
this.get('model').save('slug').then(function () {
self.notifications.showSuccess('Permalink successfully changed to <strong>' + this.get('model.slug') + '</strong>.');
this.get('model').save().then(function () {
self.notifications.showSuccess('Permalink successfully changed to <strong>' + this.get('slug') + '</strong>.');
}, this.notifications.showErrors);
},
@ -182,20 +165,20 @@ var PostController = Ember.ObjectController.extend({
}
//Validation complete
this.set('model.published_at', newPubDateMoment.toDate());
this.set('published_at', newPubDateMoment.toDate());
// If the model doesn't currently
// exist on the server
// then just update the model's value
if (!this.get('isOnServer')) {
if (!this.get('isNew')) {
return;
}
this.get('model').save('published_at').then(function () {
this.get('model').save().then(function () {
this.notifications.showSuccess('Publish date successfully changed to <strong>' + this.get('publishedAt') + '</strong>.');
}, this.notifications.showErrors);
}
}
});
export default PostController;
export default PostController;

View File

@ -0,0 +1,11 @@
export default {
name: 'csrf-token',
initialize: function (container) {
container.register('csrf:token', $('meta[name="csrf-param"]').attr('content'), { instantiate: false });
container.injection('route', 'csrf', 'csrf:token');
container.injection('model', 'csrf', 'csrf:token');
container.injection('controller', 'csrf', 'csrf:token');
}
};

View File

@ -1,14 +1,34 @@
import User from 'ghost/models/user';
export default {
name: 'currentUser',
after: 'store',
initialize: function (container, application) {
var user = User.create(application.get('user') || {});
var store = container.lookup('store:main'),
preloadedUser = application.get('user');
container.register('user:current', user, { instantiate: false });
// If we don't have a user, don't do the injection
if (!preloadedUser) {
return;
}
container.injection('route', 'user', 'user:current');
container.injection('controller', 'user', 'user:current');
// Push the preloaded user into the data store
store.pushPayload({
users: [preloadedUser]
});
// Signal to wait until the user is loaded before continuing.
application.deferReadiness();
// Find the user (which should be fast since we just preloaded it in the store)
store.find('user', preloadedUser.id).then(function (user) {
// Register the value for injection
container.register('user:current', user, { instantiate: false });
// Inject into the routes and controllers as the user property.
container.injection('route', 'user', 'user:current');
container.injection('controller', 'user', 'user:current');
application.advanceReadiness();
});
}
};

View File

@ -2,6 +2,7 @@ import ghostPaths from 'ghost/utils/ghost-paths';
export default {
name: 'ghost-paths',
after: 'store',
initialize: function (container) {
container.register('ghost:paths', ghostPaths(), {instantiate: false});

View File

@ -1,21 +1,13 @@
import Notifications from 'ghost/utils/notifications';
var registerNotifications = {
name: 'registerNotifications',
initialize: function (container, application) {
application.register('notifications:main', Notifications);
}
};
var injectNotifications = {
export default {
name: 'injectNotifications',
initialize: function (container, application) {
application.register('notifications:main', Notifications);
application.inject('controller', 'notifications', 'notifications:main');
application.inject('component', 'notifications', 'notifications:main');
application.inject('route', 'notifications', 'notifications:main');
}
};
export {registerNotifications, injectNotifications};
};

View File

@ -1,56 +1,45 @@
import BaseModel from 'ghost/models/base';
var PostModel = BaseModel.extend({
url: BaseModel.apiRoot + '/posts/',
var Post = DS.Model.extend({
uuid: DS.attr('string'),
title: DS.attr('string'),
slug: DS.attr('string'),
markdown: DS.attr('string'),
html: DS.attr('string'),
image: DS.attr('string'),
featured: DS.attr('boolean'),
page: DS.attr('boolean'),
status: DS.attr('string'),
language: DS.attr('string'),
meta_title: DS.attr('string'),
meta_description: DS.attr('string'),
author: DS.belongsTo('user', { async: true }),
created_at: DS.attr('date'),
created_by: DS.belongsTo('user', { async: true }),
updated_at: DS.attr('date'),
updated_by: DS.belongsTo('user', { async: true }),
published_at: DS.attr('date'),
published_by: DS.belongsTo('user', { async: true }),
tags: DS.hasMany('tag', { async: true }),
generateSlug: function () {
// @TODO Make this request use this.get('title') once we're an actual user
var url = this.get('url') + 'slug/' + encodeURIComponent('test title') + '/';
var title = this.get('title'),
url = this.get('ghostPaths').apiUrl('posts', 'slug', encodeURIComponent(title));
return ic.ajax.request(url, {
type: 'GET'
});
},
save: function (properties) {
var url = this.url,
self = this,
type,
validationErrors = this.validate();
if (validationErrors.length) {
return Ember.RSVP.Promise(function (resolve, reject) {
return reject(validationErrors);
type: 'GET'
});
}
//If specific properties are being saved,
//this is an edit. Otherwise, it's an add.
if (properties && properties.length > 0) {
type = 'PUT';
url += this.get('id');
} else {
type = 'POST';
properties = Ember.keys(this);
}
return ic.ajax.request(url, {
type: type,
data: this.getProperties(properties)
}).then(function (model) {
return self.setProperties(model);
});
},
validate: function () {
validationErrors: function () {
var validationErrors = [];
if (!(this.get('title') && this.get('title').length)) {
if (!this.get('title.length')) {
validationErrors.push({
message: "You must specify a title for the post."
});
}
return validationErrors;
}
}.property('title')
});
export default PostModel;
export default Post;

13
core/client/models/tag.js Normal file
View File

@ -0,0 +1,13 @@
export default DS.Model.extend({
uuid: DS.attr('string'),
name: DS.attr('string'),
slug: DS.attr('string'),
description: DS.attr('string'),
parent_id: DS.attr('number'),
meta_title: DS.attr('string'),
meta_description: DS.attr('string'),
created_at: DS.attr('date'),
created_by: DS.attr('number'),
updated_at: DS.attr('date'),
updated_by: DS.attr('number'),
});

View File

@ -1,24 +1,28 @@
import BaseModel from 'ghost/models/base';
var UserModel = BaseModel.extend({
id: null,
name: null,
image: null,
var User = DS.Model.extend({
uuid: DS.attr('string'),
name: DS.attr('string'),
slug: DS.attr('string'),
password: DS.attr('string'),
email: DS.attr('string'),
image: DS.attr('string'),
cover: DS.attr('string'),
bio: DS.attr('string'),
website: DS.attr('string'),
location: DS.attr('string'),
accessibility: DS.attr('string'),
status: DS.attr('string'),
language: DS.attr('string'),
meta_title: DS.attr('string'),
meta_description: DS.attr('string'),
last_login: DS.attr('date'),
created_at: DS.attr('date'),
created_by: DS.attr('number'),
updated_at: DS.attr('date'),
updated_by: DS.attr('number'),
isSignedIn: Ember.computed.bool('id'),
url: BaseModel.apiRoot + '/users/me/',
forgottenUrl: BaseModel.apiRoot + '/forgotten/',
resetUrl: BaseModel.apiRoot + '/reset/',
save: function () {
return ic.ajax.request(this.url, {
type: 'POST',
data: this.getProperties(Ember.keys(this))
});
},
validate: function () {
validationErrors: function () {
var validationErrors = [];
if (!validator.isLength(this.get('name'), 0, 150)) {
@ -44,25 +48,20 @@ var UserModel = BaseModel.extend({
}
}
if (validationErrors.length > 0) {
this.set('isValid', false);
} else {
this.set('isValid', true);
}
return validationErrors;
}.property('name', 'bio', 'email', 'location', 'website'),
this.set('errors', validationErrors);
return this;
},
isValid: Ember.computed.empty('validationErrors.[]'),
saveNewPassword: function (password) {
return ic.ajax.request(BaseModel.subdir + '/ghost/changepw/', {
var url = this.get('ghostPaths').adminUrl('changepw');
return ic.ajax.request(url, {
type: 'POST',
data: password
});
},
validatePassword: function (password) {
passwordValidationErrors: function (password) {
var validationErrors = [];
if (!validator.equals(password.newPassword, password.ne2Password)) {
@ -73,24 +72,17 @@ var UserModel = BaseModel.extend({
validationErrors.push("Your password is not long enough. It must be at least 8 characters long.");
}
if (validationErrors.length > 0) {
this.set('passwordIsValid', false);
} else {
this.set('passwordIsValid', true);
}
this.set('passwordErrors', validationErrors);
return this;
return validationErrors;
},
fetchForgottenPasswordFor: function (email) {
var self = this;
var forgottenUrl = this.get('ghostPaths').apiUrl('forgotten');
return new Ember.RSVP.Promise(function (resolve, reject) {
if (!validator.isEmail(email)) {
reject(new Error('Please enter a correct email address.'));
} else {
resolve(ic.ajax.request(self.forgottenUrl, {
resolve(ic.ajax.request(forgottenUrl, {
type: 'POST',
headers: {
// @TODO Find a more proper way to do this.
@ -105,12 +97,14 @@ var UserModel = BaseModel.extend({
},
resetPassword: function (passwords, token) {
var self = this;
var self = this,
resetUrl = this.get('ghostPaths').apiUrl('reset');
return new Ember.RSVP.Promise(function (resolve, reject) {
if (!self.validatePassword(passwords).get('passwordIsValid')) {
reject(new Error('Errors found! ' + JSON.stringify(self.get('passwordErrors'))));
} else {
resolve(ic.ajax.request(self.resetUrl, {
resolve(ic.ajax.request(resetUrl, {
type: 'POST',
headers: {
// @TODO: find a more proper way to do this.
@ -127,4 +121,4 @@ var UserModel = BaseModel.extend({
}
});
export default UserModel;
export default User;

View File

@ -1,15 +1,27 @@
var ApplicationRoute = Ember.Route.extend({
actions: {
signedIn: function (user) {
this.container.lookup('user:current').setProperties(user);
// Update the user on all routes and controllers
this.container.unregister('user:current');
this.container.register('user:current', user, { instantiate: false });
this.container.injection('route', 'user', 'user:current');
this.container.injection('controller', 'user', 'user:current');
this.set('user', user);
this.set('controller.user', user);
},
signedOut: function () {
this.container.lookup('user:current').setProperties({
id: null,
name: null,
image: null
});
// Nullify the user on all routes and controllers
this.container.unregister('user:current');
this.container.register('user:current', null, { instantiate: false });
this.container.injection('route', 'user', 'user:current');
this.container.injection('controller', 'user', 'user:current');
this.set('user', null);
this.set('controller.user', null);
},
openModal: function (modalName, model) {

View File

@ -1,6 +1,8 @@
var AuthenticatedRoute = Ember.Route.extend({
beforeModel: function () {
if (!this.get('user.isSignedIn')) {
var user = this.container.lookup('user:current');
if (!user || !user.get('isSignedIn')) {
this.notifications.showError('Please sign in');
this.transitionTo('signin');
@ -9,7 +11,7 @@ var AuthenticatedRoute = Ember.Route.extend({
actions: {
error: function (error) {
if (error.jqXHR.status === 401) {
if (error.jqXHR && error.jqXHR.status === 401) {
this.transitionTo('signin');
}
}

View File

@ -1,14 +1,11 @@
import ajax from 'ghost/utils/ajax';
import styleBody from 'ghost/mixins/style-body';
import AuthenticatedRoute from 'ghost/routes/authenticated';
import Post from 'ghost/models/post';
var EditorRoute = AuthenticatedRoute.extend(styleBody, {
classNames: ['editor'],
controllerName: 'posts.post',
model: function (params) {
return ajax('/ghost/api/v0.1/posts/' + params.post_id).then(function (post) {
return Post.create(post);
});
return this.store.find('post', params.post_id);
}
});

View File

@ -1,9 +1,8 @@
import AuthenticatedRoute from 'ghost/routes/authenticated';
import styleBody from 'ghost/mixins/style-body';
import Post from 'ghost/models/post';
var NewRoute = AuthenticatedRoute.extend(styleBody, {
controllerName: 'posts/post',
controllerName: 'posts.post',
classNames: ['editor'],
renderTemplate: function () {
@ -11,7 +10,9 @@ var NewRoute = AuthenticatedRoute.extend(styleBody, {
},
model: function () {
return Post.create();
return this.store.createRecord('post', {
title: 'New Post'
});
}
});

View File

@ -1,17 +1,11 @@
import ajax from 'ghost/utils/ajax';
import styleBody from 'ghost/mixins/style-body';
import AuthenticatedRoute from 'ghost/routes/authenticated';
import Post from 'ghost/models/post';
var PostsRoute = AuthenticatedRoute.extend(styleBody, {
classNames: ['manage'],
model: function () {
return ajax('/ghost/api/v0.1/posts').then(function (response) {
return response.posts.map(function (post) {
return Post.create(post);
});
});
return this.store.find('post', { status: 'all', staticPages: 'all' });
},
actions: {

View File

@ -1,11 +1,5 @@
/*global ajax */
import Post from 'ghost/models/post';
var PostsPostRoute = Ember.Route.extend({
export default Ember.Route.extend({
model: function (params) {
return ajax('/ghost/api/v0.1/posts/' + params.post_id).then(function (post) {
return Post.create(post);
});
return this.store.find('post', params.post_id);
}
});
export default PostsPostRoute;

View File

@ -15,7 +15,7 @@ var SigninRoute = Ember.Route.extend(styleBody, {
if (!isEmpty(data.email) && !isEmpty(data.password)) {
ajax({
url: '/ghost/signin/',
url: this.get('ghostPaths').adminUrl('signin'),
type: 'POST',
headers: {
"X-CSRF-Token": this.get('csrf')
@ -23,11 +23,12 @@ var SigninRoute = Ember.Route.extend(styleBody, {
data: data
}).then(
function (response) {
self.send('signedIn', response.userData);
self.notifications.clear();
self.transitionTo('posts');
self.store.pushPayload({ users: [response.userData]});
self.store.find('user', response.userData.id).then(function (user) {
self.send('signedIn', user);
self.notifications.clear();
self.transitionTo('posts');
});
}, function (resp) {
// This path is ridiculous, should be a helper in notifications; e.g. notifications.showAPIError
self.notifications.showAPIError(resp, 'There was a problem logging in, please try again.');

View File

@ -26,11 +26,12 @@ var SignupRoute = Ember.Route.extend(styleBody, {
data: data
}).then(function (resp) {
if (resp && resp.userData) {
self.send('signedIn', resp.userData);
self.notifications.clear();
self.transitionTo('posts');
self.store.pushPayload({ users: [resp.userData]});
self.store.find('user', resp.userData.id).then(function (user) {
self.send('signedIn', user);
self.notifications.clear();
self.transitionTo('posts');
});
} else {
self.transitionTo('signin');
}

View File

@ -0,0 +1,16 @@
export default DS.RESTSerializer.extend({
serializeIntoHash: function (hash, type, record, options) {
// Our API expects an id on the posted object
options = options || {};
options.includeId = true;
// We have a plural root in the API
var root = Ember.String.pluralize(type.typeKey),
data = this.serialize(record, options);
// Don't ever pass uuid's
delete data.uuid;
hash[root] = [data];
}
});

View File

@ -1,7 +1,7 @@
<header class="floatingheader">
<button class="button-back" href="#">Back</button>
{{!-- @TODO: add back title updates depending on featured state --}}
<a {{bind-attr class="featured:featured:unfeatured"}} href="#" title="Feature this post">
<a {{bind-attr class="featured:featured:unfeatured"}} href="#" title="Feature this post" {{action "toggleFeatured"}}>
<span class="hidden">Star</span>
</a>
<small>

View File

@ -2,7 +2,11 @@
<nav>
<section id="entry-tags" href="#" class="left">
<label class="tag-label" for="tags" title="Tags"><span class="hidden">Tags</span></label>
<div class="tags"></div>
<div class="tags">
{{#each tags}}
<span class="tag">{{name}}</span>
{{/each}}
</div>
<input type="hidden" class="tags-holder" id="tags-holder">
<input class="tag-input" id="tags" type="text" data-input-behaviour="tag" />
<ul class="suggestions overlay"></ul>

View File

@ -43,8 +43,14 @@ function setSelected(list, name) {
adminControllers = {
'index': function (req, res) {
/*jslint unparam:true*/
var userData;
if (req.session && req.session.userData) {
userData = JSON.stringify(req.session.userData);
}
res.render('default-ember', {
user: JSON.stringify(req.session.userData)
user: userData
});
},
// Route: index

View File

@ -32,7 +32,8 @@ var middleware = {
authenticate: function (req, res, next) {
var noAuthNeeded = [
'/ghost/signin/', '/ghost/signout/', '/ghost/signup/',
'/ghost/forgotten/', '/ghost/reset/', '/ghost/ember/'
'/ghost/forgotten/', '/ghost/reset/', '/ghost/ember/',
'/ghost/ember/signin'
],
subPath;