Support saving posts via Store

This commit is contained in:
Robin Ward 2015-04-09 14:54:17 -04:00
parent d4a05825da
commit 76f7786d0d
16 changed files with 205 additions and 160 deletions

View File

@ -1,11 +1,20 @@
import RestAdapter from 'discourse/adapters/rest';
import { Result } from 'discourse/adapters/rest';
export default RestAdapter.extend({
// GET /posts doesn't include a type
find(store, type, findArgs) {
return this._super(store, type, findArgs).then(function(result) {
return {post: result};
});
},
createRecord(store, type, args) {
const typeField = Ember.String.underscore(type);
args.nested_post = true;
return Discourse.ajax(this.pathFor(store, type), { method: 'POST', data: args }).then(function (json) {
return new Result(json[typeField], json);
});
}
});

View File

@ -1,5 +1,11 @@
const ADMIN_MODELS = ['plugin'];
export function Result(payload, responseJson) {
this.payload = payload;
this.responseJson = responseJson;
this.target = null;
}
export default Ember.Object.extend({
pathFor(store, type, findArgs) {
let path = "/" + Ember.String.underscore(store.pluralize(type));
@ -35,7 +41,18 @@ export default Ember.Object.extend({
update(store, type, id, attrs) {
const data = {};
data[Ember.String.underscore(type)] = attrs;
return Discourse.ajax(this.pathFor(store, type, id), { method: 'PUT', data });
return Discourse.ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) {
return new Result(json[type], json);
});
},
createRecord(store, type, attrs) {
const data = {};
const typeField = Ember.String.underscore(type);
data[typeField] = attrs;
return Discourse.ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) {
return new Result(json[typeField], json);
});
},
destroyRecord(store, type, record) {

View File

@ -219,6 +219,7 @@ export default DiscourseController.extend({
imageSizes: this.get('view').imageSizes(),
editReason: this.get("editReason")
}).then(function(opts) {
// If we replied as a new topic successfully, remove the draft.
if (self.get('replyAsNewTopicDraft')) {
self.destroyDraft();
@ -246,7 +247,6 @@ export default DiscourseController.extend({
bootbox.alert(error);
});
if (this.get('controllers.application.currentRouteName').split('.')[0] === 'topic' &&
composer.get('topic.id') === this.get('controllers.topic.model.id')) {
staged = composer.get('stagedPost');

View File

@ -0,0 +1,30 @@
export function throwAjaxError(undoCallback) {
return function(error) {
if (error instanceof Error) {
Ember.Logger.error(error.stack);
}
if (typeof error === "string") {
Ember.Logger.error(error);
}
// If we provided an `undo` callback
if (undoCallback) { undoCallback(error); }
let parsedError;
if (error.responseText) {
try {
const parsedJSON = $.parseJSON(error.responseText);
if (parsedJSON.errors) {
parsedError = parsedJSON.errors[0];
} else if (parsedJSON.failed) {
parsedError = parsedJSON.message;
}
} catch(ex) {
// in case the JSON doesn't parse
Ember.Logger.error(ex.stack);
}
}
throw parsedError || I18n.t('generic_error');
};
}

View File

@ -1,6 +1,6 @@
import RestModel from 'discourse/models/rest';
import Post from 'discourse/models/post';
import Topic from 'discourse/models/topic';
import { throwAjaxError } from 'discourse/lib/ajax-error';
const CLOSED = 'closed',
SAVING = 'saving',
@ -430,25 +430,22 @@ const Composer = RestModel.extend({
promise = Ember.RSVP.resolve();
}
post.setProperties({
const props = {
raw: this.get('reply'),
editReason: opts.editReason,
imageSizes: opts.imageSizes,
edit_reason: opts.editReason,
image_sizes: opts.imageSizes,
cooked: this.getCookedHtml()
});
};
this.set('composeState', CLOSED);
return promise.then(function() {
return post.save(function(result) {
post.updateFromPost(result);
return post.save(props).then(function() {
self.clearState();
}, function (error) {
}).catch(throwAjaxError(function() {
post.set('cooked', oldCooked);
self.set('composeState', OPEN);
const response = $.parseJSON(error.responseText);
throw response && response.errors ? response.errors[0] : I18n.t('generic_error');
});
}));
});
},
@ -473,7 +470,7 @@ const Composer = RestModel.extend({
let addedToStream = false;
// Build the post object
const createdPost = Post.create({
const createdPost = this.store.createRecord('post', {
imageSizes: opts.imageSizes,
cooked: this.getCookedHtml(),
reply_count: 0,
@ -489,7 +486,6 @@ const Composer = RestModel.extend({
moderator: user.get('moderator'),
admin: user.get('admin'),
yours: true,
newPost: true,
read: true
});
@ -521,11 +517,7 @@ const Composer = RestModel.extend({
// engine, staging will just cause a blank post to render
if (!_.isEmpty(createdPost.get('cooked'))) {
state = postStream.stagePost(createdPost, user);
if(state === "alreadyStaging"){
return;
}
if (state === "alreadyStaging") { return; }
}
}
@ -534,13 +526,12 @@ const Composer = RestModel.extend({
composer.set("stagedPost", state === "staged" && createdPost);
return createdPost.save().then(function(result) {
let saving = true;
createdPost.updateFromJson(result);
if (topic) {
// It's no longer a new post
createdPost.set('newPost', false);
topic.set('draft_sequence', result.draft_sequence);
topic.set('draft_sequence', result.target.draft_sequence);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
@ -563,30 +554,13 @@ const Composer = RestModel.extend({
composer.set('composeState', SAVING);
}
return { post: result };
}).catch(function(error) {
// If an error occurs
return { post: createdPost };
}).catch(throwAjaxError(function() {
if (postStream) {
postStream.undoPost(createdPost);
}
composer.set('composeState', OPEN);
// TODO extract error handling code
let parsedError;
try {
const parsedJSON = $.parseJSON(error.responseText);
if (parsedJSON.errors) {
parsedError = parsedJSON.errors[0];
} else if (parsedJSON.failed) {
parsedError = parsedJSON.message;
}
}
catch(ex) {
parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText;
}
throw parsedError;
});
}));
},
getCookedHtml() {

View File

@ -113,45 +113,33 @@ const Post = RestModel.extend({
});
}.property('actions_summary.@each.users', 'actions_summary.@each.count'),
save() {
const self = this;
if (!this.get('newPost')) {
// We're updating a post
return Discourse.ajax("/posts/" + (this.get('id')), {
type: 'PUT',
dataType: 'json',
data: {
afterUpdate(res) {
if (res.category) {
Discourse.Site.current().updateCategory(res.category);
}
},
updateProperties() {
return {
post: { raw: this.get('raw'), edit_reason: this.get('editReason') },
image_sizes: this.get('imageSizes')
}
}).then(function(result) {
// If we received a category update, update it
self.set('version', result.post.version);
if (result.category) Discourse.Site.current().updateCategory(result.category);
return Discourse.Post.create(result.post);
});
};
},
} else {
// We're saving a post
createProperties() {
const data = this.getProperties(Discourse.Composer.serializedFieldsForCreate());
data.reply_to_post_number = this.get('reply_to_post_number');
data.image_sizes = this.get('imageSizes');
data.nested_post = true;
const metaData = this.get('metaData');
// Put the metaData into the request
if (metaData) {
data.meta_data = {};
Ember.keys(metaData).forEach(function(key) { data.meta_data[key] = metaData.get(key); });
}
return Discourse.ajax("/posts", {
type: 'POST',
data: data
}).then(function(result) {
return Discourse.Post.create(result.post);
});
}
return data;
},
// Expands the first post's content, if embedded and shortened.
@ -266,50 +254,6 @@ const Post = RestModel.extend({
});
},
/**
Updates a post from a JSON packet. This is normally done after the post is saved to refresh any
attributes.
**/
updateFromJson(obj) {
if (!obj) return;
let skip, oldVal;
// Update all the properties
const post = this;
_.each(obj, function(val,key) {
if (key !== 'actions_summary'){
oldVal = post[key];
skip = false;
if (val && val !== oldVal) {
if (key === "reply_to_user" && val && oldVal) {
skip = val.username === oldVal.username || Em.get(val, "username") === Em.get(oldVal, "username");
}
if(!skip) {
post.set(key, val);
}
}
}
});
// Rebuild actions summary
this.set('actions_summary', Em.A());
if (obj.actions_summary) {
const lookup = Em.Object.create();
_.each(obj.actions_summary,function(a) {
a.post = post;
a.actionType = Discourse.Site.current().postActionTypeById(a.id);
const actionSummary = Discourse.ActionSummary.create(a);
post.get('actions_summary').pushObject(actionSummary);
lookup.set(a.actionType.get('name_key'), actionSummary);
});
this.set('actionByName', lookup);
}
},
// Load replies to this post
loadReplies() {
if(this.get('loadingReplies')){

View File

@ -1,22 +1,52 @@
import Presence from 'discourse/mixins/presence';
const RestModel = Ember.Object.extend(Presence, {
update(attrs) {
const self = this,
type = this.get('__type');
isNew: Ember.computed.equal('__state', 'new'),
isCreated: Ember.computed.equal('__state', 'created'),
const munge = this.__munge;
return this.store.update(type, this.get('id'), attrs).then(function(result) {
if (result && result[type]) {
Object.keys(result).forEach(function(k) {
attrs[k] = result[k];
afterUpdate: Ember.K,
update(props) {
props = props || this.updateProperties();
const type = this.get('__type'),
store = this.get('store');
const self = this;
return store.update(type, this.get('id'), props).then(function(res) {
self.setProperties(self.__munge(res.payload || res.responseJson));
self.afterUpdate(res);
return res;
});
}
self.setProperties(munge(attrs));
return result;
},
_saveNew(props) {
props = props || this.createProperties();
const type = this.get('__type'),
store = this.get('store'),
adapter = store.adapterFor(type);
const self = this;
return adapter.createRecord(store, type, props).then(function(res) {
if (!res) { throw "Received no data back from createRecord"; }
self.setProperties(self.__munge(res.payload));
self.set('__state', 'created');
res.target = self;
return res;
});
},
createProperties() {
throw "You must overwrite `createProperties()` before saving a record";
},
save(props) {
return this.get('isNew') ? this._saveNew(props) : this.update(props);
},
destroyRecord() {
const type = this.get('__type');
return this.store.destroyRecord(type, this);
@ -34,7 +64,7 @@ RestModel.reopenClass({
args = args || {};
if (!args.store) {
const container = Discourse.__container__;
Ember.warn('Use `store.createRecord` to create records instead of `.create()`');
// Ember.warn('Use `store.createRecord` to create records instead of `.create()`');
args.store = container.lookup('store:main');
}

View File

@ -16,26 +16,23 @@ export default Ember.Object.extend({
},
findAll(type) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
const self = this;
return adapter.findAll(this, type).then(function(result) {
return this.adapterFor(type).findAll(this, type).then(function(result) {
return self._resultSet(type, result);
});
},
// Mostly for legacy, things like TopicList without ResultSets
findFiltered(type, findArgs) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
const self = this;
return adapter.find(this, type, findArgs).then(function(result) {
return this.adapterFor(type).find(this, type, findArgs).then(function(result) {
return self._build(type, result);
});
},
find(type, findArgs) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
const self = this;
return adapter.find(this, type, findArgs).then(function(result) {
return this.adapterFor(type).find(this, type, findArgs).then(function(result) {
if (typeof findArgs === "object") {
return self._resultSet(type, result);
} else {
@ -64,8 +61,7 @@ export default Ember.Object.extend({
},
update(type, id, attrs) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
return adapter.update(this, type, id, attrs, function(result) {
return this.adapterFor(type).update(this, type, id, attrs, function(result) {
if (result && result[type] && result[type].id) {
const oldRecord = _identityMap[type][id];
delete _identityMap[type][id];
@ -81,8 +77,7 @@ export default Ember.Object.extend({
},
destroyRecord(type, record) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
return adapter.destroyRecord(this, type, record).then(function(result) {
return this.adapterFor(type).destroyRecord(this, type, record).then(function(result) {
const forType = _identityMap[type];
if (forType) { delete forType[record.get('id')]; }
return result;
@ -101,6 +96,7 @@ export default Ember.Object.extend({
_build(type, obj) {
obj.store = this;
obj.__type = type;
obj.__state = obj.id ? "created" : "new";
const klass = this.container.lookupFactory('model:' + type) || RestModel;
const model = klass.create(obj);
@ -111,6 +107,10 @@ export default Ember.Object.extend({
return model;
},
adapterFor(type) {
return this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
},
_hydrate(type, obj) {
if (!obj) { throw "Can't hydrate " + type + " of `null`"; }
if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; }

View File

@ -15,6 +15,7 @@
//= require ./discourse/helpers/register-unbound
//= require ./discourse/mixins/scrolling
//= require_tree ./discourse/mixins
//= require ./discourse/lib/ajax-error
//= require ./discourse/lib/markdown
//= require ./discourse/lib/search-for-term
//= require ./discourse/lib/user-search

View File

@ -100,6 +100,8 @@ test("Create a Reply", () => {
test("Edit the first post", () => {
visit("/t/internationalization-localization/280");
ok(!exists('.topic-post:eq(0) .post-info.edits'), 'it has no edits icon at first');
click('.topic-post:eq(0) button[data-action=showMoreActions]');
click('.topic-post:eq(0) button[data-action=edit]');
andThen(() => {
@ -111,6 +113,7 @@ test("Edit the first post", () => {
click('#reply-control button.create');
andThen(() => {
ok(!exists('#wmd-input'), 'it closes the composer');
ok(exists('.topic-post:eq(0) .post-info.edits'), 'it has the edits icon');
ok(find('#topic-title h1').text().indexOf('This is the new text for the title') !== -1, 'it shows the new title');
ok(find('.topic-post:eq(0) .cooked').text().indexOf('This is the new text for the post') !== -1, 'it updates the post');
});

View File

@ -1,4 +1,4 @@
export default {
"/posts/398": {"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png","uploaded_avatar_id":5697,"created_at":"2013-02-05T21:29:00.280Z","cooked":"<p>Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?</p>","post_number":1,"post_type":1,"updated_at":"2013-02-05T21:29:00.280Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":25,"incoming_link_count":314,"reads":475,"score":1702.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Uwe Keim","primary_group_name":null,"version":2,"can_edit":true,"can_delete":false,"can_recover":true,"user_title":null,"raw":"Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}
"/posts/398": {"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png","uploaded_avatar_id":5697,"created_at":"2013-02-05T21:29:00.280Z","cooked":"<p>Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?</p>","post_number":1,"post_type":1,"updated_at":"2013-02-05T21:29:00.280Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":25,"incoming_link_count":314,"reads":475,"score":1702.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Uwe Keim","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"user_title":null,"raw":"Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}
};

File diff suppressed because one or more lines are too long

View File

@ -2,15 +2,21 @@ function parsePostData(query) {
const result = {};
query.split("&").forEach(function(part) {
const item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]).replace(/\+/g, ' ');
const firstSeg = decodeURIComponent(item[0]);
const m = /^([^\[]+)\[([^\]]+)\]/.exec(firstSeg);
const val = decodeURIComponent(item[1]).replace(/\+/g, ' ');
if (m) {
result[m[1]] = result[m[1]] || {};
result[m[1]][m[2]] = val;
} else {
result[firstSeg] = val;
}
});
return result;
}
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function response(code, obj) {
if (typeof code === "object") {
obj = code;
@ -122,7 +128,10 @@ export default function() {
this.put('/posts/:post_id/recover', success);
this.put('/posts/:post_id', (request) => {
return response({ post: {id: request.params.post_id, version: 2 } });
const data = parsePostData(request.requestBody);
data.post.id = request.params.post_id;
data.post.version = 2;
return response(200, data.post);
});
this.put('/t/:slug/:id', (request) => {
@ -157,9 +166,15 @@ export default function() {
}
});
this.post('/widgets', function(request) {
const widget = parsePostData(request.requestBody).widget;
widget.id = 100;
return response(200, {widget});
});
this.put('/widgets/:widget_id', function(request) {
const w = _widgets.findBy('id', parseInt(request.params.widget_id));
return response({ widget: clone(w) });
const widget = parsePostData(request.requestBody).widget;
return response({ widget });
});
this.get('/widgets', function(request) {

View File

@ -28,6 +28,21 @@ test('update', function() {
});
});
test('save new', function() {
const store = createStore();
const widget = store.createRecord('widget');
ok(widget.get('isNew'), 'it is a new record');
ok(!widget.get('isCreated'), 'it is not created');
widget.save({ name: 'Evil Widget' }).then(function() {
ok(widget.get('id'), 'it has an id');
ok(widget.get('name'), 'Evil Widget');
ok(widget.get('isCreated'), 'it is created');
ok(!widget.get('isNew'), 'it is no longer new');
});
});
test('destroyRecord', function() {
const store = createStore();
store.find('widget', 123).then(function(widget) {

View File

@ -5,6 +5,8 @@ import createStore from 'helpers/create-store';
test('createRecord', function() {
const store = createStore();
const widget = store.createRecord('widget', {id: 111, name: 'hello'});
ok(!widget.get('isNew'), 'it is not a new record');
equal(widget.get('name'), 'hello');
equal(widget.get('id'), 111);
});
@ -13,6 +15,7 @@ test('createRecord without an `id`', function() {
const store = createStore();
const widget = store.createRecord('widget', {name: 'hello'});
ok(widget.get('isNew'), 'it is a new record');
ok(!widget.get('id'), 'there is no id');
});
@ -21,6 +24,7 @@ test('createRecord without attributes', function() {
const widget = store.createRecord('widget');
ok(!widget.get('id'), 'there is no id');
ok(widget.get('isNew'), 'it is a new record');
});
test('createRecord with a record as attributes returns that record from the map', function() {
@ -36,6 +40,7 @@ test('find', function() {
store.find('widget', 123).then(function(w) {
equal(w.get('name'), 'Trout Lure');
equal(w.get('id'), 123);
ok(!w.get('isNew'), 'found records are not new');
// A second find by id returns the same object
store.find('widget', 123).then(function(w2) {
@ -70,6 +75,7 @@ test('findAll', function() {
store.findAll('widget').then(function(result) {
equal(result.get('length'), 2);
const w = result.findBy('id', 124);
ok(!w.get('isNew'), 'found records are not new');
equal(w.get('name'), 'Evil Repellant');
});
});

View File

@ -437,9 +437,10 @@ var bootbox = window.bootbox || (function(document, $) {
// wire up button handlers
div.on('click', '.modal-footer a', function(e) {
var self = this;
Ember.run(function() {
var handler = $(this).data("handler"),
var handler = $(self).data("handler"),
cb = callbacks[handler],
hideModal = null;