Interface is wired up for Approving/Rejecting posts

This commit is contained in:
Robin Ward 2015-04-14 14:21:02 -04:00
parent 96d2c5069b
commit 0c233e4e25
20 changed files with 273 additions and 90 deletions

View file

@ -6,6 +6,16 @@ export function Result(payload, responseJson) {
this.target = null; this.target = null;
} }
const ajax = Discourse.ajax;
// We use this to make sure 404s are caught
function rethrow(error) {
if (error.status === 404) {
throw "404: " + error.responseText;
}
throw(error);
}
export default Ember.Object.extend({ export default Ember.Object.extend({
pathFor(store, type, findArgs) { pathFor(store, type, findArgs) {
let path = "/" + Ember.String.underscore(store.pluralize(type)); let path = "/" + Ember.String.underscore(store.pluralize(type));
@ -31,17 +41,18 @@ export default Ember.Object.extend({
}, },
findAll(store, type) { findAll(store, type) {
return Discourse.ajax(this.pathFor(store, type)); return ajax(this.pathFor(store, type)).catch(rethrow);
}, },
find(store, type, findArgs) { find(store, type, findArgs) {
return Discourse.ajax(this.pathFor(store, type, findArgs)); return ajax(this.pathFor(store, type, findArgs)).catch(rethrow);
}, },
update(store, type, id, attrs) { update(store, type, id, attrs) {
const data = {}; const data = {};
data[Ember.String.underscore(type)] = attrs; data[Ember.String.underscore(type)] = attrs;
return Discourse.ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) { return ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) {
return new Result(json[type], json); return new Result(json[type], json);
}); });
}, },
@ -50,13 +61,13 @@ export default Ember.Object.extend({
const data = {}; const data = {};
const typeField = Ember.String.underscore(type); const typeField = Ember.String.underscore(type);
data[typeField] = attrs; data[typeField] = attrs;
return Discourse.ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) { return ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) {
return new Result(json[typeField], json); return new Result(json[typeField], json);
}); });
}, },
destroyRecord(store, type, record) { destroyRecord(store, type, record) {
return Discourse.ajax(this.pathFor(store, type, record.get('id')), { method: 'DELETE' }); return ajax(this.pathFor(store, type, record.get('id')), { method: 'DELETE' });
} }
}); });

View file

@ -1,16 +1,16 @@
import { popupAjaxError } from 'discourse/lib/ajax-error';
function updateState(state) {
return function(post) {
post.update({ state }).then(() => {
this.get('model').removeObject(post);
}).catch(popupAjaxError);
};
}
export default Ember.Controller.extend({ export default Ember.Controller.extend({
actions: { actions: {
approve(post) { approve: updateState('approved'),
post.update({ state: 'approved' }).then(() => { reject: updateState('rejected')
this.get('model').removeObject(post);
});
},
reject(post) {
post.update({ state: 'rejected' }).then(() => {
this.get('model').removeObject(post);
});
}
} }
}); });

View file

@ -1,5 +1,4 @@
export function throwAjaxError(undoCallback) { function extractError(error) {
return function(error) {
if (error instanceof Error) { if (error instanceof Error) {
Ember.Logger.error(error.stack); Ember.Logger.error(error.stack);
} }
@ -8,9 +7,6 @@ export function throwAjaxError(undoCallback) {
Ember.Logger.error(error); Ember.Logger.error(error);
} }
// If we provided an `undo` callback
if (undoCallback) { undoCallback(error); }
let parsedError; let parsedError;
if (error.responseText) { if (error.responseText) {
try { try {
@ -25,6 +21,18 @@ export function throwAjaxError(undoCallback) {
Ember.Logger.error(ex.stack); Ember.Logger.error(ex.stack);
} }
} }
throw parsedError || I18n.t('generic_error'); return parsedError || I18n.t('generic_error');
}
export function throwAjaxError(undoCallback) {
return function(error) {
// If we provided an `undo` callback
if (undoCallback) { undoCallback(error); }
throw extractError(error);
}; };
} }
export function popupAjaxError(err) {
bootbox.alert(extractError(err));
}

View file

@ -3,24 +3,30 @@ import Presence from 'discourse/mixins/presence';
const RestModel = Ember.Object.extend(Presence, { const RestModel = Ember.Object.extend(Presence, {
isNew: Ember.computed.equal('__state', 'new'), isNew: Ember.computed.equal('__state', 'new'),
isCreated: Ember.computed.equal('__state', 'created'), isCreated: Ember.computed.equal('__state', 'created'),
isSaving: false,
afterUpdate: Ember.K, afterUpdate: Ember.K,
update(props) { update(props) {
if (this.get('isSaving')) { return Ember.RSVP.reject(); }
props = props || this.updateProperties(); props = props || this.updateProperties();
const type = this.get('__type'), const type = this.get('__type'),
store = this.get('store'); store = this.get('store');
const self = this; const self = this;
self.set('isSaving', true);
return store.update(type, this.get('id'), props).then(function(res) { return store.update(type, this.get('id'), props).then(function(res) {
self.setProperties(self.__munge(res.payload || res.responseJson)); self.setProperties(self.__munge(res.payload || res.responseJson));
self.afterUpdate(res); self.afterUpdate(res);
return res; return res;
}); }).finally(() => this.set('isSaving', false));
}, },
_saveNew(props) { _saveNew(props) {
if (this.get('isSaving')) { return Ember.RSVP.reject(); }
props = props || this.createProperties(); props = props || this.createProperties();
const type = this.get('__type'), const type = this.get('__type'),
@ -28,6 +34,7 @@ const RestModel = Ember.Object.extend(Presence, {
adapter = store.adapterFor(type); adapter = store.adapterFor(type);
const self = this; const self = this;
self.set('isSaving', true);
return adapter.createRecord(store, type, props).then(function(res) { return adapter.createRecord(store, type, props).then(function(res) {
if (!res) { throw "Received no data back from createRecord"; } if (!res) { throw "Received no data back from createRecord"; }
@ -40,7 +47,7 @@ const RestModel = Ember.Object.extend(Presence, {
res.target = self; res.target = self;
return res; return res;
}); }).finally(() => this.set('isSaving', false));
}, },
createProperties() { createProperties() {

View file

@ -36,7 +36,7 @@ export default Ember.Object.extend({
if (typeof findArgs === "object") { if (typeof findArgs === "object") {
return self._resultSet(type, result); return self._resultSet(type, result);
} else { } else {
return self._hydrate(type, result[Ember.String.underscore(type)]); return self._hydrate(type, result[Ember.String.underscore(type)], result);
} }
}); });
}, },
@ -48,7 +48,7 @@ export default Ember.Object.extend({
const typeName = Ember.String.underscore(self.pluralize(type)), const typeName = Ember.String.underscore(self.pluralize(type)),
totalRows = result["total_rows_" + typeName] || result.get('totalRows'), totalRows = result["total_rows_" + typeName] || result.get('totalRows'),
loadMoreUrl = result["load_more_" + typeName], loadMoreUrl = result["load_more_" + typeName],
content = result[typeName].map(obj => self._hydrate(type, obj)); content = result[typeName].map(obj => self._hydrate(type, obj, result));
resultSet.setProperties({ totalRows, loadMoreUrl }); resultSet.setProperties({ totalRows, loadMoreUrl });
resultSet.get('content').pushObjects(content); resultSet.get('content').pushObjects(content);
@ -86,7 +86,7 @@ export default Ember.Object.extend({
_resultSet(type, result) { _resultSet(type, result) {
const typeName = Ember.String.underscore(this.pluralize(type)), const typeName = Ember.String.underscore(this.pluralize(type)),
content = result[typeName].map(obj => this._hydrate(type, obj)), content = result[typeName].map(obj => this._hydrate(type, obj, result)),
totalRows = result["total_rows_" + typeName] || content.length, totalRows = result["total_rows_" + typeName] || content.length,
loadMoreUrl = result["load_more_" + typeName]; loadMoreUrl = result["load_more_" + typeName];
@ -111,10 +111,39 @@ export default Ember.Object.extend({
return this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); return this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
}, },
_hydrate(type, obj) { _hydrateEmbedded(obj, root) {
const self = this;
Object.keys(obj).forEach(function(k) {
const m = /(.+)\_id$/.exec(k);
if (m) {
const subType = m[1];
const collection = root[self.pluralize(subType)];
if (collection) {
const found = collection.findProperty('id', obj[k]);
if (found) {
const hydrated = self._hydrate(subType, found, root);
if (hydrated) {
obj[subType] = hydrated;
delete obj[k];
}
}
}
}
});
},
_hydrate(type, obj, root) {
if (!obj) { throw "Can't hydrate " + type + " of `null`"; } if (!obj) { throw "Can't hydrate " + type + " of `null`"; }
if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; } if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; }
root = root || obj;
// Experimental: If serialized with a certain option we'll wire up embedded objects
// automatically.
if (root.__rest_serializer === "1") {
this._hydrateEmbedded(obj, root);
}
_identityMap[type] = _identityMap[type] || {}; _identityMap[type] = _identityMap[type] || {};
const existing = _identityMap[type][obj.id]; const existing = _identityMap[type][obj.id];

View file

@ -5,4 +5,3 @@ export default DiscourseRoute.extend({
return this.store.find('queuedPost', {status: 'new'}); return this.store.find('queuedPost', {status: 'new'});
} }
}); });

View file

@ -1,6 +1,9 @@
// This route is used for retrieving a topic based on params // This route is used for retrieving a topic based on params
export default Discourse.Route.extend({ export default Discourse.Route.extend({
// Avoid default model hook
model: function() { return; },
setupController: function(controller, params) { setupController: function(controller, params) {
params = params || {}; params = params || {};
params.track_visit = true; params.track_visit = true;

View file

@ -2,9 +2,6 @@
<div class='queued-posts'> <div class='queued-posts'>
{{#each post in model}} {{#each post in model}}
<div class='queued-post'> <div class='queued-post'>
{{#if post.title}}
<h4 class='title'>{{post.title}}</h4>
{{/if}}
<div class='poster'> <div class='poster'>
{{avatar post.user imageSize="large"}} {{avatar post.user imageSize="large"}}
</div> </div>
@ -14,11 +11,30 @@
</div> </div>
<div class='clearfix'></div> <div class='clearfix'></div>
<span class='post-title'>
{{i18n "queue.topic"}}
{{#if post.topic}}
{{topic-link post.topic}}
{{else}}
{{post.post_options.title}}
{{/if}}
</span>
{{{cook-text post.raw}}} {{{cook-text post.raw}}}
<div class='queue-controls'> <div class='queue-controls'>
{{d-button action="approve" actionParam=post label="queue.approve" icon="check" class="btn-primary approve"}} {{d-button action="approve"
{{d-button action="reject" actionParam=post label="queue.reject" icon="times" class="btn-warning reject"}} actionParam=post
disabled=post.isSaving
label="queue.approve"
icon="check"
class="btn-primary approve"}}
{{d-button action="reject"
actionParam=post
disabled=post.isSaving
label="queue.reject"
icon="times"
class="btn-warning reject"}}
</div> </div>
</div> </div>
<div class='clearfix'></div> <div class='clearfix'></div>

View file

@ -10,8 +10,10 @@
width: $topic-body-width; width: $topic-body-width;
float: left; float: left;
} }
h4.title {
margin-bottom: 1em; .post-title {
color: darken(scale-color-diff(), 50%);
font-weight: bold;
} }
border-bottom: 1px solid darken(scale-color-diff(), 10%); border-bottom: 1px solid darken(scale-color-diff(), 10%);

View file

@ -219,6 +219,7 @@ class ApplicationController < ActionController::Base
def render_json_dump(obj, opts=nil) def render_json_dump(obj, opts=nil)
opts ||= {} opts ||= {}
obj['__rest_serializer'] = "1" if opts[:rest_serializer]
render json: MultiJson.dump(obj), status: opts[:status] || 200 render json: MultiJson.dump(obj), status: opts[:status] || 200
end end

View file

@ -8,12 +8,20 @@ class QueuedPostsController < ApplicationController
state = QueuedPost.states[(params[:state] || 'new').to_sym] state = QueuedPost.states[(params[:state] || 'new').to_sym]
state ||= QueuedPost.states[:new] state ||= QueuedPost.states[:new]
@queued_posts = QueuedPost.where(state: state) @queued_posts = QueuedPost.where(state: state).includes(:topic, :user)
render_serialized(@queued_posts, QueuedPostSerializer, root: :queued_posts) render_serialized(@queued_posts, QueuedPostSerializer, root: :queued_posts, rest_serializer: true)
end end
def update def update
qp = QueuedPost.where(id: params[:id]).first qp = QueuedPost.where(id: params[:id]).first
state = params[:queued_post][:state]
if state == 'approved'
qp.approve!(current_user)
elsif state == 'rejected'
qp.reject!(current_user)
end
render_serialized(qp, QueuedPostSerializer, root: :queued_posts) render_serialized(qp, QueuedPostSerializer, root: :queued_posts)
end end

View file

@ -1,4 +1,5 @@
class QueuedPostSerializer < ApplicationSerializer class QueuedPostSerializer < ApplicationSerializer
attributes :id, attributes :id,
:queue, :queue,
:user_id, :user_id,
@ -11,4 +12,6 @@ class QueuedPostSerializer < ApplicationSerializer
:created_at :created_at
has_one :user, serializer: BasicUserSerializer, embed: :object has_one :user, serializer: BasicUserSerializer, embed: :object
has_one :topic, serializer: BasicTopicSerializer
end end

View file

@ -226,6 +226,7 @@ en:
placeholder: "type the topic title here" placeholder: "type the topic title here"
queue: queue:
topic: "Topic:"
approve: 'Approve Post' approve: 'Approve Post'
reject: 'Reject Post' reject: 'Reject Post'
title: "Needs Approval" title: "Needs Approval"

View file

@ -1,4 +1,6 @@
require 'spec_helper' require 'spec_helper'
require_dependency 'queued_posts_controller'
require_dependency 'queued_post'
describe QueuedPostsController do describe QueuedPostsController do
context 'without authentication' do context 'without authentication' do
@ -24,5 +26,34 @@ describe QueuedPostsController do
expect(response).to be_success expect(response).to be_success
end end
end end
context 'update' do
let!(:user) { log_in(:moderator) }
let(:qp) { Fabricate(:queued_post) }
context 'approved' do
it 'updates the post to approved' do
xhr :put, :update, id: qp.id, queued_post: { state: 'approved' }
expect(response).to be_success
qp.reload
expect(qp.state).to eq(QueuedPost.states[:approved])
end
end
context 'rejected' do
it 'updates the post to approved' do
xhr :put, :update, id: qp.id, queued_post: { state: 'rejected' }
expect(response).to be_success
qp.reload
expect(qp.state).to eq(QueuedPost.states[:rejected])
end
end
end
end end

View file

@ -0,0 +1,18 @@
Fabricator(:queued_post) do
queue 'new_post'
state QueuedPost.states[:new]
user
topic
raw 'This post should be queued up'
post_options do
{ reply_to_post_number: 1,
via_email: true,
raw_email: 'store_me',
auto_track: true,
custom_fields: { hello: 'world' },
cooking_options: { cat: 'hat' },
cook_method: Post.cook_methods[:raw_html],
image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}} }
end
end

View file

@ -7,22 +7,7 @@ describe QueuedPost do
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:admin) { Fabricate(:admin) } let(:admin) { Fabricate(:admin) }
let(:qp) { QueuedPost.create(queue: 'new_post', let(:qp) { Fabricate(:queued_post, topic: topic, user: user) }
state: QueuedPost.states[:new],
user_id: user.id,
topic_id: topic.id,
raw: 'This post should be queued up',
post_options: {
reply_to_post_number: 1,
via_email: true,
raw_email: 'store_me',
auto_track: true,
custom_fields: { hello: 'world' },
cooking_options: { cat: 'hat' },
cook_method: Post.cook_methods[:raw_html],
not_create_option: true,
image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}}
}) }
it "returns the appropriate options for posting" do it "returns the appropriate options for posting" do
create_options = qp.create_options create_options = qp.create_options

View file

@ -2,7 +2,7 @@ import { acceptance } from "helpers/qunit-helpers";
acceptance("View Topic"); acceptance("View Topic");
test("Enter a Topic", () => { test("Enter a Topic", () => {
visit("/t/internationalization-localization/280"); visit("/t/internationalization-localization/280/1");
andThen(() => { andThen(() => {
ok(exists("#topic"), "The topic was rendered"); ok(exists("#topic"), "The topic was rendered");
ok(exists("#topic .post-cloak"), "The topic has cloaked posts"); ok(exists("#topic .post-cloak"), "The topic has cloaked posts");

View file

@ -173,6 +173,14 @@ export default function() {
}); });
}); });
this.get('/fruits/:id', function() {
return response({
__rest_serializer: "1",
fruit: {id: 1, name: 'apple', farmer_id: 1},
farmers: [{id: 1, name: 'Evil Trout'}]
});
});
this.get('/widgets/:widget_id', function(request) { this.get('/widgets/:widget_id', function(request) {
const w = _widgets.findBy('id', parseInt(request.params.widget_id)); const w = _widgets.findBy('id', parseInt(request.params.widget_id));
if (w) { if (w) {

View file

@ -19,23 +19,51 @@ test('munging', function() {
test('update', function() { test('update', function() {
const store = createStore(); const store = createStore();
store.find('widget', 123).then(function(widget) { store.find('widget', 123).then(function(widget) {
equal(widget.get('name'), 'Trout Lure'); equal(widget.get('name'), 'Trout Lure');
widget.update({ name: 'new name' }).then(function() {
ok(!widget.get('isSaving'));
const promise = widget.update({ name: 'new name' });
ok(widget.get('isSaving'));
promise.then(function() {
ok(!widget.get('isSaving'));
equal(widget.get('name'), 'new name'); equal(widget.get('name'), 'new name');
}); });
}); });
}); });
test('updating simultaneously', function() {
expect(2);
const store = createStore();
store.find('widget', 123).then(function(widget) {
const firstPromise = widget.update({ name: 'new name' });
const secondPromise = widget.update({ name: 'new name' });
firstPromise.then(function() {
ok(true, 'the first promise succeeeds');
});
secondPromise.catch(function() {
ok(true, 'the second promise fails');
});
});
});
test('save new', function() { test('save new', function() {
const store = createStore(); const store = createStore();
const widget = store.createRecord('widget'); const widget = store.createRecord('widget');
ok(widget.get('isNew'), 'it is a new record'); ok(widget.get('isNew'), 'it is a new record');
ok(!widget.get('isCreated'), 'it is not created'); ok(!widget.get('isCreated'), 'it is not created');
ok(!widget.get('isSaving'));
widget.save({ name: 'Evil Widget' }).then(function() { const promise = widget.save({ name: 'Evil Widget' });
ok(widget.get('isSaving'));
promise.then(function() {
ok(!widget.get('isSaving'));
ok(widget.get('id'), 'it has an id'); ok(widget.get('id'), 'it has an id');
ok(widget.get('name'), 'Evil Widget'); ok(widget.get('name'), 'Evil Widget');
ok(widget.get('isCreated'), 'it is created'); ok(widget.get('isCreated'), 'it is created');
@ -43,6 +71,23 @@ test('save new', function() {
}); });
}); });
test('creating simultaneously', function() {
expect(2);
const store = createStore();
const widget = store.createRecord('widget');
const firstPromise = widget.save({ name: 'Evil Widget' });
const secondPromise = widget.save({ name: 'Evil Widget' });
firstPromise.then(function() {
ok(true, 'the first promise succeeeds');
});
secondPromise.catch(function() {
ok(true, 'the second promise fails');
});
});
test('destroyRecord', function() { test('destroyRecord', function() {
const store = createStore(); const store = createStore();
store.find('widget', 123).then(function(widget) { store.find('widget', 123).then(function(widget) {

View file

@ -88,3 +88,11 @@ test('destroyRecord', function() {
}); });
}); });
}); });
test('find embedded', function() {
const store = createStore();
store.find('fruit', 1).then(function(f) {
ok(f.get('farmer'), 'it has the embedded object');
});
});