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:
Penar Musaraj
2018-08-01 02:34:54 -04:00
committed by Sam
parent 70ea153dce
commit 1f45215537
31 changed files with 643 additions and 26 deletions
@@ -2,6 +2,10 @@ import LoadMore from "discourse/mixins/load-more";
import ClickTrack from "discourse/lib/click-track";
import { selectedText } from "discourse/lib/utilities";
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, {
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() {
if (this.get("loading")) {
return;
@@ -849,7 +849,10 @@ export default Ember.Controller.extend({
if (key === "new_topic") {
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;
},
@computed("viewingSelf", "currentUser.admin")
showDrafts(viewingSelf, isAdmin) {
return viewingSelf || isAdmin;
},
@computed("viewingSelf", "currentUser.admin")
showPrivateMessages(viewingSelf, isAdmin) {
return (
@@ -12,6 +12,7 @@ export const CREATE_TOPIC = "createTopic",
EDIT_SHARED_DRAFT = "editSharedDraft",
PRIVATE_MESSAGE = "privateMessage",
NEW_PRIVATE_MESSAGE_KEY = "new_private_message",
NEW_TOPIC_KEY = "new_topic",
REPLY = "reply",
EDIT = "edit",
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 UserActionStat from "discourse/models/user-action-stat";
import UserAction from "discourse/models/user-action";
import UserDraftsStream from "discourse/models/user-drafts-stream";
import Group from "discourse/models/group";
import { emojiUnescape } from "discourse/lib/text";
import PreloadStore from "preload-store";
@@ -47,6 +48,11 @@ const User = RestModel.extend({
return UserPostsStream.create({ user: this });
},
@computed()
userDraftsStream() {
return UserDraftsStream.create({ user: this });
},
staff: Em.computed.or("admin", "moderator"),
destroySession() {
@@ -112,6 +112,7 @@ export default function() {
this.route("likesGiven", { path: "likes-given" });
this.route("bookmarks");
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({
renderTemplate() {
this.render("user/messages");
@@ -7,6 +9,23 @@ export default Discourse.Route.extend({
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: {
willTransition: function() {
this._super();
@@ -1,5 +1,3 @@
import Draft from "discourse/models/draft";
export default Discourse.Route.extend({
titleToken() {
const username = this.modelFor("user").get("username");
@@ -67,21 +65,6 @@ export default Discourse.Route.extend({
setupController(controller, user) {
controller.set("model", user);
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() {
@@ -1,12 +1,20 @@
<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>
<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-title'>
{{topic-status topic=item disableActions=true}}
<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>
</div>
<div class="category">{{category-link item.category}}</div>
@@ -50,3 +58,10 @@
{{/each}}
</div>
{{/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|}}
{{user-stream-item item=item removeBookmark=(action "removeBookmark")}}
{{user-stream-item
item=item
removeBookmark=(action "removeBookmark")
resumeDraft=(action "resumeDraft")
removeDraft=(action "removeDraft")
}}
{{/each}}
@@ -9,6 +9,11 @@
<li>
{{#link-to 'userActivity.replies'}}{{i18n 'user_action_groups.5'}}{{/link-to}}
</li>
{{#if user.showDrafts}}
<li>
{{#link-to 'userActivity.drafts'}}{{i18n 'user_action_groups.15'}}{{/link-to}}
</li>
{{/if}}
<li>
{{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
</li>