Convert Discourse.Post to ES6 and use Store model

- Includes acceptance tests for composer (post, edit)
- Supports acceptance testing of bootbox
This commit is contained in:
Robin Ward
2015-04-01 14:18:46 -04:00
parent 19a9a8b408
commit 22ffcba8e6
19 changed files with 747 additions and 440 deletions

View File

@ -0,0 +1,11 @@
import RestAdapter 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};
});
}
});

View File

@ -61,7 +61,8 @@ export default DiscourseController.extend({
if (postId) { if (postId) {
this.set('model.loading', true); this.set('model.loading', true);
const composer = this; const composer = this;
return Discourse.Post.load(postId).then(function(post) {
return this.store.find('post', postId).then(function(post) {
const quote = Discourse.Quote.build(post, post.get("raw")); const quote = Discourse.Quote.build(post, post.get("raw"));
composer.appendBlockAtCursor(quote); composer.appendBlockAtCursor(quote);
composer.set('model.loading', false); composer.set('model.loading', false);
@ -412,7 +413,7 @@ export default DiscourseController.extend({
composerModel.set('topic', opts.topic); composerModel.set('topic', opts.topic);
} }
} else { } else {
composerModel = composerModel || Discourse.Composer.create(); composerModel = composerModel || Discourse.Composer.create({ store: this.store });
composerModel.open(opts); composerModel.open(opts);
} }

View File

@ -323,7 +323,13 @@
// Adds a listener callback to a DOM element which is fired on a specified // Adds a listener callback to a DOM element which is fired on a specified
// event. // event.
util.addEvent = function (elem, event, listener) { util.addEvent = function (elem, event, listener) {
elem.addEventListener(event, listener, false); var wrapped = function() {
var wrappedArgs = Array.prototype.slice(arguments);
Ember.run(function() {
listener.call(this, wrappedArgs);
});
};
elem.addEventListener(event, wrapped, false);
}; };
@ -904,7 +910,7 @@
// TODO allow us to inject this in (its our debouncer) // TODO allow us to inject this in (its our debouncer)
var debounce = function(func,wait,trickle) { var debounce = function(func,wait,trickle) {
var timeout = null; var timeout = null;
return function(){ return function() {
var context = this; var context = this;
var args = arguments; var args = arguments;
@ -924,8 +930,8 @@
currentWait = wait; currentWait = wait;
} }
if (timeout) { clearTimeout(timeout); } if (timeout) { Ember.run.cancel(timeout); }
timeout = setTimeout(later, currentWait); timeout = Ember.run.later(later, currentWait);
} }
} }

View File

@ -29,7 +29,7 @@ const CLOSED = 'closed',
const Composer = Discourse.Model.extend({ const Composer = Discourse.Model.extend({
archetypes: function() { archetypes: function() {
return Discourse.Site.currentProp('archetypes'); return this.site.get('archetypes');
}.property(), }.property(),
creatingTopic: Em.computed.equal('action', CREATE_TOPIC), creatingTopic: Em.computed.equal('action', CREATE_TOPIC),
@ -127,21 +127,16 @@ const Composer = Discourse.Model.extend({
} else { } else {
// has a category? (when needed) // has a category? (when needed)
return this.get('canCategorize') && return this.get('canCategorize') &&
!Discourse.SiteSettings.allow_uncategorized_topics && !this.siteSettings.allow_uncategorized_topics &&
!this.get('categoryId') && !this.get('categoryId') &&
!Discourse.User.currentProp('staff'); !this.user.get('staff');
} }
}.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'), }.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'),
/**
Is the title's length valid?
@property titleLengthValid
**/
titleLengthValid: function() { titleLengthValid: function() {
if (Discourse.User.currentProp('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true; if (this.user.get('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true;
if (this.get('titleLength') < this.get('minimumTitleLength')) return false; if (this.get('titleLength') < this.get('minimumTitleLength')) return false;
return (this.get('titleLength') <= Discourse.SiteSettings.max_topic_title_length); return (this.get('titleLength') <= this.siteSettings.max_topic_title_length);
}.property('minimumTitleLength', 'titleLength', 'post.static_doc'), }.property('minimumTitleLength', 'titleLength', 'post.static_doc'),
// The icon for the save button // The icon for the save button
@ -194,9 +189,9 @@ const Composer = Discourse.Model.extend({
**/ **/
minimumTitleLength: function() { minimumTitleLength: function() {
if (this.get('privateMessage')) { if (this.get('privateMessage')) {
return Discourse.SiteSettings.min_private_message_title_length; return this.siteSettings.min_private_message_title_length;
} else { } else {
return Discourse.SiteSettings.min_topic_title_length; return this.siteSettings.min_topic_title_length;
} }
}.property('privateMessage'), }.property('privateMessage'),
@ -216,12 +211,12 @@ const Composer = Discourse.Model.extend({
**/ **/
minimumPostLength: function() { minimumPostLength: function() {
if( this.get('privateMessage') ) { if( this.get('privateMessage') ) {
return Discourse.SiteSettings.min_private_message_post_length; return this.siteSettings.min_private_message_post_length;
} else if (this.get('topicFirstPost')) { } else if (this.get('topicFirstPost')) {
// first post (topic body) // first post (topic body)
return Discourse.SiteSettings.min_first_post_length; return this.siteSettings.min_first_post_length;
} else { } else {
return Discourse.SiteSettings.min_post_length; return this.siteSettings.min_post_length;
} }
}.property('privateMessage', 'topicFirstPost'), }.property('privateMessage', 'topicFirstPost'),
@ -249,7 +244,7 @@ const Composer = Discourse.Model.extend({
_setupComposer: function() { _setupComposer: function() {
const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true')); const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true'));
this.set('showPreview', val === 'true'); this.set('showPreview', val === 'true');
this.set('archetypeId', Discourse.Site.currentProp('default_archetype')); this.set('archetypeId', this.site.get('default_archetype'));
}.on('init'), }.on('init'),
/** /**
@ -349,15 +344,15 @@ const Composer = Discourse.Model.extend({
this.setProperties({ this.setProperties({
categoryId: opts.categoryId || this.get('topic.category.id'), categoryId: opts.categoryId || this.get('topic.category.id'),
archetypeId: opts.archetypeId || Discourse.Site.currentProp('default_archetype'), archetypeId: opts.archetypeId || this.site.get('default_archetype'),
metaData: opts.metaData ? Em.Object.create(opts.metaData) : null, metaData: opts.metaData ? Em.Object.create(opts.metaData) : null,
reply: opts.reply || this.get("reply") || "" reply: opts.reply || this.get("reply") || ""
}); });
if (opts.postId) { if (opts.postId) {
this.set('loading', true); this.set('loading', true);
Discourse.Post.load(opts.postId).then(function(result) { this.store.find('post', opts.postId).then(function(post) {
composer.set('post', result); composer.set('post', post);
composer.set('loading', false); composer.set('loading', false);
}); });
} }
@ -370,10 +365,10 @@ const Composer = Discourse.Model.extend({
this.setProperties(topicProps); this.setProperties(topicProps);
Discourse.Post.load(opts.post.get('id')).then(function(result) { this.store.find('post', opts.post.get('id')).then(function(post) {
composer.setProperties({ composer.setProperties({
reply: result.get('raw'), reply: post.get('raw'),
originalText: result.get('raw'), originalText: post.get('raw'),
loading: false loading: false
}); });
}); });
@ -467,7 +462,7 @@ const Composer = Discourse.Model.extend({
createPost(opts) { createPost(opts) {
const post = this.get('post'), const post = this.get('post'),
topic = this.get('topic'), topic = this.get('topic'),
currentUser = Discourse.User.current(), user = this.user,
postStream = this.get('topic.postStream'); postStream = this.get('topic.postStream');
let addedToStream = false; let addedToStream = false;
@ -477,17 +472,17 @@ const Composer = Discourse.Model.extend({
imageSizes: opts.imageSizes, imageSizes: opts.imageSizes,
cooked: this.getCookedHtml(), cooked: this.getCookedHtml(),
reply_count: 0, reply_count: 0,
name: currentUser.get('name'), name: user.get('name'),
display_username: currentUser.get('name'), display_username: user.get('name'),
username: currentUser.get('username'), username: user.get('username'),
user_id: currentUser.get('id'), user_id: user.get('id'),
user_title: currentUser.get('title'), user_title: user.get('title'),
uploaded_avatar_id: currentUser.get('uploaded_avatar_id'), uploaded_avatar_id: user.get('uploaded_avatar_id'),
user_custom_fields: currentUser.get('custom_fields'), user_custom_fields: user.get('custom_fields'),
post_type: Discourse.Site.currentProp('post_types.regular'), post_type: this.site.get('post_types.regular'),
actions_summary: [], actions_summary: [],
moderator: currentUser.get('moderator'), moderator: user.get('moderator'),
admin: currentUser.get('admin'), admin: user.get('admin'),
yours: true, yours: true,
newPost: true, newPost: true,
read: true read: true
@ -520,7 +515,7 @@ const Composer = Discourse.Model.extend({
// we would need to handle oneboxes and other bits that are not even in the // we would need to handle oneboxes and other bits that are not even in the
// engine, staging will just cause a blank post to render // engine, staging will just cause a blank post to render
if (!_.isEmpty(createdPost.get('cooked'))) { if (!_.isEmpty(createdPost.get('cooked'))) {
state = postStream.stagePost(createdPost, currentUser); state = postStream.stagePost(createdPost, user);
if(state === "alreadyStaging"){ if(state === "alreadyStaging"){
return; return;
@ -529,69 +524,64 @@ const Composer = Discourse.Model.extend({
} }
} }
const composer = this, const composer = this;
promise = new Ember.RSVP.Promise(function(resolve, reject) { composer.set('composeState', SAVING);
composer.set('composeState', SAVING);
createdPost.save(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);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
// We created a new topic, let's show it.
composer.set('composeState', CLOSED);
saving = false;
// Update topic_count for the category
const category = Discourse.Site.currentProp('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); });
if (category) category.incrementProperty('topic_count');
Discourse.notifyPropertyChange('globalNotice');
}
composer.clearState();
composer.set('createdPost', createdPost);
if (addedToStream) {
composer.set('composeState', CLOSED);
} else if (saving) {
composer.set('composeState', SAVING);
}
return resolve({ post: result });
}, function(error) {
// If an error occurs
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;
}
reject(parsedError);
});
});
composer.set("stagedPost", state === "staged" && createdPost); composer.set("stagedPost", state === "staged" && createdPost);
return promise; 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);
postStream.commitPost(createdPost);
addedToStream = true;
} else {
// We created a new topic, let's show it.
composer.set('composeState', CLOSED);
saving = false;
// Update topic_count for the category
const category = composer.site.get('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); });
if (category) category.incrementProperty('topic_count');
Discourse.notifyPropertyChange('globalNotice');
}
composer.clearState();
composer.set('createdPost', createdPost);
if (addedToStream) {
composer.set('composeState', CLOSED);
} else if (saving) {
composer.set('composeState', SAVING);
}
return { post: result };
}).catch(function(error) {
// If an error occurs
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() { getCookedHtml() {
@ -604,7 +594,7 @@ const Composer = Discourse.Model.extend({
// Do not save when there is no reply // Do not save when there is no reply
if (!this.get('reply')) return; if (!this.get('reply')) return;
// Do not save when the reply's length is too small // Do not save when the reply's length is too small
if (this.get('replyLength') < Discourse.SiteSettings.min_post_length) return; if (this.get('replyLength') < this.siteSettings.min_post_length) return;
const data = { const data = {
reply: this.get('reply'), reply: this.get('reply'),
@ -673,6 +663,14 @@ Composer.reopenClass({
} }
}, },
create(args) {
args = args || {};
args.user = args.user || Discourse.User.current();
args.site = args.site || Discourse.Site.current();
args.siteSettings = args.siteSettings || Discourse.SiteSettings;
return this._super(args);
},
serializeToTopic(fieldName, property) { serializeToTopic(fieldName, property) {
if (!property) { property = fieldName; } if (!property) { property = fieldName; }
_edit_topic_serializer[fieldName] = property; _edit_topic_serializer[fieldName] = property;

View File

@ -1,20 +1,12 @@
/** const Post = Discourse.Model.extend({
A data model representing a post in a topic
@class Post init() {
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.Post = Discourse.Model.extend({
init: function() {
this.set('replyHistory', []); this.set('replyHistory', []);
}, },
shareUrl: function() { shareUrl: function() {
var user = Discourse.User.current(); const user = Discourse.User.current();
var userSuffix = user ? '?u=' + user.get('username_lower') : ''; const userSuffix = user ? '?u=' + user.get('username_lower') : '';
if (this.get('firstPost')) { if (this.get('firstPost')) {
return this.get('topic.url') + userSuffix; return this.get('topic.url') + userSuffix;
@ -33,7 +25,7 @@ Discourse.Post = Discourse.Model.extend({
userDeleted: Em.computed.empty('user_id'), userDeleted: Em.computed.empty('user_id'),
showName: function() { showName: function() {
var name = this.get('name'); const name = this.get('name');
return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts; return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts;
}.property('name', 'username'), }.property('name', 'username'),
@ -69,17 +61,17 @@ Discourse.Post = Discourse.Model.extend({
}.property("user_id"), }.property("user_id"),
wikiChanged: function() { wikiChanged: function() {
var data = { wiki: this.get("wiki") }; const data = { wiki: this.get("wiki") };
this._updatePost("wiki", data); this._updatePost("wiki", data);
}.observes('wiki'), }.observes('wiki'),
postTypeChanged: function () { postTypeChanged: function () {
var data = { post_type: this.get("post_type") }; const data = { post_type: this.get("post_type") };
this._updatePost("post_type", data); this._updatePost("post_type", data);
}.observes("post_type"), }.observes("post_type"),
_updatePost: function (field, data) { _updatePost(field, data) {
var self = this; const self = this;
Discourse.ajax("/posts/" + this.get("id") + "/" + field, { Discourse.ajax("/posts/" + this.get("id") + "/" + field, {
type: "PUT", type: "PUT",
data: data data: data
@ -103,7 +95,7 @@ Discourse.Post = Discourse.Model.extend({
editCount: function() { return this.get('version') - 1; }.property('version'), editCount: function() { return this.get('version') - 1; }.property('version'),
flagsAvailable: function() { flagsAvailable: function() {
var post = this; const post = this;
return Discourse.Site.currentProp('flagTypes').filter(function(item) { return Discourse.Site.currentProp('flagTypes').filter(function(item) {
return post.get("actionByName." + item.get('name_key') + ".can_act"); return post.get("actionByName." + item.get('name_key') + ".can_act");
}); });
@ -119,9 +111,8 @@ Discourse.Post = Discourse.Model.extend({
}); });
}.property('actions_summary.@each.users', 'actions_summary.@each.count'), }.property('actions_summary.@each.users', 'actions_summary.@each.count'),
// Save a post and call the callback when done. save() {
save: function(complete, error) { const self = this;
var self = this;
if (!this.get('newPost')) { if (!this.get('newPost')) {
// We're updating a post // We're updating a post
return Discourse.ajax("/posts/" + (this.get('id')), { return Discourse.ajax("/posts/" + (this.get('id')), {
@ -135,19 +126,17 @@ Discourse.Post = Discourse.Model.extend({
// If we received a category update, update it // If we received a category update, update it
self.set('version', result.post.version); self.set('version', result.post.version);
if (result.category) Discourse.Site.current().updateCategory(result.category); if (result.category) Discourse.Site.current().updateCategory(result.category);
if (complete) complete(Discourse.Post.create(result.post)); return Discourse.Post.create(result.post);
}).catch(function(result) {
// Post failed to update
if (error) error(result);
}); });
} else { } else {
// We're saving a post // We're saving a post
var data = this.getProperties(Discourse.Composer.serializedFieldsForCreate()); const data = this.getProperties(Discourse.Composer.serializedFieldsForCreate());
data.reply_to_post_number = this.get('reply_to_post_number'); data.reply_to_post_number = this.get('reply_to_post_number');
data.image_sizes = this.get('imageSizes'); data.image_sizes = this.get('imageSizes');
data.nested_post = true;
var metaData = this.get('metaData'); const metaData = this.get('metaData');
// Put the metaData into the request // Put the metaData into the request
if (metaData) { if (metaData) {
data.meta_data = {}; data.meta_data = {};
@ -158,34 +147,22 @@ Discourse.Post = Discourse.Model.extend({
type: 'POST', type: 'POST',
data: data data: data
}).then(function(result) { }).then(function(result) {
// Post created return Discourse.Post.create(result.post);
if (complete) complete(Discourse.Post.create(result));
}).catch(function(result) {
// Failed to create a post
if (error) error(result);
}); });
} }
}, },
/** // Expands the first post's content, if embedded and shortened.
Expands the first post's content, if embedded and shortened. expand() {
const self = this;
@method expandFirstPost
**/
expand: function() {
var self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) { return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) {
self.set('cooked', "<section class='expanded-embed'>" + post.cooked + "</section>" ); self.set('cooked', "<section class='expanded-embed'>" + post.cooked + "</section>" );
}); });
}, },
/** // Recover a deleted post
Recover a deleted post recover() {
const post = this;
@method recover
**/
recover: function() {
var post = this;
post.setProperties({ post.setProperties({
deleted_at: null, deleted_at: null,
deleted_by: null, deleted_by: null,
@ -207,11 +184,8 @@ Discourse.Post = Discourse.Model.extend({
/** /**
Changes the state of the post to be deleted. Does not call the server, that should be Changes the state of the post to be deleted. Does not call the server, that should be
done elsewhere. done elsewhere.
@method setDeletedState
@param {Discourse.User} deletedBy The user deleting the post
**/ **/
setDeletedState: function(deletedBy) { setDeletedState(deletedBy) {
this.set('oldCooked', this.get('cooked')); this.set('oldCooked', this.get('cooked'));
// Moderators can delete posts. Users can only trigger a deleted at message, unless delete_removed_posts_after is 0. // Moderators can delete posts. Users can only trigger a deleted at message, unless delete_removed_posts_after is 0.
@ -237,10 +211,8 @@ Discourse.Post = Discourse.Model.extend({
Changes the state of the post to NOT be deleted. Does not call the server. Changes the state of the post to NOT be deleted. Does not call the server.
This can only be called after setDeletedState was called, but the delete This can only be called after setDeletedState was called, but the delete
failed on the server. failed on the server.
@method undoDeletedState
**/ **/
undoDeleteState: function() { undoDeleteState() {
if (this.get('oldCooked')) { if (this.get('oldCooked')) {
this.setProperties({ this.setProperties({
deleted_at: null, deleted_at: null,
@ -253,13 +225,7 @@ Discourse.Post = Discourse.Model.extend({
} }
}, },
/** destroy(deletedBy) {
Deletes a post
@method destroy
@param {Discourse.User} deletedBy The user deleting the post
**/
destroy: function(deletedBy) {
this.setDeletedState(deletedBy); this.setDeletedState(deletedBy);
return Discourse.ajax("/posts/" + this.get('id'), { return Discourse.ajax("/posts/" + this.get('id'), {
data: { context: window.location.pathname }, data: { context: window.location.pathname },
@ -270,14 +236,11 @@ Discourse.Post = Discourse.Model.extend({
/** /**
Updates a post from another's attributes. This will normally happen when a post is loading but Updates a post from another's attributes. This will normally happen when a post is loading but
is already found in an identity map. is already found in an identity map.
@method updateFromPost
@param {Discourse.Post} otherPost The post we're updating from
**/ **/
updateFromPost: function(otherPost) { updateFromPost(otherPost) {
var self = this; const self = this;
Object.keys(otherPost).forEach(function (key) { Object.keys(otherPost).forEach(function (key) {
var value = otherPost[key], let value = otherPost[key],
oldValue = self[key]; oldValue = self[key];
if (key === "replyHistory") { if (key === "replyHistory") {
@ -287,7 +250,7 @@ Discourse.Post = Discourse.Model.extend({
if (!value) { value = null; } if (!value) { value = null; }
if (!oldValue) { oldValue = null; } if (!oldValue) { oldValue = null; }
var skip = false; let skip = false;
if (typeof value !== "function" && oldValue !== value) { if (typeof value !== "function" && oldValue !== value) {
// wishing for an identity map // wishing for an identity map
if (key === "reply_to_user" && value && oldValue) { if (key === "reply_to_user" && value && oldValue) {
@ -304,17 +267,14 @@ Discourse.Post = Discourse.Model.extend({
/** /**
Updates a post from a JSON packet. This is normally done after the post is saved to refresh any Updates a post from a JSON packet. This is normally done after the post is saved to refresh any
attributes. attributes.
@method updateFromJson
@param {Object} obj The Json data to update with
**/ **/
updateFromJson: function(obj) { updateFromJson(obj) {
if (!obj) return; if (!obj) return;
var skip, oldVal; let skip, oldVal;
// Update all the properties // Update all the properties
var post = this; const post = this;
_.each(obj, function(val,key) { _.each(obj, function(val,key) {
if (key !== 'actions_summary'){ if (key !== 'actions_summary'){
oldVal = post[key]; oldVal = post[key];
@ -336,12 +296,11 @@ Discourse.Post = Discourse.Model.extend({
// Rebuild actions summary // Rebuild actions summary
this.set('actions_summary', Em.A()); this.set('actions_summary', Em.A());
if (obj.actions_summary) { if (obj.actions_summary) {
var lookup = Em.Object.create(); const lookup = Em.Object.create();
_.each(obj.actions_summary,function(a) { _.each(obj.actions_summary,function(a) {
var actionSummary;
a.post = post; a.post = post;
a.actionType = Discourse.Site.current().postActionTypeById(a.id); a.actionType = Discourse.Site.current().postActionTypeById(a.id);
actionSummary = Discourse.ActionSummary.create(a); const actionSummary = Discourse.ActionSummary.create(a);
post.get('actions_summary').pushObject(actionSummary); post.get('actions_summary').pushObject(actionSummary);
lookup.set(a.actionType.get('name_key'), actionSummary); lookup.set(a.actionType.get('name_key'), actionSummary);
}); });
@ -350,7 +309,7 @@ Discourse.Post = Discourse.Model.extend({
}, },
// Load replies to this post // Load replies to this post
loadReplies: function() { loadReplies() {
if(this.get('loadingReplies')){ if(this.get('loadingReplies')){
return; return;
} }
@ -358,12 +317,12 @@ Discourse.Post = Discourse.Model.extend({
this.set('loadingReplies', true); this.set('loadingReplies', true);
this.set('replies', []); this.set('replies', []);
var self = this; const self = this;
return Discourse.ajax("/posts/" + (this.get('id')) + "/replies") return Discourse.ajax("/posts/" + (this.get('id')) + "/replies")
.then(function(loaded) { .then(function(loaded) {
var replies = self.get('replies'); const replies = self.get('replies');
_.each(loaded,function(reply) { _.each(loaded,function(reply) {
var post = Discourse.Post.create(reply); const post = Discourse.Post.create(reply);
post.set('topic', self.get('topic')); post.set('topic', self.get('topic'));
replies.pushObject(post); replies.pushObject(post);
}); });
@ -375,7 +334,7 @@ Discourse.Post = Discourse.Model.extend({
// Whether to show replies directly below // Whether to show replies directly below
showRepliesBelow: function() { showRepliesBelow: function() {
var replyCount = this.get('reply_count'); const replyCount = this.get('reply_count');
// We don't show replies if there aren't any // We don't show replies if there aren't any
if (replyCount === 0) return false; if (replyCount === 0) return false;
@ -387,13 +346,13 @@ Discourse.Post = Discourse.Model.extend({
if (replyCount > 1) return true; if (replyCount > 1) return true;
// If we have *exactly* one reply, we have to consider if it's directly below us // If we have *exactly* one reply, we have to consider if it's directly below us
var topic = this.get('topic'); const topic = this.get('topic');
return !topic.isReplyDirectlyBelow(this); return !topic.isReplyDirectlyBelow(this);
}.property('reply_count'), }.property('reply_count'),
expandHidden: function() { expandHidden() {
var self = this; const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) { return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) {
self.setProperties({ self.setProperties({
cooked: result.cooked, cooked: result.cooked,
@ -402,17 +361,17 @@ Discourse.Post = Discourse.Model.extend({
}); });
}, },
rebake: function () { rebake() {
return Discourse.ajax("/posts/" + this.get("id") + "/rebake", { type: "PUT" }); return Discourse.ajax("/posts/" + this.get("id") + "/rebake", { type: "PUT" });
}, },
unhide: function () { unhide() {
return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" }); return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" });
}, },
toggleBookmark: function() { toggleBookmark() {
var self = this, const self = this;
bookmarkedTopic; let bookmarkedTopic;
this.toggleProperty("bookmarked"); this.toggleProperty("bookmarked");
@ -435,16 +394,16 @@ Discourse.Post = Discourse.Model.extend({
} }
}); });
Discourse.Post.reopenClass({ Post.reopenClass({
createActionSummary: function(result) { createActionSummary(result) {
if (result.actions_summary) { if (result.actions_summary) {
var lookup = Em.Object.create(); const lookup = Em.Object.create();
// this area should be optimized, it is creating way too many objects per post // this area should be optimized, it is creating way too many objects per post
result.actions_summary = result.actions_summary.map(function(a) { result.actions_summary = result.actions_summary.map(function(a) {
a.post = result; a.post = result;
a.actionType = Discourse.Site.current().postActionTypeById(a.id); a.actionType = Discourse.Site.current().postActionTypeById(a.id);
var actionSummary = Discourse.ActionSummary.create(a); const actionSummary = Discourse.ActionSummary.create(a);
lookup[a.actionType.name_key] = actionSummary; lookup[a.actionType.name_key] = actionSummary;
return actionSummary; return actionSummary;
}); });
@ -452,8 +411,8 @@ Discourse.Post.reopenClass({
} }
}, },
create: function(obj) { create(obj) {
var result = this._super.apply(this, arguments); const result = this._super.apply(this, arguments);
this.createActionSummary(result); this.createActionSummary(result);
if (obj && obj.reply_to_user) { if (obj && obj.reply_to_user) {
result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)); result.set('reply_to_user', Discourse.User.create(obj.reply_to_user));
@ -461,14 +420,14 @@ Discourse.Post.reopenClass({
return result; return result;
}, },
updateBookmark: function(postId, bookmarked) { updateBookmark(postId, bookmarked) {
return Discourse.ajax("/posts/" + postId + "/bookmark", { return Discourse.ajax("/posts/" + postId + "/bookmark", {
type: 'PUT', type: 'PUT',
data: { bookmarked: bookmarked } data: { bookmarked: bookmarked }
}); });
}, },
deleteMany: function(selectedPosts, selectedReplies) { deleteMany(selectedPosts, selectedReplies) {
return Discourse.ajax("/posts/destroy_many", { return Discourse.ajax("/posts/destroy_many", {
type: 'DELETE', type: 'DELETE',
data: { data: {
@ -478,37 +437,33 @@ Discourse.Post.reopenClass({
}); });
}, },
loadRevision: function(postId, version) { loadRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) {
return Ember.Object.create(result); return Ember.Object.create(result);
}); });
}, },
hideRevision: function(postId, version) { hideRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/hide", { type: 'PUT' }); return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/hide", { type: 'PUT' });
}, },
showRevision: function(postId, version) { showRevision(postId, version) {
return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/show", { type: 'PUT' }); return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/show", { type: 'PUT' });
}, },
loadQuote: function(postId) { loadQuote(postId) {
return Discourse.ajax("/posts/" + postId + ".json").then(function (result) { return Discourse.ajax("/posts/" + postId + ".json").then(function (result) {
var post = Discourse.Post.create(result); const post = Discourse.Post.create(result);
return Discourse.Quote.build(post, post.get('raw')); return Discourse.Quote.build(post, post.get('raw'));
}); });
}, },
loadRawEmail: function(postId) { loadRawEmail(postId) {
return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) { return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) {
return result.raw_email; return result.raw_email;
}); });
},
load: function(postId) {
return Discourse.ajax("/posts/" + postId + ".json").then(function (result) {
return Discourse.Post.create(result);
});
} }
}); });
export default Post;

View File

@ -25,6 +25,7 @@
//= require ./discourse/lib/safari-hacks //= require ./discourse/lib/safari-hacks
//= require_tree ./discourse/adapters //= require_tree ./discourse/adapters
//= require ./discourse/models/model //= require ./discourse/models/model
//= require ./discourse/models/post
//= require ./discourse/models/user_action //= require ./discourse/models/user_action
//= require ./discourse/models/composer //= require ./discourse/models/composer
//= require ./discourse/models/post-stream //= require ./discourse/models/post-stream

View File

@ -336,7 +336,11 @@ class PostsController < ApplicationController
# doesn't return the post as the root JSON object, but as a nested object. # doesn't return the post as the root JSON object, but as a nested object.
# If a param is present it uses that result structure. # If a param is present it uses that result structure.
def backwards_compatible_json(json_obj, success) def backwards_compatible_json(json_obj, success)
json_obj = json_obj[:post] || json_obj['post'] unless params[:nested_post] json_obj.symbolize_keys!
if params[:nested_post].blank? && json_obj[:errors].blank?
json_obj = json_obj[:post]
end
render json: json_obj, status: (!!success) ? 200 : 422 render json: json_obj, status: (!!success) ? 200 : 422
end end

View File

@ -0,0 +1,117 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Composer", { loggedIn: true });
test("Tests the Composer controls", () => {
visit("/");
andThen(() => {
ok(exists('#create-topic'), 'the create button is visible');
});
click('#create-topic');
andThen(() => {
ok(exists('#wmd-input'), 'the composer input is visible');
ok(exists('.title-input .popup-tip.bad.hide'), 'title errors are hidden by default');
ok(exists('.textarea-wrapper .popup-tip.bad.hide'), 'body errors are hidden by default');
});
click('a.toggle-preview');
andThen(() => {
ok(!exists('#wmd-preview:visible'), "clicking the toggle hides the preview");
});
click('a.toggle-preview');
andThen(() => {
ok(exists('#wmd-preview:visible'), "clicking the toggle shows the preview again");
});
click('#reply-control button.create');
andThen(() => {
ok(!exists('.title-input .popup-tip.bad.hide'), 'it shows the empty title error');
ok(!exists('.textarea-wrapper .popup-tip.bad.hide'), 'it shows the empty body error');
});
fillIn('#reply-title', "this is my new topic title");
andThen(() => {
ok(exists('.title-input .popup-tip.good'), 'the title is now good');
});
fillIn('#wmd-input', "this is the *content* of a post");
andThen(() => {
equal(find('#wmd-preview').html(), "<p>this is the <em>content</em> of a post</p>", "it previews content");
ok(exists('.textarea-wrapper .popup-tip.good'), 'the body is now good');
});
click('#reply-control a.cancel');
andThen(() => {
ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog');
});
click('.modal-footer a:eq(1)');
andThen(() => {
ok(!exists('.bootbox.modal'), 'the confirmation can be cancelled');
});
});
test("Create a topic with server side errors", () => {
visit("/");
click('#create-topic');
fillIn('#reply-title', "this title triggers an error");
fillIn('#wmd-input', "this is the *content* of a post");
click('#reply-control button.create');
andThen(() => {
ok(exists('.bootbox.modal'), 'it pops up an error message');
});
click('.bootbox.modal a.btn-primary');
andThen(() => {
ok(!exists('.bootbox.modal'), 'it dismisses the error');
ok(exists('#wmd-input'), 'the composer input is visible');
});
});
test("Create a Topic", () => {
visit("/");
click('#create-topic');
fillIn('#reply-title', "Internationalization Localization");
fillIn('#wmd-input', "this is the *content* of a new topic post");
click('#reply-control button.create');
andThen(() => {
equal(currentURL(), "/t/internationalization-localization/280", "it transitions to the newly created topic URL");
});
});
test("Create a Reply", () => {
visit("/t/internationalization-localization/280");
click('#topic-footer-buttons .btn.create');
andThen(() => {
ok(exists('#wmd-input'), 'the composer input is visible');
ok(!exists('#reply-title'), 'there is no title since this is a reply');
});
fillIn('#wmd-input', 'this is the content of my reply');
click('#reply-control button.create');
andThen(() => {
exists('#post_12345', 'it inserts the post into the document');
});
});
test("Edit the first post", () => {
visit("/t/internationalization-localization/280");
click('.topic-post:eq(0) button[data-action=showMoreActions]');
click('.topic-post:eq(0) button[data-action=edit]');
andThen(() => {
equal(find('#wmd-input').val().indexOf('Any plans to support'), 0, 'it populates the input with the post text');
});
fillIn('#wmd-input', "This is the new text for the post");
fillIn('#reply-title', "This is the new text for the title");
click('#reply-control button.create');
andThen(() => {
ok(!exists('#wmd-input'), 'it closes the composer');
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,10 +1,6 @@
import { acceptance } from "helpers/qunit-helpers"; import { acceptance } from "helpers/qunit-helpers";
acceptance("Header (Staff)", { acceptance("Header (Staff)", { loggedIn: true });
user: { username: 'test',
staff: true,
site_flagged_posts_count: 1 }
});
test("header", () => { test("header", () => {
visit("/"); visit("/");

View File

@ -0,0 +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}
};

View File

@ -0,0 +1,4 @@
export default {
"/session/current.json": {"current_user":{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/localhost/eviltrout/{size}/5275.png","name":"Robin Ward","total_unread_notifications":205,"unread_notifications":0,"unread_private_messages":0,"admin":true,"notification_channel_position":null,"site_flagged_posts_count":1,"moderator":true,"staff":true,"title":"co-founder","reply_count":859,"topic_count":36,"enable_quoting":true,"external_links_in_new_tab":false,"dynamic_favicon":true,"trust_level":4,"can_edit":true,"can_invite_to_forum":true,"should_be_redirected_to_top":false,"disable_jump_reply":false,"custom_fields":{},"muted_category_ids":[],"dismissed_banner_key":null,"akismet_review_count":0}}
};

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@ function parsePostData(query) {
const result = {}; const result = {};
query.split("&").forEach(function(part) { query.split("&").forEach(function(part) {
const item = part.split("="); const item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]); result[item[0]] = decodeURIComponent(item[1]).replace(/\+/g, ' ');
}); });
return result; return result;
} }
@ -33,9 +33,16 @@ const _moreWidgets = [
{id: 224, name: 'Good Repellant'} {id: 224, name: 'Good Repellant'}
]; ];
function loggedIn() {
return !!Discourse.User.current();
}
export default function() { export default function() {
const server = new Pretender(function() { const server = new Pretender(function() {
const fixturesByUrl = {};
// Load any fixtures automatically // Load any fixtures automatically
const self = this; const self = this;
Ember.keys(require._eak_seen).forEach(function(entry) { Ember.keys(require._eak_seen).forEach(function(entry) {
@ -44,6 +51,7 @@ export default function() {
if (fixture && fixture.default) { if (fixture && fixture.default) {
const obj = fixture.default; const obj = fixture.default;
Ember.keys(obj).forEach(function(url) { Ember.keys(obj).forEach(function(url) {
fixturesByUrl[url] = obj[url];
self.get(url, function() { self.get(url, function() {
return response(obj[url]); return response(obj[url]);
}); });
@ -52,6 +60,20 @@ export default function() {
} }
}); });
this.get('/composer-messages', () => { return response([]); });
this.get("/latest.json", () => {
const json = fixturesByUrl['/latest.json'];
if (loggedIn()) {
// Stuff to let us post
json.topic_list.can_create_topic = true;
json.topic_list.draft_key = "new_topic";
json.topic_list.draft_sequence = 1;
}
return response(json);
});
this.get("/t/id_for/:slug", function() { this.get("/t/id_for/:slug", function() {
return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"}); return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"});
}); });
@ -99,6 +121,33 @@ export default function() {
this.delete('/posts/:post_id', success); this.delete('/posts/:post_id', success);
this.put('/posts/:post_id/recover', success); this.put('/posts/:post_id/recover', success);
this.put('/posts/:post_id', (request) => {
return response({ post: {id: request.params.post_id, version: 2 } });
});
this.put('/t/:slug/:id', (request) => {
const data = parsePostData(request.requestBody);
return response(200, { basic_topic: {id: request.params.id,
title: data.title,
fancy_title: data.title,
slug: request.params.slug } })
});
this.post('/posts', function(request) {
const data = parsePostData(request.requestBody);
if (data.title === "this title triggers an error") {
return response(422, {errors: ['That title has already been taken']});
} else {
return response(200, {
success: true,
action: 'create_post',
post: {id: 12345, topic_id: 280, topic_slug: 'internationalization-localization'}
});
}
});
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) {
@ -130,8 +179,11 @@ export default function() {
}); });
this.delete('/widgets/:widget_id', success); this.delete('/widgets/:widget_id', success);
});
this.post('/topics/timings', function() {
return response(200, {});
});
});
server.prepareBody = function(body){ server.prepareBody = function(body){
if (body && typeof body === "object") { if (body && typeof body === "object") {

View File

@ -1,20 +1,59 @@
/* global asyncTest */ /* global asyncTest */
import sessionFixtures from 'fixtures/session-fixtures';
import siteFixtures from 'fixtures/site_fixtures'; import siteFixtures from 'fixtures/site_fixtures';
import HeaderView from 'discourse/views/header';
function currentUser() {
return Discourse.User.create(sessionFixtures['/session/current.json'].current_user);
}
function logIn() {
Discourse.User.resetCurrent(currentUser());
}
const Plugin = $.fn.modal;
const Modal = Plugin.Constructor;
function AcceptanceModal(option, _relatedTarget) {
return this.each(function () {
var $this = $(this);
var data = $this.data('bs.modal');
var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option === 'object' && option);
if (!data) $this.data('bs.modal', (data = new Modal(this, options)));
data.$body = $('#ember-testing');
if (typeof option === 'string') data[option](_relatedTarget);
else if (options.show) data.show(_relatedTarget);
});
}
window.bootbox.$body = $('#ember-testing');
$.fn.modal = AcceptanceModal;
var oldAvatar = Discourse.Utilities.avatarImg;
function acceptance(name, options) { function acceptance(name, options) {
module("Acceptance: " + name, { module("Acceptance: " + name, {
setup: function() { setup: function() {
Ember.run(Discourse, Discourse.advanceReadiness); Ember.run(Discourse, Discourse.advanceReadiness);
// Don't render avatars in acceptance tests, it's faster and no 404s
Discourse.Utilities.avatarImg = () => "";
// For now don't do scrolling stuff in Test Mode
Ember.CloakedCollectionView.scrolled = Ember.K;
HeaderView.reopen({examineDockHeader: Ember.K});
var siteJson = siteFixtures['site.json'].site; var siteJson = siteFixtures['site.json'].site;
if (options) { if (options) {
if (options.setup) { if (options.setup) {
options.setup.call(this); options.setup.call(this);
} }
if (options.user) { if (options.loggedIn) {
Discourse.User.resetCurrent(Discourse.User.create(options.user)); logIn();
} }
if (options.settings) { if (options.settings) {
@ -34,6 +73,7 @@ function acceptance(name, options) {
options.teardown.call(this); options.teardown.call(this);
} }
Discourse.Utilities.avatarImg = oldAvatar;
Discourse.reset(); Discourse.reset();
} }
}); });
@ -61,4 +101,4 @@ function fixture(selector) {
return $("#qunit-fixture"); return $("#qunit-fixture");
} }
export { acceptance, controllerFor, asyncTestDiscourse, fixture }; export { acceptance, controllerFor, asyncTestDiscourse, fixture, logIn, currentUser };

View File

@ -1,16 +1,18 @@
module("Discourse.Composer", { import { currentUser } from 'helpers/qunit-helpers';
setup: function() {
sandbox.stub(Discourse.User, 'currentProp').withArgs('admin').returns(false);
},
teardown: function() { module("model:composer");
Discourse.User.currentProp.restore();
} function createComposer(opts) {
}); opts = opts || {};
opts.user = opts.user || currentUser();
opts.site = Discourse.Site.current();
opts.siteSettings = Discourse.SiteSettings;
return Discourse.Composer.create(opts);
}
test('replyLength', function() { test('replyLength', function() {
var replyLength = function(val, expectedLength) { const replyLength = function(val, expectedLength) {
var composer = Discourse.Composer.create({ reply: val }); const composer = createComposer({ reply: val });
equal(composer.get('replyLength'), expectedLength); equal(composer.get('replyLength'), expectedLength);
}; };
@ -23,8 +25,8 @@ test('replyLength', function() {
test('missingReplyCharacters', function() { test('missingReplyCharacters', function() {
Discourse.SiteSettings.min_first_post_length = 40; Discourse.SiteSettings.min_first_post_length = 40;
var missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) { const missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) {
var composer = Discourse.Composer.create({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost }); const composer = createComposer({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost });
equal(composer.get('missingReplyCharacters'), expected, message); equal(composer.get('missingReplyCharacters'), expected, message);
}; };
@ -34,8 +36,8 @@ test('missingReplyCharacters', function() {
}); });
test('missingTitleCharacters', function() { test('missingTitleCharacters', function() {
var missingTitleCharacters = function(val, isPM, expected, message) { const missingTitleCharacters = function(val, isPM, expected, message) {
var composer = Discourse.Composer.create({ title: val, creatingPrivateMessage: isPM }); const composer = createComposer({ title: val, creatingPrivateMessage: isPM });
equal(composer.get('missingTitleCharacters'), expected, message); equal(composer.get('missingTitleCharacters'), expected, message);
}; };
@ -44,7 +46,7 @@ test('missingTitleCharacters', function() {
}); });
test('replyDirty', function() { test('replyDirty', function() {
var composer = Discourse.Composer.create(); const composer = createComposer();
ok(!composer.get('replyDirty'), "by default it's false"); ok(!composer.get('replyDirty'), "by default it's false");
composer.setProperties({ composer.setProperties({
@ -58,7 +60,7 @@ test('replyDirty', function() {
}); });
test("appendText", function() { test("appendText", function() {
var composer = Discourse.Composer.create(); const composer = createComposer();
blank(composer.get('reply'), "the reply is blank by default"); blank(composer.get('reply'), "the reply is blank by default");
@ -89,7 +91,7 @@ test("appendText", function() {
test("Title length for regular topics", function() { test("Title length for regular topics", function() {
Discourse.SiteSettings.min_topic_title_length = 5; Discourse.SiteSettings.min_topic_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10; Discourse.SiteSettings.max_topic_title_length = 10;
var composer = Discourse.Composer.create(); const composer = createComposer();
composer.set('title', 'asdf'); composer.set('title', 'asdf');
ok(!composer.get('titleLengthValid'), "short titles are not valid"); ok(!composer.get('titleLengthValid'), "short titles are not valid");
@ -104,7 +106,7 @@ test("Title length for regular topics", function() {
test("Title length for private messages", function() { test("Title length for private messages", function() {
Discourse.SiteSettings.min_private_message_title_length = 5; Discourse.SiteSettings.min_private_message_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10; Discourse.SiteSettings.max_topic_title_length = 10;
var composer = Discourse.Composer.create({action: Discourse.Composer.PRIVATE_MESSAGE}); const composer = createComposer({action: Discourse.Composer.PRIVATE_MESSAGE});
composer.set('title', 'asdf'); composer.set('title', 'asdf');
ok(!composer.get('titleLengthValid'), "short titles are not valid"); ok(!composer.get('titleLengthValid'), "short titles are not valid");
@ -119,7 +121,7 @@ test("Title length for private messages", function() {
test("Title length for private messages", function() { test("Title length for private messages", function() {
Discourse.SiteSettings.min_private_message_title_length = 5; Discourse.SiteSettings.min_private_message_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10; Discourse.SiteSettings.max_topic_title_length = 10;
var composer = Discourse.Composer.create({action: Discourse.Composer.PRIVATE_MESSAGE}); const composer = createComposer({action: Discourse.Composer.PRIVATE_MESSAGE});
composer.set('title', 'asdf'); composer.set('title', 'asdf');
ok(!composer.get('titleLengthValid'), "short titles are not valid"); ok(!composer.get('titleLengthValid'), "short titles are not valid");
@ -132,10 +134,10 @@ test("Title length for private messages", function() {
}); });
test('editingFirstPost', function() { test('editingFirstPost', function() {
var composer = Discourse.Composer.create(); const composer = createComposer();
ok(!composer.get('editingFirstPost'), "it's false by default"); ok(!composer.get('editingFirstPost'), "it's false by default");
var post = Discourse.Post.create({id: 123, post_number: 2}); const post = Discourse.Post.create({id: 123, post_number: 2});
composer.setProperties({post: post, action: Discourse.Composer.EDIT }); composer.setProperties({post: post, action: Discourse.Composer.EDIT });
ok(!composer.get('editingFirstPost'), "it's false when not editing the first post"); ok(!composer.get('editingFirstPost'), "it's false when not editing the first post");
@ -145,7 +147,7 @@ test('editingFirstPost', function() {
}); });
test('clearState', function() { test('clearState', function() {
var composer = Discourse.Composer.create({ const composer = createComposer({
originalText: 'asdf', originalText: 'asdf',
reply: 'asdf2', reply: 'asdf2',
post: Discourse.Post.create({id: 1}), post: Discourse.Post.create({id: 1}),
@ -163,61 +165,48 @@ test('clearState', function() {
test('initial category when uncategorized is allowed', function() { test('initial category when uncategorized is allowed', function() {
Discourse.SiteSettings.allow_uncategorized_topics = true; Discourse.SiteSettings.allow_uncategorized_topics = true;
var composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); const composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1});
equal(composer.get('categoryId'),undefined,"Uncategorized by default"); equal(composer.get('categoryId'),undefined,"Uncategorized by default");
}); });
test('initial category when uncategorized is not allowed', function() { test('initial category when uncategorized is not allowed', function() {
Discourse.SiteSettings.allow_uncategorized_topics = false; Discourse.SiteSettings.allow_uncategorized_topics = false;
var composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); const composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1});
ok(composer.get('categoryId') === undefined, "Uncategorized by default. Must choose a category."); ok(composer.get('categoryId') === undefined, "Uncategorized by default. Must choose a category.");
}); });
test('showPreview', function() { test('showPreview', function() {
var new_composer = function() { const newComposer = function() {
return Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); return Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1});
}; };
Discourse.Mobile.mobileView = true; Discourse.Mobile.mobileView = true;
equal(new_composer().get('showPreview'), false, "Don't show preview in mobile view"); equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view");
Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: 'true' }); Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: 'true' });
equal(new_composer().get('showPreview'), false, "Don't show preview in mobile view even if KeyValueStore wants to"); equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view even if KeyValueStore wants to");
Discourse.KeyValueStore.remove('composer.showPreview'); Discourse.KeyValueStore.remove('composer.showPreview');
Discourse.Mobile.mobileView = false; Discourse.Mobile.mobileView = false;
equal(new_composer().get('showPreview'), true, "Show preview by default in desktop view"); equal(newComposer().get('showPreview'), true, "Show preview by default in desktop view");
}); });
test('open with a quote', function() { test('open with a quote', function() {
var quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]'; const quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]';
var new_composer = function() { const newComposer = function() {
return Discourse.Composer.open({action: Discourse.Composer.REPLY, draftKey: 'asfd', draftSequence: 1, quote: quote}); return Discourse.Composer.open({action: Discourse.Composer.REPLY, draftKey: 'asfd', draftSequence: 1, quote: quote});
}; };
equal(new_composer().get('originalText'), quote, "originalText is the quote" ); equal(newComposer().get('originalText'), quote, "originalText is the quote" );
equal(new_composer().get('replyDirty'), false, "replyDirty is initally false with a quote" ); equal(newComposer().get('replyDirty'), false, "replyDirty is initally false with a quote" );
});
module("Discourse.Composer as admin", {
setup: function() {
Discourse.SiteSettings.min_topic_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10;
sandbox.stub(Discourse.User, 'currentProp').withArgs('admin').returns(true);
},
teardown: function() {
Discourse.SiteSettings.min_topic_title_length = 15;
Discourse.SiteSettings.max_topic_title_length = 255;
Discourse.User.currentProp.restore();
}
}); });
test("Title length for static page topics as admin", function() { test("Title length for static page topics as admin", function() {
var composer = Discourse.Composer.create(); Discourse.SiteSettings.min_topic_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10;
const composer = createComposer();
var post = Discourse.Post.create({id: 123, post_number: 2, static_doc: true}); const post = Discourse.Post.create({id: 123, post_number: 2, static_doc: true});
composer.setProperties({post: post, action: Discourse.Composer.EDIT }); composer.setProperties({post: post, action: Discourse.Composer.EDIT });
composer.set('title', 'asdf'); composer.set('title', 'asdf');

View File

@ -59,7 +59,11 @@ sinon.config = {
useFakeServer: false useFakeServer: false
}; };
window.assetPath = function() { return null; }; window.assetPath = function(url) {
if (url.indexOf('defer') === 0) {
return "/assets/" + url;
}
};
// Stop the message bus so we don't get ajax calls // Stop the message bus so we don't get ajax calls
window.MessageBus.stop(); window.MessageBus.stop();

View File

@ -427,16 +427,17 @@ var bootbox = window.bootbox || (function(document, $) {
}); });
// well, *if* we have a primary - give the first dom element focus // well, *if* we have a primary - give the first dom element focus
div.on('shown', function() { div.on('shown.bs.modal', function() {
div.find("a.btn-primary:first").focus(); div.find("a.btn-primary:first").focus();
}); });
div.on('hidden', function() { div.on('hidden.bs.modal', function() {
div.remove(); div.remove();
}); });
// wire up button handlers // wire up button handlers
div.on('click', '.modal-footer a', function(e) { div.on('click', '.modal-footer a', function(e) {
Ember.run(function() {
var handler = $(this).data("handler"), var handler = $(this).data("handler"),
cb = callbacks[handler], cb = callbacks[handler],
@ -462,10 +463,11 @@ var bootbox = window.bootbox || (function(document, $) {
if (hideModal !== false) { if (hideModal !== false) {
div.modal("hide"); div.modal("hide");
} }
});
}); });
// stick the modal right at the bottom of the main body out of the way // stick the modal right at the bottom of the main body out of the way
$("body").append(div); (that.$body || $("body")).append(div);
div.modal({ div.modal({
// unless explicitly overridden take whatever our default backdrop value is // unless explicitly overridden take whatever our default backdrop value is

View File

@ -1,218 +1,338 @@
/* ========================================================= /* ========================================================================
* bootstrap-modal.js v2.0.3 * Bootstrap: modal.js v3.3.4
* http://twitter.github.com/bootstrap/javascript.html#modals * http://getbootstrap.com/javascript/#modals
* ========================================================= * ========================================================================
* Copyright 2012 Twitter, Inc. * Copyright 2011-2015 Twitter, Inc.
* * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Licensed under the Apache License, Version 2.0 (the "License"); * ======================================================================== */
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
!function ($) { +function ($) {
'use strict';
"use strict"; // jshint ;_; // MODAL CLASS DEFINITION
// ======================
var Modal = function (element, options) {
this.options = options
this.$body = $(document.body)
this.$element = $(element)
this.$dialog = this.$element.find('.modal-dialog')
this.$backdrop = null
this.isShown = null
this.originalBodyPad = null
this.scrollbarWidth = 0
this.ignoreBackdropClick = false
/* MODAL CLASS DEFINITION if (this.options.remote) {
* ====================== */ this.$element
.find('.modal-content')
var Modal = function (content, options) { .load(this.options.remote, $.proxy(function () {
this.options = options this.$element.trigger('loaded.bs.modal')
this.$element = $(content) }, this))
.delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)) }
} }
Modal.prototype = { Modal.VERSION = '3.3.4'
constructor: Modal Modal.TRANSITION_DURATION = 300
Modal.BACKDROP_TRANSITION_DURATION = 150
, toggle: function () {
return this[!this.isShown ? 'show' : 'hide']()
}
, show: function () {
var that = this
, e = $.Event('show')
this.$element.trigger(e)
if (this.isShown || e.isDefaultPrevented()) return
$('body').addClass('modal-open')
this.isShown = true
escape.call(this)
backdrop.call(this, function () {
var transition = $.support.transition && that.$element.hasClass('fade')
if (!that.$element.parent().length) {
that.$element.appendTo(document.body) //don't move modals dom position
}
that.$element
.show()
if (transition) {
that.$element[0].offsetWidth // force reflow
}
that.$element.addClass('in')
transition ?
that.$element.one($.support.transition.end, function () { that.$element.trigger('shown') }) :
that.$element.trigger('shown')
})
}
, hide: function (e) {
e && e.preventDefault()
var that = this
e = $.Event('hide')
this.$element.trigger(e)
if (!this.isShown || e.isDefaultPrevented()) return
this.isShown = false
$('body').removeClass('modal-open')
escape.call(this)
this.$element.removeClass('in')
$.support.transition && this.$element.hasClass('fade') ?
hideWithTransition.call(this) :
hideModal.call(this)
}
Modal.DEFAULTS = {
backdrop: true,
keyboard: true,
show: true
} }
Modal.prototype.toggle = function (_relatedTarget) {
return this.isShown ? this.hide() : this.show(_relatedTarget)
}
/* MODAL PRIVATE METHODS Modal.prototype.show = function (_relatedTarget) {
* ===================== */
function hideWithTransition() {
var that = this var that = this
, timeout = setTimeout(function () { var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget })
that.$element.off($.support.transition.end)
hideModal.call(that)
}, 500)
this.$element.one($.support.transition.end, function () { this.$element.trigger(e)
clearTimeout(timeout)
hideModal.call(that) if (this.isShown || e.isDefaultPrevented()) return
this.isShown = true
this.checkScrollbar()
this.setScrollbar()
this.$body.addClass('modal-open')
this.escape()
this.resize()
this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this))
this.$dialog.on('mousedown.dismiss.bs.modal', function () {
that.$element.one('mouseup.dismiss.bs.modal', function (e) {
if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true
})
})
this.backdrop(function () {
var transition = $.support.transition && that.$element.hasClass('fade')
if (!that.$element.parent().length) {
that.$element.appendTo(that.$body) // don't move modals dom position
}
that.$element
.show()
.scrollTop(0)
that.adjustDialog()
if (transition) {
that.$element[0].offsetWidth // force reflow
}
that.$element.addClass('in')
that.enforceFocus()
var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget })
transition ?
that.$dialog // wait for modal to slide in
.one('bsTransitionEnd', function () {
that.$element.trigger('focus').trigger(e)
})
.emulateTransitionEnd(Modal.TRANSITION_DURATION) :
that.$element.trigger('focus').trigger(e)
}) })
} }
function hideModal(that) { Modal.prototype.hide = function (e) {
this.$element if (e) e.preventDefault()
.hide()
.trigger('hidden')
backdrop.call(this) e = $.Event('hide.bs.modal')
this.$element.trigger(e)
if (!this.isShown || e.isDefaultPrevented()) return
this.isShown = false
this.escape()
this.resize()
$(document).off('focusin.bs.modal')
this.$element
.removeClass('in')
.off('click.dismiss.bs.modal')
.off('mouseup.dismiss.bs.modal')
this.$dialog.off('mousedown.dismiss.bs.modal')
$.support.transition && this.$element.hasClass('fade') ?
this.$element
.one('bsTransitionEnd', $.proxy(this.hideModal, this))
.emulateTransitionEnd(Modal.TRANSITION_DURATION) :
this.hideModal()
} }
function backdrop(callback) { Modal.prototype.enforceFocus = function () {
$(document)
.off('focusin.bs.modal') // guard against infinite focus loop
.on('focusin.bs.modal', $.proxy(function (e) {
if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
this.$element.trigger('focus')
}
}, this))
}
Modal.prototype.escape = function () {
if (this.isShown && this.options.keyboard) {
this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) {
e.which == 27 && this.hide()
}, this))
} else if (!this.isShown) {
this.$element.off('keydown.dismiss.bs.modal')
}
}
Modal.prototype.resize = function () {
if (this.isShown) {
$(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this))
} else {
$(window).off('resize.bs.modal')
}
}
Modal.prototype.hideModal = function () {
var that = this var that = this
, animate = this.$element.hasClass('fade') ? 'fade' : '' this.$element.hide()
this.backdrop(function () {
that.$body.removeClass('modal-open')
that.resetAdjustments()
that.resetScrollbar()
that.$element.trigger('hidden.bs.modal')
})
}
Modal.prototype.removeBackdrop = function () {
this.$backdrop && this.$backdrop.remove()
this.$backdrop = null
}
Modal.prototype.backdrop = function (callback) {
var that = this
var animate = this.$element.hasClass('fade') ? 'fade' : ''
if (this.isShown && this.options.backdrop) { if (this.isShown && this.options.backdrop) {
var doAnimate = $.support.transition && animate var doAnimate = $.support.transition && animate
this.$backdrop = $('<div class="modal-backdrop ' + animate + '" />') this.$backdrop = $(document.createElement('div'))
.appendTo(document.body) .addClass('modal-backdrop ' + animate)
.appendTo(this.$body)
if (this.options.backdrop != 'static') { this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) {
this.$backdrop.click($.proxy(this.hide, this)) if (this.ignoreBackdropClick) {
} this.ignoreBackdropClick = false
return
}
if (e.target !== e.currentTarget) return
this.options.backdrop == 'static'
? this.$element[0].focus()
: this.hide()
}, this))
if (doAnimate) this.$backdrop[0].offsetWidth // force reflow if (doAnimate) this.$backdrop[0].offsetWidth // force reflow
this.$backdrop.addClass('in') this.$backdrop.addClass('in')
if (!callback) return
doAnimate ? doAnimate ?
this.$backdrop.one($.support.transition.end, callback) : this.$backdrop
.one('bsTransitionEnd', callback)
.emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
callback() callback()
} else if (!this.isShown && this.$backdrop) { } else if (!this.isShown && this.$backdrop) {
this.$backdrop.removeClass('in') this.$backdrop.removeClass('in')
$.support.transition && this.$element.hasClass('fade')? var callbackRemove = function () {
this.$backdrop.one($.support.transition.end, $.proxy(removeBackdrop, this)) : that.removeBackdrop()
removeBackdrop.call(this) callback && callback()
}
$.support.transition && this.$element.hasClass('fade') ?
this.$backdrop
.one('bsTransitionEnd', callbackRemove)
.emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
callbackRemove()
} else if (callback) { } else if (callback) {
callback() callback()
} }
} }
function removeBackdrop() { // these following methods are used to handle overflowing modals
this.$backdrop.remove()
this.$backdrop = null Modal.prototype.handleUpdate = function () {
this.adjustDialog()
} }
function escape() { Modal.prototype.adjustDialog = function () {
var that = this var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight
if (this.isShown && this.options.keyboard) {
$(document).on('keyup.dismiss.modal', function ( e ) {
e.which == 27 && that.hide()
})
} else if (!this.isShown) {
$(document).off('keyup.dismiss.modal')
}
}
this.$element.css({
/* MODAL PLUGIN DEFINITION paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '',
* ======================= */ paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : ''
$.fn.modal = function (option) {
return this.each(function () {
var $this = $(this)
, data = $this.data('modal')
, options = $.extend({}, $.fn.modal.defaults, $this.data(), typeof option == 'object' && option)
if (!data) $this.data('modal', (data = new Modal(this, options)))
if (typeof option == 'string') data[option]()
else if (options.show) data.show()
}) })
} }
$.fn.modal.defaults = { Modal.prototype.resetAdjustments = function () {
backdrop: true this.$element.css({
, keyboard: true paddingLeft: '',
, show: true paddingRight: ''
})
} }
Modal.prototype.checkScrollbar = function () {
var fullWindowWidth = window.innerWidth
if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8
var documentElementRect = document.documentElement.getBoundingClientRect()
fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left)
}
this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth
this.scrollbarWidth = this.measureScrollbar()
}
Modal.prototype.setScrollbar = function () {
var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10)
this.originalBodyPad = document.body.style.paddingRight || ''
if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth)
}
Modal.prototype.resetScrollbar = function () {
this.$body.css('padding-right', this.originalBodyPad)
}
Modal.prototype.measureScrollbar = function () { // thx walsh
var scrollDiv = document.createElement('div')
scrollDiv.className = 'modal-scrollbar-measure'
this.$body.append(scrollDiv)
var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
this.$body[0].removeChild(scrollDiv)
return scrollbarWidth
}
// MODAL PLUGIN DEFINITION
// =======================
function Plugin(option, _relatedTarget) {
return this.each(function () {
var $this = $(this)
var data = $this.data('bs.modal')
var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option)
if (!data) $this.data('bs.modal', (data = new Modal(this, options)))
if (typeof option == 'string') data[option](_relatedTarget)
else if (options.show) data.show(_relatedTarget)
})
}
var old = $.fn.modal
$.fn.modal = Plugin
$.fn.modal.Constructor = Modal $.fn.modal.Constructor = Modal
/* MODAL DATA-API // MODAL NO CONFLICT
* ============== */ // =================
$(function () { $.fn.modal.noConflict = function () {
$('body').on('click.modal.data-api', '[data-toggle="modal"]', function ( e ) { $.fn.modal = old
var $this = $(this), href return this
, $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) //strip for ie7 }
, option = $target.data('modal') ? 'toggle' : $.extend({}, $target.data(), $this.data())
e.preventDefault()
$target.modal(option) // MODAL DATA-API
// ==============
$(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
var $this = $(this)
var href = $this.attr('href')
var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7
var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
if ($this.is('a')) e.preventDefault()
$target.one('show.bs.modal', function (showEvent) {
if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown
$target.one('hidden.bs.modal', function () {
$this.is(':visible') && $this.trigger('focus')
})
}) })
Plugin.call($target, option, this)
}) })
}(window.jQuery); }(jQuery);

View File

@ -148,11 +148,14 @@
// Find the bottom view and what's onscreen // Find the bottom view and what's onscreen
while (bottomView < childViews.length) { while (bottomView < childViews.length) {
var view = childViews[bottomView], var view = childViews[bottomView],
$view = view.$(), $view = view.$();
// in case of not full-window scrolling
scrollOffset = this.get('wrapperTop') || 0, if (!$view) { break; }
viewTop = $view.offset().top + scrollOffset,
viewBottom = viewTop + $view.height(); // in case of not full-window scrolling
var scrollOffset = this.get('wrapperTop') || 0,
viewTop = $view.offset().top + scrollOffset,
viewBottom = viewTop + $view.height();
if (viewTop > viewportBottom) { break; } if (viewTop > viewportBottom) { break; }
toUncloak.push(view); toUncloak.push(view);