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) {
this.set('model.loading', true);
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"));
composer.appendBlockAtCursor(quote);
composer.set('model.loading', false);
@ -412,7 +413,7 @@ export default DiscourseController.extend({
composerModel.set('topic', opts.topic);
}
} else {
composerModel = composerModel || Discourse.Composer.create();
composerModel = composerModel || Discourse.Composer.create({ store: this.store });
composerModel.open(opts);
}

View File

@ -323,7 +323,13 @@
// Adds a listener callback to a DOM element which is fired on a specified
// event.
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)
var debounce = function(func,wait,trickle) {
var timeout = null;
return function(){
return function() {
var context = this;
var args = arguments;
@ -924,8 +930,8 @@
currentWait = wait;
}
if (timeout) { clearTimeout(timeout); }
timeout = setTimeout(later, currentWait);
if (timeout) { Ember.run.cancel(timeout); }
timeout = Ember.run.later(later, currentWait);
}
}

View File

@ -29,7 +29,7 @@ const CLOSED = 'closed',
const Composer = Discourse.Model.extend({
archetypes: function() {
return Discourse.Site.currentProp('archetypes');
return this.site.get('archetypes');
}.property(),
creatingTopic: Em.computed.equal('action', CREATE_TOPIC),
@ -127,21 +127,16 @@ const Composer = Discourse.Model.extend({
} else {
// has a category? (when needed)
return this.get('canCategorize') &&
!Discourse.SiteSettings.allow_uncategorized_topics &&
!this.siteSettings.allow_uncategorized_topics &&
!this.get('categoryId') &&
!Discourse.User.currentProp('staff');
!this.user.get('staff');
}
}.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'),
/**
Is the title's length valid?
@property titleLengthValid
**/
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;
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'),
// The icon for the save button
@ -194,9 +189,9 @@ const Composer = Discourse.Model.extend({
**/
minimumTitleLength: function() {
if (this.get('privateMessage')) {
return Discourse.SiteSettings.min_private_message_title_length;
return this.siteSettings.min_private_message_title_length;
} else {
return Discourse.SiteSettings.min_topic_title_length;
return this.siteSettings.min_topic_title_length;
}
}.property('privateMessage'),
@ -216,12 +211,12 @@ const Composer = Discourse.Model.extend({
**/
minimumPostLength: function() {
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')) {
// first post (topic body)
return Discourse.SiteSettings.min_first_post_length;
return this.siteSettings.min_first_post_length;
} else {
return Discourse.SiteSettings.min_post_length;
return this.siteSettings.min_post_length;
}
}.property('privateMessage', 'topicFirstPost'),
@ -249,7 +244,7 @@ const Composer = Discourse.Model.extend({
_setupComposer: function() {
const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true'));
this.set('showPreview', val === 'true');
this.set('archetypeId', Discourse.Site.currentProp('default_archetype'));
this.set('archetypeId', this.site.get('default_archetype'));
}.on('init'),
/**
@ -349,15 +344,15 @@ const Composer = Discourse.Model.extend({
this.setProperties({
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,
reply: opts.reply || this.get("reply") || ""
});
if (opts.postId) {
this.set('loading', true);
Discourse.Post.load(opts.postId).then(function(result) {
composer.set('post', result);
this.store.find('post', opts.postId).then(function(post) {
composer.set('post', post);
composer.set('loading', false);
});
}
@ -370,10 +365,10 @@ const Composer = Discourse.Model.extend({
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({
reply: result.get('raw'),
originalText: result.get('raw'),
reply: post.get('raw'),
originalText: post.get('raw'),
loading: false
});
});
@ -467,7 +462,7 @@ const Composer = Discourse.Model.extend({
createPost(opts) {
const post = this.get('post'),
topic = this.get('topic'),
currentUser = Discourse.User.current(),
user = this.user,
postStream = this.get('topic.postStream');
let addedToStream = false;
@ -477,17 +472,17 @@ const Composer = Discourse.Model.extend({
imageSizes: opts.imageSizes,
cooked: this.getCookedHtml(),
reply_count: 0,
name: currentUser.get('name'),
display_username: currentUser.get('name'),
username: currentUser.get('username'),
user_id: currentUser.get('id'),
user_title: currentUser.get('title'),
uploaded_avatar_id: currentUser.get('uploaded_avatar_id'),
user_custom_fields: currentUser.get('custom_fields'),
post_type: Discourse.Site.currentProp('post_types.regular'),
name: user.get('name'),
display_username: user.get('name'),
username: user.get('username'),
user_id: user.get('id'),
user_title: user.get('title'),
uploaded_avatar_id: user.get('uploaded_avatar_id'),
user_custom_fields: user.get('custom_fields'),
post_type: this.site.get('post_types.regular'),
actions_summary: [],
moderator: currentUser.get('moderator'),
admin: currentUser.get('admin'),
moderator: user.get('moderator'),
admin: user.get('admin'),
yours: true,
newPost: 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
// engine, staging will just cause a blank post to render
if (!_.isEmpty(createdPost.get('cooked'))) {
state = postStream.stagePost(createdPost, currentUser);
state = postStream.stagePost(createdPost, user);
if(state === "alreadyStaging"){
return;
@ -529,69 +524,64 @@ const Composer = Discourse.Model.extend({
}
}
const composer = this,
promise = new Ember.RSVP.Promise(function(resolve, reject) {
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);
});
});
const composer = this;
composer.set('composeState', SAVING);
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() {
@ -604,7 +594,7 @@ const Composer = Discourse.Model.extend({
// Do not save when there is no reply
if (!this.get('reply')) return;
// 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 = {
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) {
if (!property) { property = fieldName; }
_edit_topic_serializer[fieldName] = property;

View File

@ -1,20 +1,12 @@
/**
A data model representing a post in a topic
const Post = Discourse.Model.extend({
@class Post
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.Post = Discourse.Model.extend({
init: function() {
init() {
this.set('replyHistory', []);
},
shareUrl: function() {
var user = Discourse.User.current();
var userSuffix = user ? '?u=' + user.get('username_lower') : '';
const user = Discourse.User.current();
const userSuffix = user ? '?u=' + user.get('username_lower') : '';
if (this.get('firstPost')) {
return this.get('topic.url') + userSuffix;
@ -33,7 +25,7 @@ Discourse.Post = Discourse.Model.extend({
userDeleted: Em.computed.empty('user_id'),
showName: function() {
var name = this.get('name');
const name = this.get('name');
return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts;
}.property('name', 'username'),
@ -69,17 +61,17 @@ Discourse.Post = Discourse.Model.extend({
}.property("user_id"),
wikiChanged: function() {
var data = { wiki: this.get("wiki") };
const data = { wiki: this.get("wiki") };
this._updatePost("wiki", data);
}.observes('wiki'),
postTypeChanged: function () {
var data = { post_type: this.get("post_type") };
const data = { post_type: this.get("post_type") };
this._updatePost("post_type", data);
}.observes("post_type"),
_updatePost: function (field, data) {
var self = this;
_updatePost(field, data) {
const self = this;
Discourse.ajax("/posts/" + this.get("id") + "/" + field, {
type: "PUT",
data: data
@ -103,7 +95,7 @@ Discourse.Post = Discourse.Model.extend({
editCount: function() { return this.get('version') - 1; }.property('version'),
flagsAvailable: function() {
var post = this;
const post = this;
return Discourse.Site.currentProp('flagTypes').filter(function(item) {
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'),
// Save a post and call the callback when done.
save: function(complete, error) {
var self = this;
save() {
const self = this;
if (!this.get('newPost')) {
// We're updating a post
return Discourse.ajax("/posts/" + (this.get('id')), {
@ -135,19 +126,17 @@ Discourse.Post = Discourse.Model.extend({
// If we received a category update, update it
self.set('version', result.post.version);
if (result.category) Discourse.Site.current().updateCategory(result.category);
if (complete) complete(Discourse.Post.create(result.post));
}).catch(function(result) {
// Post failed to update
if (error) error(result);
return Discourse.Post.create(result.post);
});
} else {
// 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.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
if (metaData) {
data.meta_data = {};
@ -158,34 +147,22 @@ Discourse.Post = Discourse.Model.extend({
type: 'POST',
data: data
}).then(function(result) {
// Post created
if (complete) complete(Discourse.Post.create(result));
}).catch(function(result) {
// Failed to create a post
if (error) error(result);
return Discourse.Post.create(result.post);
});
}
},
/**
Expands the first post's content, if embedded and shortened.
@method expandFirstPost
**/
expand: function() {
var self = this;
// Expands the first post's content, if embedded and shortened.
expand() {
const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) {
self.set('cooked', "<section class='expanded-embed'>" + post.cooked + "</section>" );
});
},
/**
Recover a deleted post
@method recover
**/
recover: function() {
var post = this;
// Recover a deleted post
recover() {
const post = this;
post.setProperties({
deleted_at: 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
done elsewhere.
@method setDeletedState
@param {Discourse.User} deletedBy The user deleting the post
**/
setDeletedState: function(deletedBy) {
setDeletedState(deletedBy) {
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.
@ -237,10 +211,8 @@ Discourse.Post = Discourse.Model.extend({
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
failed on the server.
@method undoDeletedState
**/
undoDeleteState: function() {
undoDeleteState() {
if (this.get('oldCooked')) {
this.setProperties({
deleted_at: null,
@ -253,13 +225,7 @@ Discourse.Post = Discourse.Model.extend({
}
},
/**
Deletes a post
@method destroy
@param {Discourse.User} deletedBy The user deleting the post
**/
destroy: function(deletedBy) {
destroy(deletedBy) {
this.setDeletedState(deletedBy);
return Discourse.ajax("/posts/" + this.get('id'), {
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
is already found in an identity map.
@method updateFromPost
@param {Discourse.Post} otherPost The post we're updating from
**/
updateFromPost: function(otherPost) {
var self = this;
updateFromPost(otherPost) {
const self = this;
Object.keys(otherPost).forEach(function (key) {
var value = otherPost[key],
let value = otherPost[key],
oldValue = self[key];
if (key === "replyHistory") {
@ -287,7 +250,7 @@ Discourse.Post = Discourse.Model.extend({
if (!value) { value = null; }
if (!oldValue) { oldValue = null; }
var skip = false;
let skip = false;
if (typeof value !== "function" && oldValue !== value) {
// wishing for an identity map
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
attributes.
@method updateFromJson
@param {Object} obj The Json data to update with
**/
updateFromJson: function(obj) {
updateFromJson(obj) {
if (!obj) return;
var skip, oldVal;
let skip, oldVal;
// Update all the properties
var post = this;
const post = this;
_.each(obj, function(val,key) {
if (key !== 'actions_summary'){
oldVal = post[key];
@ -336,12 +296,11 @@ Discourse.Post = Discourse.Model.extend({
// Rebuild actions summary
this.set('actions_summary', Em.A());
if (obj.actions_summary) {
var lookup = Em.Object.create();
const lookup = Em.Object.create();
_.each(obj.actions_summary,function(a) {
var actionSummary;
a.post = post;
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);
lookup.set(a.actionType.get('name_key'), actionSummary);
});
@ -350,7 +309,7 @@ Discourse.Post = Discourse.Model.extend({
},
// Load replies to this post
loadReplies: function() {
loadReplies() {
if(this.get('loadingReplies')){
return;
}
@ -358,12 +317,12 @@ Discourse.Post = Discourse.Model.extend({
this.set('loadingReplies', true);
this.set('replies', []);
var self = this;
const self = this;
return Discourse.ajax("/posts/" + (this.get('id')) + "/replies")
.then(function(loaded) {
var replies = self.get('replies');
const replies = self.get('replies');
_.each(loaded,function(reply) {
var post = Discourse.Post.create(reply);
const post = Discourse.Post.create(reply);
post.set('topic', self.get('topic'));
replies.pushObject(post);
});
@ -375,7 +334,7 @@ Discourse.Post = Discourse.Model.extend({
// Whether to show replies directly below
showRepliesBelow: function() {
var replyCount = this.get('reply_count');
const replyCount = this.get('reply_count');
// We don't show replies if there aren't any
if (replyCount === 0) return false;
@ -387,13 +346,13 @@ Discourse.Post = Discourse.Model.extend({
if (replyCount > 1) return true;
// 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);
}.property('reply_count'),
expandHidden: function() {
var self = this;
expandHidden() {
const self = this;
return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) {
self.setProperties({
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" });
},
unhide: function () {
unhide() {
return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" });
},
toggleBookmark: function() {
var self = this,
bookmarkedTopic;
toggleBookmark() {
const self = this;
let bookmarkedTopic;
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) {
var lookup = Em.Object.create();
const lookup = Em.Object.create();
// this area should be optimized, it is creating way too many objects per post
result.actions_summary = result.actions_summary.map(function(a) {
a.post = result;
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;
return actionSummary;
});
@ -452,8 +411,8 @@ Discourse.Post.reopenClass({
}
},
create: function(obj) {
var result = this._super.apply(this, arguments);
create(obj) {
const result = this._super.apply(this, arguments);
this.createActionSummary(result);
if (obj && 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;
},
updateBookmark: function(postId, bookmarked) {
updateBookmark(postId, bookmarked) {
return Discourse.ajax("/posts/" + postId + "/bookmark", {
type: 'PUT',
data: { bookmarked: bookmarked }
});
},
deleteMany: function(selectedPosts, selectedReplies) {
deleteMany(selectedPosts, selectedReplies) {
return Discourse.ajax("/posts/destroy_many", {
type: 'DELETE',
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 Ember.Object.create(result);
});
},
hideRevision: function(postId, version) {
hideRevision(postId, version) {
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' });
},
loadQuote: function(postId) {
loadQuote(postId) {
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'));
});
},
loadRawEmail: function(postId) {
loadRawEmail(postId) {
return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) {
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_tree ./discourse/adapters
//= require ./discourse/models/model
//= require ./discourse/models/post
//= require ./discourse/models/user_action
//= require ./discourse/models/composer
//= 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.
# If a param is present it uses that result structure.
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
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";
acceptance("Header (Staff)", {
user: { username: 'test',
staff: true,
site_flagged_posts_count: 1 }
});
acceptance("Header (Staff)", { loggedIn: true });
test("header", () => {
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 = {};
query.split("&").forEach(function(part) {
const item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]);
result[item[0]] = decodeURIComponent(item[1]).replace(/\+/g, ' ');
});
return result;
}
@ -33,9 +33,16 @@ const _moreWidgets = [
{id: 224, name: 'Good Repellant'}
];
function loggedIn() {
return !!Discourse.User.current();
}
export default function() {
const server = new Pretender(function() {
const fixturesByUrl = {};
// Load any fixtures automatically
const self = this;
Ember.keys(require._eak_seen).forEach(function(entry) {
@ -44,6 +51,7 @@ export default function() {
if (fixture && fixture.default) {
const obj = fixture.default;
Ember.keys(obj).forEach(function(url) {
fixturesByUrl[url] = obj[url];
self.get(url, function() {
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() {
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.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) {
const w = _widgets.findBy('id', parseInt(request.params.widget_id));
if (w) {
@ -130,8 +179,11 @@ export default function() {
});
this.delete('/widgets/:widget_id', success);
});
this.post('/topics/timings', function() {
return response(200, {});
});
});
server.prepareBody = function(body){
if (body && typeof body === "object") {

View File

@ -1,20 +1,59 @@
/* global asyncTest */
import sessionFixtures from 'fixtures/session-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) {
module("Acceptance: " + name, {
setup: function() {
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;
if (options) {
if (options.setup) {
options.setup.call(this);
}
if (options.user) {
Discourse.User.resetCurrent(Discourse.User.create(options.user));
if (options.loggedIn) {
logIn();
}
if (options.settings) {
@ -34,6 +73,7 @@ function acceptance(name, options) {
options.teardown.call(this);
}
Discourse.Utilities.avatarImg = oldAvatar;
Discourse.reset();
}
});
@ -61,4 +101,4 @@ function fixture(selector) {
return $("#qunit-fixture");
}
export { acceptance, controllerFor, asyncTestDiscourse, fixture };
export { acceptance, controllerFor, asyncTestDiscourse, fixture, logIn, currentUser };

View File

@ -1,16 +1,18 @@
module("Discourse.Composer", {
setup: function() {
sandbox.stub(Discourse.User, 'currentProp').withArgs('admin').returns(false);
},
import { currentUser } from 'helpers/qunit-helpers';
teardown: function() {
Discourse.User.currentProp.restore();
}
});
module("model:composer");
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() {
var replyLength = function(val, expectedLength) {
var composer = Discourse.Composer.create({ reply: val });
const replyLength = function(val, expectedLength) {
const composer = createComposer({ reply: val });
equal(composer.get('replyLength'), expectedLength);
};
@ -23,8 +25,8 @@ test('replyLength', function() {
test('missingReplyCharacters', function() {
Discourse.SiteSettings.min_first_post_length = 40;
var missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) {
var composer = Discourse.Composer.create({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost });
const missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) {
const composer = createComposer({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost });
equal(composer.get('missingReplyCharacters'), expected, message);
};
@ -34,8 +36,8 @@ test('missingReplyCharacters', function() {
});
test('missingTitleCharacters', function() {
var missingTitleCharacters = function(val, isPM, expected, message) {
var composer = Discourse.Composer.create({ title: val, creatingPrivateMessage: isPM });
const missingTitleCharacters = function(val, isPM, expected, message) {
const composer = createComposer({ title: val, creatingPrivateMessage: isPM });
equal(composer.get('missingTitleCharacters'), expected, message);
};
@ -44,7 +46,7 @@ test('missingTitleCharacters', function() {
});
test('replyDirty', function() {
var composer = Discourse.Composer.create();
const composer = createComposer();
ok(!composer.get('replyDirty'), "by default it's false");
composer.setProperties({
@ -58,7 +60,7 @@ test('replyDirty', function() {
});
test("appendText", function() {
var composer = Discourse.Composer.create();
const composer = createComposer();
blank(composer.get('reply'), "the reply is blank by default");
@ -89,7 +91,7 @@ test("appendText", function() {
test("Title length for regular topics", function() {
Discourse.SiteSettings.min_topic_title_length = 5;
Discourse.SiteSettings.max_topic_title_length = 10;
var composer = Discourse.Composer.create();
const composer = createComposer();
composer.set('title', 'asdf');
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() {
Discourse.SiteSettings.min_private_message_title_length = 5;
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');
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() {
Discourse.SiteSettings.min_private_message_title_length = 5;
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');
ok(!composer.get('titleLengthValid'), "short titles are not valid");
@ -132,10 +134,10 @@ test("Title length for private messages", function() {
});
test('editingFirstPost', function() {
var composer = Discourse.Composer.create();
const composer = createComposer();
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 });
ok(!composer.get('editingFirstPost'), "it's false when not editing the first post");
@ -145,7 +147,7 @@ test('editingFirstPost', function() {
});
test('clearState', function() {
var composer = Discourse.Composer.create({
const composer = createComposer({
originalText: 'asdf',
reply: 'asdf2',
post: Discourse.Post.create({id: 1}),
@ -163,61 +165,48 @@ test('clearState', function() {
test('initial category when uncategorized is allowed', function() {
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");
});
test('initial category when uncategorized is not allowed', function() {
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.");
});
test('showPreview', function() {
var new_composer = function() {
const newComposer = function() {
return Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1});
};
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' });
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.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() {
var quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]';
var new_composer = function() {
const quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]';
const newComposer = function() {
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(new_composer().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();
}
equal(newComposer().get('originalText'), quote, "originalText is the quote" );
equal(newComposer().get('replyDirty'), false, "replyDirty is initally false with a quote" );
});
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.set('title', 'asdf');

View File

@ -59,7 +59,11 @@ sinon.config = {
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
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
div.on('shown', function() {
div.on('shown.bs.modal', function() {
div.find("a.btn-primary:first").focus();
});
div.on('hidden', function() {
div.on('hidden.bs.modal', function() {
div.remove();
});
// wire up button handlers
div.on('click', '.modal-footer a', function(e) {
Ember.run(function() {
var handler = $(this).data("handler"),
cb = callbacks[handler],
@ -462,10 +463,11 @@ var bootbox = window.bootbox || (function(document, $) {
if (hideModal !== false) {
div.modal("hide");
}
});
});
// 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({
// unless explicitly overridden take whatever our default backdrop value is

View File

@ -1,218 +1,338 @@
/* =========================================================
* bootstrap-modal.js v2.0.3
* http://twitter.github.com/bootstrap/javascript.html#modals
* =========================================================
* Copyright 2012 Twitter, Inc.
*
* 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.
* ========================================================= */
/* ========================================================================
* Bootstrap: modal.js v3.3.4
* http://getbootstrap.com/javascript/#modals
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/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
* ====================== */
var Modal = function (content, options) {
this.options = options
this.$element = $(content)
.delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this))
if (this.options.remote) {
this.$element
.find('.modal-content')
.load(this.options.remote, $.proxy(function () {
this.$element.trigger('loaded.bs.modal')
}, this))
}
}
Modal.prototype = {
Modal.VERSION = '3.3.4'
constructor: Modal
, 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.TRANSITION_DURATION = 300
Modal.BACKDROP_TRANSITION_DURATION = 150
Modal.DEFAULTS = {
backdrop: true,
keyboard: true,
show: true
}
Modal.prototype.toggle = function (_relatedTarget) {
return this.isShown ? this.hide() : this.show(_relatedTarget)
}
/* MODAL PRIVATE METHODS
* ===================== */
function hideWithTransition() {
Modal.prototype.show = function (_relatedTarget) {
var that = this
, timeout = setTimeout(function () {
that.$element.off($.support.transition.end)
hideModal.call(that)
}, 500)
var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget })
this.$element.one($.support.transition.end, function () {
clearTimeout(timeout)
hideModal.call(that)
this.$element.trigger(e)
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) {
this.$element
.hide()
.trigger('hidden')
Modal.prototype.hide = function (e) {
if (e) e.preventDefault()
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
, 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) {
var doAnimate = $.support.transition && animate
this.$backdrop = $('<div class="modal-backdrop ' + animate + '" />')
.appendTo(document.body)
this.$backdrop = $(document.createElement('div'))
.addClass('modal-backdrop ' + animate)
.appendTo(this.$body)
if (this.options.backdrop != 'static') {
this.$backdrop.click($.proxy(this.hide, this))
}
this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) {
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
this.$backdrop.addClass('in')
if (!callback) return
doAnimate ?
this.$backdrop.one($.support.transition.end, callback) :
this.$backdrop
.one('bsTransitionEnd', callback)
.emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
callback()
} else if (!this.isShown && this.$backdrop) {
this.$backdrop.removeClass('in')
$.support.transition && this.$element.hasClass('fade')?
this.$backdrop.one($.support.transition.end, $.proxy(removeBackdrop, this)) :
removeBackdrop.call(this)
var callbackRemove = function () {
that.removeBackdrop()
callback && callback()
}
$.support.transition && this.$element.hasClass('fade') ?
this.$backdrop
.one('bsTransitionEnd', callbackRemove)
.emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
callbackRemove()
} else if (callback) {
callback()
}
}
function removeBackdrop() {
this.$backdrop.remove()
this.$backdrop = null
// these following methods are used to handle overflowing modals
Modal.prototype.handleUpdate = function () {
this.adjustDialog()
}
function escape() {
var that = this
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')
}
}
Modal.prototype.adjustDialog = function () {
var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight
/* MODAL PLUGIN DEFINITION
* ======================= */
$.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()
this.$element.css({
paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '',
paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : ''
})
}
$.fn.modal.defaults = {
backdrop: true
, keyboard: true
, show: true
Modal.prototype.resetAdjustments = function () {
this.$element.css({
paddingLeft: '',
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
/* MODAL DATA-API
* ============== */
// MODAL NO CONFLICT
// =================
$(function () {
$('body').on('click.modal.data-api', '[data-toggle="modal"]', function ( e ) {
var $this = $(this), href
, $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())
$.fn.modal.noConflict = function () {
$.fn.modal = old
return this
}
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
while (bottomView < childViews.length) {
var view = childViews[bottomView],
$view = view.$(),
// in case of not full-window scrolling
scrollOffset = this.get('wrapperTop') || 0,
viewTop = $view.offset().top + scrollOffset,
viewBottom = viewTop + $view.height();
$view = view.$();
if (!$view) { break; }
// 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; }
toUncloak.push(view);