FEATURE: Drafts view in user profile
* add drafts.json endpoint, user profile tab with drafts stream * improve drafts stream display in user profile * truncate excerpts in drafts list, better handling for resume draft action * improve draft stream SQL query, add rspec tests * if composer is open, quietly close it when user opens another draft from drafts stream; load PM draft only when user is in /u/username/messages (instead of /u/username) * cleanup * linting fixes * apply prettier styling to modified files * add client tests for drafts, includes a fixture for drafts.json * improvements to code following review * refresh drafts route when user deletes a draft open in the composer while being in the drafts route; minor prettier scss fix * added more spec tests, deleted an acceptance test for removing drafts that was too finicky, formatting and code style fixes, added appEvent for draft:destroyed * prettier, eslint fixes * use "username_lower" from users table, added error handling for rejected promises * adds guardian spec for can_see_drafts, adds improvements following code review * move DraftsController spec to its own file * fix failing drafts qunit test, use getOwner instead of deprecated this.container * limit test fixture for draft.json testing to new_topic request only
This commit is contained in:
parent
70ea153dce
commit
1f45215537
|
@ -2,6 +2,10 @@ import LoadMore from "discourse/mixins/load-more";
|
||||||
import ClickTrack from "discourse/lib/click-track";
|
import ClickTrack from "discourse/lib/click-track";
|
||||||
import { selectedText } from "discourse/lib/utilities";
|
import { selectedText } from "discourse/lib/utilities";
|
||||||
import Post from "discourse/models/post";
|
import Post from "discourse/models/post";
|
||||||
|
import DiscourseURL from "discourse/lib/url";
|
||||||
|
import Draft from "discourse/models/draft";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import { getOwner } from "discourse-common/lib/get-owner";
|
||||||
|
|
||||||
export default Ember.Component.extend(LoadMore, {
|
export default Ember.Component.extend(LoadMore, {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
@ -57,6 +61,41 @@ export default Ember.Component.extend(LoadMore, {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resumeDraft(item) {
|
||||||
|
const composer = getOwner(this).lookup("controller:composer");
|
||||||
|
if (composer.get("model.viewOpen")) {
|
||||||
|
composer.close();
|
||||||
|
}
|
||||||
|
if (item.get("postUrl")) {
|
||||||
|
DiscourseURL.routeTo(item.get("postUrl"));
|
||||||
|
} else {
|
||||||
|
Draft.get(item.draft_key)
|
||||||
|
.then(d => {
|
||||||
|
if (d.draft) {
|
||||||
|
composer.open({
|
||||||
|
draft: d.draft,
|
||||||
|
draftKey: item.draft_key,
|
||||||
|
draftSequence: d.draft_sequence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
popupAjaxError(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeDraft(draft) {
|
||||||
|
const stream = this.get("stream");
|
||||||
|
Draft.clear(draft.draft_key, draft.sequence)
|
||||||
|
.then(() => {
|
||||||
|
stream.load(this.site);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
popupAjaxError(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
loadMore() {
|
loadMore() {
|
||||||
if (this.get("loading")) {
|
if (this.get("loading")) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -849,7 +849,10 @@ export default Ember.Controller.extend({
|
||||||
if (key === "new_topic") {
|
if (key === "new_topic") {
|
||||||
this.send("clearTopicDraft");
|
this.send("clearTopicDraft");
|
||||||
}
|
}
|
||||||
Draft.clear(key, this.get("model.draftSequence"));
|
|
||||||
|
Draft.clear(key, this.get("model.draftSequence")).then(() => {
|
||||||
|
this.appEvents.trigger("draft:destroyed", key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,11 @@ export default Ember.Controller.extend(CanCheckEmails, {
|
||||||
return viewingSelf || isAdmin;
|
return viewingSelf || isAdmin;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed("viewingSelf", "currentUser.admin")
|
||||||
|
showDrafts(viewingSelf, isAdmin) {
|
||||||
|
return viewingSelf || isAdmin;
|
||||||
|
},
|
||||||
|
|
||||||
@computed("viewingSelf", "currentUser.admin")
|
@computed("viewingSelf", "currentUser.admin")
|
||||||
showPrivateMessages(viewingSelf, isAdmin) {
|
showPrivateMessages(viewingSelf, isAdmin) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const CREATE_TOPIC = "createTopic",
|
||||||
EDIT_SHARED_DRAFT = "editSharedDraft",
|
EDIT_SHARED_DRAFT = "editSharedDraft",
|
||||||
PRIVATE_MESSAGE = "privateMessage",
|
PRIVATE_MESSAGE = "privateMessage",
|
||||||
NEW_PRIVATE_MESSAGE_KEY = "new_private_message",
|
NEW_PRIVATE_MESSAGE_KEY = "new_private_message",
|
||||||
|
NEW_TOPIC_KEY = "new_topic",
|
||||||
REPLY = "reply",
|
REPLY = "reply",
|
||||||
EDIT = "edit",
|
EDIT = "edit",
|
||||||
REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic",
|
REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic",
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import RestModel from "discourse/models/rest";
|
||||||
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import { postUrl } from "discourse/lib/utilities";
|
||||||
|
import { userPath } from "discourse/lib/url";
|
||||||
|
import User from "discourse/models/user";
|
||||||
|
|
||||||
|
import {
|
||||||
|
NEW_TOPIC_KEY,
|
||||||
|
NEW_PRIVATE_MESSAGE_KEY
|
||||||
|
} from "discourse/models/composer";
|
||||||
|
|
||||||
|
export default RestModel.extend({
|
||||||
|
@computed("draft_username")
|
||||||
|
editableDraft(draftUsername) {
|
||||||
|
return draftUsername === User.currentProp("username");
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("username_lower")
|
||||||
|
userUrl(usernameLower) {
|
||||||
|
return userPath(usernameLower);
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("topic_id")
|
||||||
|
postUrl(topicId) {
|
||||||
|
if (!topicId) return;
|
||||||
|
|
||||||
|
return postUrl(
|
||||||
|
this.get("slug"),
|
||||||
|
this.get("topic_id"),
|
||||||
|
this.get("post_number")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("draft_key", "post_number")
|
||||||
|
draftType(draftKey, postNumber) {
|
||||||
|
switch (draftKey) {
|
||||||
|
case NEW_TOPIC_KEY:
|
||||||
|
return I18n.t("drafts.new_topic");
|
||||||
|
case NEW_PRIVATE_MESSAGE_KEY:
|
||||||
|
return I18n.t("drafts.new_private_message");
|
||||||
|
default:
|
||||||
|
return postNumber
|
||||||
|
? I18n.t("drafts.post_reply", { postNumber })
|
||||||
|
: I18n.t("drafts.topic_reply");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { url } from "discourse/lib/computed";
|
||||||
|
import RestModel from "discourse/models/rest";
|
||||||
|
import UserDraft from "discourse/models/user-draft";
|
||||||
|
import { emojiUnescape } from "discourse/lib/text";
|
||||||
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
|
||||||
|
import {
|
||||||
|
NEW_TOPIC_KEY,
|
||||||
|
NEW_PRIVATE_MESSAGE_KEY
|
||||||
|
} from "discourse/models/composer";
|
||||||
|
|
||||||
|
export default RestModel.extend({
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super();
|
||||||
|
this.setProperties({
|
||||||
|
itemsLoaded: 0,
|
||||||
|
content: [],
|
||||||
|
lastLoadedUrl: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
baseUrl: url(
|
||||||
|
"itemsLoaded",
|
||||||
|
"user.username_lower",
|
||||||
|
"/drafts.json?offset=%@&username=%@"
|
||||||
|
),
|
||||||
|
|
||||||
|
load(site) {
|
||||||
|
this.setProperties({
|
||||||
|
itemsLoaded: 0,
|
||||||
|
content: [],
|
||||||
|
lastLoadedUrl: null,
|
||||||
|
site: site
|
||||||
|
});
|
||||||
|
return this.findItems();
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed("content.length", "loaded")
|
||||||
|
noContent(contentLength, loaded) {
|
||||||
|
return loaded && contentLength === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(draft) {
|
||||||
|
let content = this.get("content").filter(
|
||||||
|
item => item.sequence !== draft.sequence
|
||||||
|
);
|
||||||
|
this.setProperties({ content, itemsLoaded: content.length });
|
||||||
|
},
|
||||||
|
|
||||||
|
findItems() {
|
||||||
|
let findUrl = this.get("baseUrl");
|
||||||
|
|
||||||
|
const lastLoadedUrl = this.get("lastLoadedUrl");
|
||||||
|
if (lastLoadedUrl === findUrl) {
|
||||||
|
return Ember.RSVP.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.get("loading")) {
|
||||||
|
return Ember.RSVP.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("loading", true);
|
||||||
|
|
||||||
|
return ajax(findUrl, { cache: "false" })
|
||||||
|
.then(result => {
|
||||||
|
if (result && result.no_results_help) {
|
||||||
|
this.set("noContentHelp", result.no_results_help);
|
||||||
|
}
|
||||||
|
if (result && result.drafts) {
|
||||||
|
const copy = Em.A();
|
||||||
|
result.drafts.forEach(draft => {
|
||||||
|
let draftData = JSON.parse(draft.data);
|
||||||
|
draft.post_number = draftData.postId || null;
|
||||||
|
if (
|
||||||
|
draft.draft_key === NEW_PRIVATE_MESSAGE_KEY ||
|
||||||
|
draft.draft_key === NEW_TOPIC_KEY
|
||||||
|
) {
|
||||||
|
draft.title = draftData.title;
|
||||||
|
}
|
||||||
|
draft.title = emojiUnescape(
|
||||||
|
Handlebars.Utils.escapeExpression(draft.title)
|
||||||
|
);
|
||||||
|
if (draft.category_id) {
|
||||||
|
draft.category =
|
||||||
|
this.site.categories.findBy("id", draft.category_id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.pushObject(UserDraft.create(draft));
|
||||||
|
});
|
||||||
|
this.get("content").pushObjects(copy);
|
||||||
|
this.setProperties({
|
||||||
|
loaded: true,
|
||||||
|
itemsLoaded: this.get("itemsLoaded") + result.drafts.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.set("loading", false);
|
||||||
|
this.set("lastLoadedUrl", findUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -13,6 +13,7 @@ import Badge from "discourse/models/badge";
|
||||||
import UserBadge from "discourse/models/user-badge";
|
import UserBadge from "discourse/models/user-badge";
|
||||||
import UserActionStat from "discourse/models/user-action-stat";
|
import UserActionStat from "discourse/models/user-action-stat";
|
||||||
import UserAction from "discourse/models/user-action";
|
import UserAction from "discourse/models/user-action";
|
||||||
|
import UserDraftsStream from "discourse/models/user-drafts-stream";
|
||||||
import Group from "discourse/models/group";
|
import Group from "discourse/models/group";
|
||||||
import { emojiUnescape } from "discourse/lib/text";
|
import { emojiUnescape } from "discourse/lib/text";
|
||||||
import PreloadStore from "preload-store";
|
import PreloadStore from "preload-store";
|
||||||
|
@ -47,6 +48,11 @@ const User = RestModel.extend({
|
||||||
return UserPostsStream.create({ user: this });
|
return UserPostsStream.create({ user: this });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed()
|
||||||
|
userDraftsStream() {
|
||||||
|
return UserDraftsStream.create({ user: this });
|
||||||
|
},
|
||||||
|
|
||||||
staff: Em.computed.or("admin", "moderator"),
|
staff: Em.computed.or("admin", "moderator"),
|
||||||
|
|
||||||
destroySession() {
|
destroySession() {
|
||||||
|
|
|
@ -112,6 +112,7 @@ export default function() {
|
||||||
this.route("likesGiven", { path: "likes-given" });
|
this.route("likesGiven", { path: "likes-given" });
|
||||||
this.route("bookmarks");
|
this.route("bookmarks");
|
||||||
this.route("pending");
|
this.route("pending");
|
||||||
|
this.route("drafts");
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
export default Discourse.Route.extend({
|
||||||
|
model() {
|
||||||
|
let userDraftsStream = this.modelFor("user").get("userDraftsStream");
|
||||||
|
return userDraftsStream.load(this.site).then(() => userDraftsStream);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTemplate() {
|
||||||
|
this.render("user_stream");
|
||||||
|
},
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
controller.set("model", model);
|
||||||
|
this.appEvents.on("draft:destroyed", this, this.refresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
didTransition() {
|
||||||
|
this.controllerFor("user-activity")._showFooter();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,3 +1,5 @@
|
||||||
|
import Draft from "discourse/models/draft";
|
||||||
|
|
||||||
export default Discourse.Route.extend({
|
export default Discourse.Route.extend({
|
||||||
renderTemplate() {
|
renderTemplate() {
|
||||||
this.render("user/messages");
|
this.render("user/messages");
|
||||||
|
@ -7,6 +9,23 @@ export default Discourse.Route.extend({
|
||||||
return this.modelFor("user");
|
return this.modelFor("user");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setupController(controller, user) {
|
||||||
|
const composerController = this.controllerFor("composer");
|
||||||
|
controller.set("model", user);
|
||||||
|
if (this.currentUser) {
|
||||||
|
Draft.get("new_private_message").then(data => {
|
||||||
|
if (data.draft) {
|
||||||
|
composerController.open({
|
||||||
|
draft: data.draft,
|
||||||
|
draftKey: "new_private_message",
|
||||||
|
ignoreIfChanged: true,
|
||||||
|
draftSequence: data.draft_sequence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
willTransition: function() {
|
willTransition: function() {
|
||||||
this._super();
|
this._super();
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import Draft from "discourse/models/draft";
|
|
||||||
|
|
||||||
export default Discourse.Route.extend({
|
export default Discourse.Route.extend({
|
||||||
titleToken() {
|
titleToken() {
|
||||||
const username = this.modelFor("user").get("username");
|
const username = this.modelFor("user").get("username");
|
||||||
|
@ -67,21 +65,6 @@ export default Discourse.Route.extend({
|
||||||
setupController(controller, user) {
|
setupController(controller, user) {
|
||||||
controller.set("model", user);
|
controller.set("model", user);
|
||||||
this.searchService.set("searchContext", user.get("searchContext"));
|
this.searchService.set("searchContext", user.get("searchContext"));
|
||||||
|
|
||||||
const composerController = this.controllerFor("composer");
|
|
||||||
controller.set("model", user);
|
|
||||||
if (this.currentUser) {
|
|
||||||
Draft.get("new_private_message").then(function(data) {
|
|
||||||
if (data.draft) {
|
|
||||||
composerController.open({
|
|
||||||
draft: data.draft,
|
|
||||||
draftKey: "new_private_message",
|
|
||||||
ignoreIfChanged: true,
|
|
||||||
draftSequence: data.draft_sequence
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
activate() {
|
activate() {
|
||||||
|
|
|
@ -1,12 +1,20 @@
|
||||||
<div class='clearfix info'>
|
<div class='clearfix info'>
|
||||||
<a href={{item.userUrl}} data-user-card={{item.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar item imageSize="large" extraClasses="actor" ignoreTitle="true"}}</div></a>
|
<a href={{item.userUrl}} data-user-card={{item.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar item imageSize="large" extraClasses="actor" ignoreTitle="true"}}</div></a>
|
||||||
<span class='time'>{{format-date item.created_at}}</span>
|
<span class='time'>{{format-date item.created_at}}</span>
|
||||||
{{expand-post item=item}}
|
{{#if item.draftType}}
|
||||||
|
<span class='draft-type'>{{{item.draftType}}}</span>
|
||||||
|
{{else}}
|
||||||
|
{{expand-post item=item}}
|
||||||
|
{{/if}}
|
||||||
<div class='stream-topic-details'>
|
<div class='stream-topic-details'>
|
||||||
<div class='stream-topic-title'>
|
<div class='stream-topic-title'>
|
||||||
{{topic-status topic=item disableActions=true}}
|
{{topic-status topic=item disableActions=true}}
|
||||||
<span class="title">
|
<span class="title">
|
||||||
<a href={{item.postUrl}}>{{{item.title}}}</a>
|
{{#if item.postUrl}}
|
||||||
|
<a href={{item.postUrl}}>{{{item.title}}}</a>
|
||||||
|
{{else}}
|
||||||
|
{{{item.title}}}
|
||||||
|
{{/if}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="category">{{category-link item.category}}</div>
|
<div class="category">{{category-link item.category}}</div>
|
||||||
|
@ -50,3 +58,10 @@
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
|
{{#if item.editableDraft}}
|
||||||
|
<div class='user-stream-item-draft-actions'>
|
||||||
|
{{d-button action=resumeDraft actionParam=item icon="pencil" label='drafts.resume' class="resume-draft"}}
|
||||||
|
{{d-button action=removeDraft actionParam=item icon="times" label='drafts.remove' class="remove-draft"}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
{{#each stream.content as |item|}}
|
{{#each stream.content as |item|}}
|
||||||
{{user-stream-item item=item removeBookmark=(action "removeBookmark")}}
|
{{user-stream-item
|
||||||
|
item=item
|
||||||
|
removeBookmark=(action "removeBookmark")
|
||||||
|
resumeDraft=(action "resumeDraft")
|
||||||
|
removeDraft=(action "removeDraft")
|
||||||
|
}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
|
@ -9,6 +9,11 @@
|
||||||
<li>
|
<li>
|
||||||
{{#link-to 'userActivity.replies'}}{{i18n 'user_action_groups.5'}}{{/link-to}}
|
{{#link-to 'userActivity.replies'}}{{i18n 'user_action_groups.5'}}{{/link-to}}
|
||||||
</li>
|
</li>
|
||||||
|
{{#if user.showDrafts}}
|
||||||
|
<li>
|
||||||
|
{{#link-to 'userActivity.drafts'}}{{i18n 'user_action_groups.15'}}{{/link-to}}
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
<li>
|
<li>
|
||||||
{{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
|
{{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -36,13 +36,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.time,
|
.time,
|
||||||
.delete-info {
|
.delete-info,
|
||||||
|
.draft-type {
|
||||||
display: block;
|
display: block;
|
||||||
float: right;
|
float: right;
|
||||||
color: lighten($primary, 40%);
|
color: lighten($primary, 40%);
|
||||||
font-size: $font-down-2;
|
font-size: $font-down-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draft-type {
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-info i {
|
.delete-info i {
|
||||||
font-size: $font-0;
|
font-size: $font-0;
|
||||||
}
|
}
|
||||||
|
@ -83,7 +88,8 @@
|
||||||
padding: 3px 5px 5px 5px;
|
padding: 3px 5px 5px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-bookmark {
|
.remove-bookmark,
|
||||||
|
.remove-draft {
|
||||||
float: right;
|
float: right;
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
}
|
}
|
||||||
|
@ -95,6 +101,7 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: $primary;
|
color: $primary;
|
||||||
}
|
}
|
||||||
|
@ -131,7 +138,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-stream .child-actions, /* DEPRECATED: '.user-stream .child-actions' selector*/
|
.user-stream .child-actions, /* DEPRECATED: '.user-stream .child-actions' selector*/
|
||||||
.user-stream-item-actions {
|
.user-stream-item-actions,
|
||||||
|
.user-stream-item-draft-actions {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
||||||
.avatar-link {
|
.avatar-link {
|
||||||
|
|
|
@ -11,7 +11,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.time,
|
.time,
|
||||||
.delete-info {
|
.delete-info,
|
||||||
|
.draft-type {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
class DraftsController < ApplicationController
|
||||||
|
requires_login
|
||||||
|
|
||||||
|
skip_before_action :check_xhr, :preload_json
|
||||||
|
|
||||||
|
def index
|
||||||
|
params.require(:username)
|
||||||
|
params.permit(:offset)
|
||||||
|
params.permit(:limit)
|
||||||
|
|
||||||
|
user = fetch_user_from_params
|
||||||
|
|
||||||
|
opts = {
|
||||||
|
user: user,
|
||||||
|
offset: params[:offset],
|
||||||
|
limit: params[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
guardian.ensure_can_see_drafts!(user)
|
||||||
|
stream = Draft.stream(opts)
|
||||||
|
stream.each do |d|
|
||||||
|
parsed_data = JSON.parse(d.data)
|
||||||
|
if parsed_data
|
||||||
|
if parsed_data['reply']
|
||||||
|
d.raw = parsed_data['reply']
|
||||||
|
end
|
||||||
|
if parsed_data['categoryId'].present? && !d.category_id.present?
|
||||||
|
d.category_id = parsed_data['categoryId']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
help_key = "user_activity.no_drafts"
|
||||||
|
if user == current_user
|
||||||
|
help_key += ".self"
|
||||||
|
else
|
||||||
|
help_key += ".others"
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
drafts: serialize_data(stream, DraftSerializer),
|
||||||
|
no_results_help: I18n.t(help_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -43,6 +43,43 @@ class Draft < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.stream(opts = nil)
|
||||||
|
opts ||= {}
|
||||||
|
|
||||||
|
user_id = opts[:user].id
|
||||||
|
offset = (opts[:offset] || 0).to_i
|
||||||
|
limit = (opts[:limit] || 30).to_i
|
||||||
|
|
||||||
|
# JOIN of topics table based on manipulating draft_key seems imperfect
|
||||||
|
builder = DB.build <<~SQL
|
||||||
|
SELECT
|
||||||
|
d.*, t.title, t.id topic_id, t.archetype,
|
||||||
|
t.category_id, t.closed topic_closed, t.archived topic_archived,
|
||||||
|
pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id, pu.username_lower,
|
||||||
|
du.username draft_username, NULL as raw, NULL as cooked, NULL as post_number
|
||||||
|
FROM drafts d
|
||||||
|
LEFT JOIN topics t ON
|
||||||
|
CASE
|
||||||
|
WHEN d.draft_key LIKE '%' || '#{EXISTING_TOPIC}' || '%'
|
||||||
|
THEN CAST(replace(d.draft_key, '#{EXISTING_TOPIC}', '') AS INT)
|
||||||
|
ELSE 0
|
||||||
|
END = t.id
|
||||||
|
JOIN users pu on pu.id = COALESCE(t.user_id, d.user_id)
|
||||||
|
JOIN users du on du.id = #{user_id}
|
||||||
|
/*where*/
|
||||||
|
/*order_by*/
|
||||||
|
/*offset*/
|
||||||
|
/*limit*/
|
||||||
|
SQL
|
||||||
|
|
||||||
|
builder
|
||||||
|
.where('d.user_id = :user_id', user_id: user_id.to_i)
|
||||||
|
.order_by('d.updated_at desc')
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.query
|
||||||
|
end
|
||||||
|
|
||||||
def self.cleanup!
|
def self.cleanup!
|
||||||
DB.exec("DELETE FROM drafts where sequence < (
|
DB.exec("DELETE FROM drafts where sequence < (
|
||||||
SELECT max(s.sequence) from draft_sequences s
|
SELECT max(s.sequence) from draft_sequences s
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
require_relative 'post_item_excerpt'
|
||||||
|
|
||||||
|
class DraftSerializer < ApplicationSerializer
|
||||||
|
include PostItemExcerpt
|
||||||
|
|
||||||
|
attributes :created_at,
|
||||||
|
:draft_key,
|
||||||
|
:sequence,
|
||||||
|
:draft_username,
|
||||||
|
:avatar_template,
|
||||||
|
:data,
|
||||||
|
:topic_id,
|
||||||
|
:username,
|
||||||
|
:username_lower,
|
||||||
|
:name,
|
||||||
|
:user_id,
|
||||||
|
:title,
|
||||||
|
:slug,
|
||||||
|
:category_id,
|
||||||
|
:closed,
|
||||||
|
:archetype,
|
||||||
|
:archived
|
||||||
|
|
||||||
|
def avatar_template
|
||||||
|
User.avatar_template(object.username, object.uploaded_avatar_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def slug
|
||||||
|
Slug.for(object.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_slug?
|
||||||
|
object.title.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def closed
|
||||||
|
object.topic_closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def archived
|
||||||
|
object.topic_archived
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_closed?
|
||||||
|
object.topic_closed.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_archived?
|
||||||
|
object.topic_archived.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_category_id?
|
||||||
|
object.category_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -287,6 +287,14 @@ en:
|
||||||
remove: "Remove Bookmark"
|
remove: "Remove Bookmark"
|
||||||
confirm_clear: "Are you sure you want to clear all the bookmarks from this topic?"
|
confirm_clear: "Are you sure you want to clear all the bookmarks from this topic?"
|
||||||
|
|
||||||
|
drafts:
|
||||||
|
resume: "Resume Draft"
|
||||||
|
remove: "Remove Draft"
|
||||||
|
new_topic: "New topic draft"
|
||||||
|
new_private_message: "New private message draft"
|
||||||
|
topic_reply: "Draft reply"
|
||||||
|
post_reply: "Draft reply to #{{postNumber}}"
|
||||||
|
|
||||||
topic_count_latest:
|
topic_count_latest:
|
||||||
one: "See {{count}} new or updated topic"
|
one: "See {{count}} new or updated topic"
|
||||||
other: "See {{count}} new or updated topics"
|
other: "See {{count}} new or updated topics"
|
||||||
|
@ -546,6 +554,7 @@ en:
|
||||||
"12": "Sent Items"
|
"12": "Sent Items"
|
||||||
"13": "Inbox"
|
"13": "Inbox"
|
||||||
"14": "Pending"
|
"14": "Pending"
|
||||||
|
"15": "Drafts"
|
||||||
|
|
||||||
categories:
|
categories:
|
||||||
all: "all categories"
|
all: "all categories"
|
||||||
|
|
|
@ -776,6 +776,9 @@ en:
|
||||||
no_replies:
|
no_replies:
|
||||||
self: "You have not replied to any posts."
|
self: "You have not replied to any posts."
|
||||||
others: "No replies."
|
others: "No replies."
|
||||||
|
no_drafts:
|
||||||
|
self: "You have no drafts."
|
||||||
|
others: "No drafts."
|
||||||
|
|
||||||
topic_flag_types:
|
topic_flag_types:
|
||||||
spam:
|
spam:
|
||||||
|
|
|
@ -736,6 +736,7 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
get "message-bus/poll" => "message_bus#poll"
|
get "message-bus/poll" => "message_bus#poll"
|
||||||
|
|
||||||
|
resources :drafts, only: [:index]
|
||||||
get "draft" => "draft#show"
|
get "draft" => "draft#show"
|
||||||
post "draft" => "draft#update"
|
post "draft" => "draft#update"
|
||||||
delete "draft" => "draft#destroy"
|
delete "draft" => "draft#destroy"
|
||||||
|
|
|
@ -30,6 +30,10 @@ module UserGuardian
|
||||||
is_me?(user) || is_admin?
|
is_me?(user) || is_admin?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_see_drafts?(user)
|
||||||
|
is_me?(user) || is_admin?
|
||||||
|
end
|
||||||
|
|
||||||
def can_silence_user?(user)
|
def can_silence_user?(user)
|
||||||
user && is_staff? && not(user.staff?)
|
user && is_staff? && not(user.staff?)
|
||||||
end
|
end
|
||||||
|
|
|
@ -2350,6 +2350,24 @@ describe Guardian do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "can_see_drafts?" do
|
||||||
|
it "won't allow a non-logged in user to see a user's drafts" do
|
||||||
|
expect(Guardian.new.can_see_drafts?(user)).to be_falsey
|
||||||
|
end
|
||||||
|
|
||||||
|
it "won't allow a user to see another user's drafts" do
|
||||||
|
expect(Guardian.new(coding_horror).can_see_drafts?(user)).to be_falsey
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will allow user to see own drafts" do
|
||||||
|
expect(Guardian.new(user).can_see_drafts?(user)).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will allow an admin to see a user's drafts" do
|
||||||
|
expect(Guardian.new(admin).can_see_drafts?(user)).to be_truthy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "can_edit_email?" do
|
describe "can_edit_email?" do
|
||||||
context 'when allowed in settings' do
|
context 'when allowed in settings' do
|
||||||
before do
|
before do
|
||||||
|
|
|
@ -64,6 +64,34 @@ describe Draft do
|
||||||
expect(Draft.count).to eq 0
|
expect(Draft.count).to eq 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#stream' do
|
||||||
|
let(:public_post) { Fabricate(:post) }
|
||||||
|
let(:public_topic) { public_post.topic }
|
||||||
|
|
||||||
|
let(:stream) do
|
||||||
|
Draft.stream(user: @user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should include the correct number of drafts in the stream" do
|
||||||
|
Draft.set(@user, "test", 0, "first")
|
||||||
|
Draft.set(@user, "test2", 0, "second")
|
||||||
|
expect(stream.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should include the right topic id in a draft reply in the stream" do
|
||||||
|
Draft.set(@user, "topic_#{public_topic.id}", 0, "hey")
|
||||||
|
draft_row = stream.first
|
||||||
|
expect(draft_row.topic_id).to eq(public_topic.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should include the right draft username in the stream" do
|
||||||
|
Draft.set(@user, "topic_#{public_topic.id}", 0, "hey")
|
||||||
|
draft_row = stream.first
|
||||||
|
expect(draft_row.draft_username).to eq(@user.username)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
context 'key expiry' do
|
context 'key expiry' do
|
||||||
it 'nukes new topic draft after a topic is created' do
|
it 'nukes new topic draft after a topic is created' do
|
||||||
u = Fabricate(:user)
|
u = Fabricate(:user)
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe DraftsController do
|
||||||
|
it 'requires you to be logged in' do
|
||||||
|
get "/drafts.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns correct stream length after adding a draft' do
|
||||||
|
user = sign_in(Fabricate(:user))
|
||||||
|
Draft.set(user, 'xxx', 0, '{}')
|
||||||
|
get "/drafts.json", params: { username: user.username }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
parsed = JSON.parse(response.body)
|
||||||
|
expect(parsed["drafts"].length).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has empty stream after deleting last draft' do
|
||||||
|
user = sign_in(Fabricate(:user))
|
||||||
|
Draft.set(user, 'xxx', 0, '{}')
|
||||||
|
Draft.clear(user, 'xxx', 0)
|
||||||
|
get "/drafts.json", params: { username: user.username }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
parsed = JSON.parse(response.body)
|
||||||
|
expect(parsed["drafts"].length).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
|
@ -39,3 +39,18 @@ QUnit.test("Viewing Summary", async assert => {
|
||||||
assert.ok(exists(".badges-section .badge-card"), "badges");
|
assert.ok(exists(".badges-section .badge-card"), "badges");
|
||||||
assert.ok(exists(".top-categories-section .category-link"), "top categories");
|
assert.ok(exists(".top-categories-section .category-link"), "top categories");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QUnit.test("Viewing Drafts", async assert => {
|
||||||
|
await visit("/u/eviltrout/activity/drafts");
|
||||||
|
assert.ok(exists(".user-stream"), "has drafts stream");
|
||||||
|
assert.ok(
|
||||||
|
$(".user-stream .user-stream-item-draft-actions").length,
|
||||||
|
"has draft action buttons"
|
||||||
|
);
|
||||||
|
|
||||||
|
await click(".user-stream button.resume-draft:eq(0)");
|
||||||
|
assert.ok(
|
||||||
|
exists(".d-editor-input"),
|
||||||
|
"composer is visible after resuming a draft"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
"/draft.json": {
|
||||||
|
draft:
|
||||||
|
'{"reply":"dum de dum da ba.","action":"createTopic","title":"dum da ba dum dum","categoryId":null,"archetypeId":"regular","metaData":null,"composerTime":540879,"typingTime":3400}',
|
||||||
|
draft_sequence: 0
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
export default {
|
||||||
|
"/drafts.json": {
|
||||||
|
drafts: [
|
||||||
|
{
|
||||||
|
excerpt: "A fun new topic for testing drafts. ",
|
||||||
|
truncated: true,
|
||||||
|
created_at: "2018-07-22T22:20:14.608Z",
|
||||||
|
draft_key: "new_topic",
|
||||||
|
sequence: 26,
|
||||||
|
draft_username: "eviltrout",
|
||||||
|
avatar_template: "/user_avatar/localhost/eviltrout/{size}/2_1.png",
|
||||||
|
data:
|
||||||
|
'{"reply":"A fun new topic for testing drafts. \\n","action":"createTopic","title":"This here is a new topic, friend","categoryId":3,"archetypeId":"regular","metaData":null,"composerTime":24532,"typingTime":2500}',
|
||||||
|
topic_id: null,
|
||||||
|
username: "eviltrout",
|
||||||
|
name: null,
|
||||||
|
user_id: 1,
|
||||||
|
title: null,
|
||||||
|
category_id: 3,
|
||||||
|
archetype: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
excerpt:
|
||||||
|
"The last reply to this topic was 6 months ago. Your reply will bump the topic to the top of its list",
|
||||||
|
truncated: true,
|
||||||
|
created_at: "2018-07-20T19:04:32.023Z",
|
||||||
|
draft_key: "topic_280",
|
||||||
|
sequence: 0,
|
||||||
|
draft_username: "eviltrout",
|
||||||
|
avatar_template: "/letter_avatar_proxy/v2/letter/p/a87d85/{size}.png",
|
||||||
|
data:
|
||||||
|
'{"reply":"The last reply to this topic was 6 months ago. Your reply will bump the topic to the top of its list.","action":"reply","categoryId":8,"archetypeId":"regular","metaData":null,"composerTime":139499,"typingTime":6100}',
|
||||||
|
topic_id: 280,
|
||||||
|
username: "zogstrip",
|
||||||
|
name: "zogstrip",
|
||||||
|
user_id: 6,
|
||||||
|
title: "Django hangs if I write gibberish",
|
||||||
|
slug: "django-hangs-if-i-write-gibberish",
|
||||||
|
category_id: 8,
|
||||||
|
archetype: "regular"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
excerpt: "here goes a reply to a PM.",
|
||||||
|
created_at: "2018-07-20T16:58:47.433Z",
|
||||||
|
draft_key: "topic_93",
|
||||||
|
sequence: 1,
|
||||||
|
draft_username: "eviltrout",
|
||||||
|
avatar_template: "/user_avatar/localhost/eviltrout/{size}/2_1.png",
|
||||||
|
data:
|
||||||
|
'{"reply":"here goes a reply to a PM.","action":"reply","categoryId":null,"postId":212,"archetypeId":"regular","whisper":false,"metaData":null,"composerTime":455711,"typingTime":5400}',
|
||||||
|
topic_id: 93,
|
||||||
|
username: "eviltrout",
|
||||||
|
name: null,
|
||||||
|
user_id: 1,
|
||||||
|
title: "Hello dear friend, good to see you again",
|
||||||
|
slug: "hello-dear-friend-good-to-see-you-again",
|
||||||
|
archetype: "private_message"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
|
@ -225,7 +225,15 @@ export default function() {
|
||||||
return response({ category });
|
return response({ category });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.get("/draft.json", () => response({}));
|
this.get("/draft.json", request => {
|
||||||
|
if (request.queryParams.draft_key === "new_topic") {
|
||||||
|
return response(fixturesByUrl["/draft.json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.get("/drafts.json", () => response(fixturesByUrl["/drafts.json"]));
|
||||||
|
|
||||||
this.put("/queued_posts/:queued_post_id", function(request) {
|
this.put("/queued_posts/:queued_post_id", function(request) {
|
||||||
return response({ queued_post: { id: request.params.queued_post_id } });
|
return response({ queued_post: { id: request.params.queued_post_id } });
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import UserDraft from "discourse/models/user-draft";
|
||||||
|
import { NEW_TOPIC_KEY } from "discourse/models/composer";
|
||||||
|
|
||||||
|
QUnit.module("model:user-drafts");
|
||||||
|
|
||||||
|
QUnit.test("stream", assert => {
|
||||||
|
const user = Discourse.User.create({ id: 1, username: "eviltrout" });
|
||||||
|
const stream = user.get("userDraftsStream");
|
||||||
|
assert.present(stream, "a user has a drafts stream by default");
|
||||||
|
assert.equal(stream.get("itemsLoaded"), 0, "no items are loaded by default");
|
||||||
|
assert.blank(stream.get("content"), "no content by default");
|
||||||
|
});
|
||||||
|
|
||||||
|
QUnit.test("draft", assert => {
|
||||||
|
const drafts = [
|
||||||
|
UserDraft.create({
|
||||||
|
draft_key: "topic_1",
|
||||||
|
post_number: "10"
|
||||||
|
}),
|
||||||
|
UserDraft.create({
|
||||||
|
draft_key: NEW_TOPIC_KEY
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(drafts.length, 2, "drafts count is right");
|
||||||
|
assert.equal(
|
||||||
|
drafts[1].get("draftType"),
|
||||||
|
I18n.t("drafts.new_topic"),
|
||||||
|
"loads correct draftType label"
|
||||||
|
);
|
||||||
|
});
|
Loading…
Reference in New Issue