From 1f452155379c80da0c8d21b2f69b78da69d3a6bd Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 1 Aug 2018 02:34:54 -0400 Subject: [PATCH] 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 --- .../discourse/components/user-stream.js.es6 | 39 +++++++ .../discourse/controllers/composer.js.es6 | 5 +- .../discourse/controllers/user.js.es6 | 5 + .../discourse/models/composer.js.es6 | 1 + .../discourse/models/user-draft.js.es6 | 47 ++++++++ .../models/user-drafts-stream.js.es6 | 105 ++++++++++++++++++ .../javascripts/discourse/models/user.js.es6 | 6 + .../discourse/routes/app-route-map.js.es6 | 1 + .../routes/user-activity-drafts.js.es6 | 22 ++++ .../routes/user-private-messages.js.es6 | 19 ++++ .../javascripts/discourse/routes/user.js.es6 | 17 --- .../templates/components/user-stream-item.hbs | 19 +++- .../templates/components/user-stream.hbs | 7 +- .../discourse/templates/user/activity.hbs | 5 + .../common/components/user-stream-item.scss | 14 ++- .../desktop/components/user-stream-item.scss | 3 +- app/controllers/drafts_controller.rb | 47 ++++++++ app/models/draft.rb | 37 ++++++ app/serializers/draft_serializer.rb | 56 ++++++++++ config/locales/client.en.yml | 9 ++ config/locales/server.en.yml | 3 + config/routes.rb | 1 + lib/guardian/user_guardian.rb | 4 + spec/components/guardian_spec.rb | 18 +++ spec/models/draft_spec.rb | 28 +++++ spec/requests/drafts_controller_spec.rb | 27 +++++ test/javascripts/acceptance/user-test.js.es6 | 15 +++ test/javascripts/fixtures/draft.js.es6 | 7 ++ test/javascripts/fixtures/drafts.js.es6 | 61 ++++++++++ .../helpers/create-pretender.js.es6 | 10 +- .../models/user-drafts-test.js.es6 | 31 ++++++ 31 files changed, 643 insertions(+), 26 deletions(-) create mode 100644 app/assets/javascripts/discourse/models/user-draft.js.es6 create mode 100644 app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 create mode 100644 app/assets/javascripts/discourse/routes/user-activity-drafts.js.es6 create mode 100644 app/controllers/drafts_controller.rb create mode 100644 app/serializers/draft_serializer.rb create mode 100644 spec/requests/drafts_controller_spec.rb create mode 100644 test/javascripts/fixtures/draft.js.es6 create mode 100644 test/javascripts/fixtures/drafts.js.es6 create mode 100644 test/javascripts/models/user-drafts-test.js.es6 diff --git a/app/assets/javascripts/discourse/components/user-stream.js.es6 b/app/assets/javascripts/discourse/components/user-stream.js.es6 index ee32cae3002..7bd7ff01439 100644 --- a/app/assets/javascripts/discourse/components/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/components/user-stream.js.es6 @@ -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; diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index f3f26754901..bc1764c4420 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -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); + }); } }, diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index e952c7f1e43..06518682d28 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -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 ( diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 801b44c24c4..24f4d8fb794 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -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", diff --git a/app/assets/javascripts/discourse/models/user-draft.js.es6 b/app/assets/javascripts/discourse/models/user-draft.js.es6 new file mode 100644 index 00000000000..2f8d431eb73 --- /dev/null +++ b/app/assets/javascripts/discourse/models/user-draft.js.es6 @@ -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"); + } + } +}); diff --git a/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 b/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 new file mode 100644 index 00000000000..b6d82f8a33e --- /dev/null +++ b/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 @@ -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); + }); + } +}); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 94f972ca27a..e3947378fba 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -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() { diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 63c5a2a12e3..c685ba1e833 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -112,6 +112,7 @@ export default function() { this.route("likesGiven", { path: "likes-given" }); this.route("bookmarks"); this.route("pending"); + this.route("drafts"); } ); diff --git a/app/assets/javascripts/discourse/routes/user-activity-drafts.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-drafts.js.es6 new file mode 100644 index 00000000000..2ca94b05e5e --- /dev/null +++ b/app/assets/javascripts/discourse/routes/user-activity-drafts.js.es6 @@ -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; + } + } +}); diff --git a/app/assets/javascripts/discourse/routes/user-private-messages.js.es6 b/app/assets/javascripts/discourse/routes/user-private-messages.js.es6 index a2fb6a62e81..01a6052cf4a 100644 --- a/app/assets/javascripts/discourse/routes/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-private-messages.js.es6 @@ -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(); diff --git a/app/assets/javascripts/discourse/routes/user.js.es6 b/app/assets/javascripts/discourse/routes/user.js.es6 index 47e4b118937..24093fd3a53 100644 --- a/app/assets/javascripts/discourse/routes/user.js.es6 +++ b/app/assets/javascripts/discourse/routes/user.js.es6 @@ -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() { diff --git a/app/assets/javascripts/discourse/templates/components/user-stream-item.hbs b/app/assets/javascripts/discourse/templates/components/user-stream-item.hbs index e4e49ecbc13..c57d183991c 100644 --- a/app/assets/javascripts/discourse/templates/components/user-stream-item.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-stream-item.hbs @@ -1,12 +1,20 @@
{{avatar item imageSize="large" extraClasses="actor" ignoreTitle="true"}}
{{format-date item.created_at}} - {{expand-post item=item}} + {{#if item.draftType}} + {{{item.draftType}}} + {{else}} + {{expand-post item=item}} + {{/if}}
{{topic-status topic=item disableActions=true}} - {{{item.title}}} + {{#if item.postUrl}} + {{{item.title}}} + {{else}} + {{{item.title}}} + {{/if}}
{{category-link item.category}}
@@ -50,3 +58,10 @@ {{/each}}
{{/each}} + +{{#if item.editableDraft}} +
+ {{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"}} +
+{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/user-stream.hbs b/app/assets/javascripts/discourse/templates/components/user-stream.hbs index e12fd05d516..1e3b3c306e8 100644 --- a/app/assets/javascripts/discourse/templates/components/user-stream.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-stream.hbs @@ -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}} diff --git a/app/assets/javascripts/discourse/templates/user/activity.hbs b/app/assets/javascripts/discourse/templates/user/activity.hbs index f546aff2f4d..75899390d81 100644 --- a/app/assets/javascripts/discourse/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/templates/user/activity.hbs @@ -9,6 +9,11 @@
  • {{#link-to 'userActivity.replies'}}{{i18n 'user_action_groups.5'}}{{/link-to}}
  • + {{#if user.showDrafts}} +
  • + {{#link-to 'userActivity.drafts'}}{{i18n 'user_action_groups.15'}}{{/link-to}} +
  • + {{/if}}
  • {{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
  • diff --git a/app/assets/stylesheets/common/components/user-stream-item.scss b/app/assets/stylesheets/common/components/user-stream-item.scss index 7e904766650..54c2f126a89 100644 --- a/app/assets/stylesheets/common/components/user-stream-item.scss +++ b/app/assets/stylesheets/common/components/user-stream-item.scss @@ -36,13 +36,18 @@ } .time, - .delete-info { + .delete-info, + .draft-type { display: block; float: right; color: lighten($primary, 40%); font-size: $font-down-2; } + .draft-type { + clear: right; + } + .delete-info i { font-size: $font-0; } @@ -83,7 +88,8 @@ padding: 3px 5px 5px 5px; } - .remove-bookmark { + .remove-bookmark, + .remove-draft { float: right; margin-top: -4px; } @@ -95,6 +101,7 @@ p { display: inline-block; + span { color: $primary; } @@ -131,7 +138,8 @@ } .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; .avatar-link { diff --git a/app/assets/stylesheets/desktop/components/user-stream-item.scss b/app/assets/stylesheets/desktop/components/user-stream-item.scss index af6000f3845..d74b9550f16 100644 --- a/app/assets/stylesheets/desktop/components/user-stream-item.scss +++ b/app/assets/stylesheets/desktop/components/user-stream-item.scss @@ -11,7 +11,8 @@ } .time, - .delete-info { + .delete-info, + .draft-type { margin-right: 8px; } diff --git a/app/controllers/drafts_controller.rb b/app/controllers/drafts_controller.rb new file mode 100644 index 00000000000..313e8814816 --- /dev/null +++ b/app/controllers/drafts_controller.rb @@ -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 diff --git a/app/models/draft.rb b/app/models/draft.rb index b008a6c51c9..812670feaaf 100644 --- a/app/models/draft.rb +++ b/app/models/draft.rb @@ -43,6 +43,43 @@ class Draft < ActiveRecord::Base 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! DB.exec("DELETE FROM drafts where sequence < ( SELECT max(s.sequence) from draft_sequences s diff --git a/app/serializers/draft_serializer.rb b/app/serializers/draft_serializer.rb new file mode 100644 index 00000000000..52399d3588d --- /dev/null +++ b/app/serializers/draft_serializer.rb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e90a58a7ec7..ad05f9c7eac 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -287,6 +287,14 @@ en: remove: "Remove Bookmark" 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: one: "See {{count}} new or updated topic" other: "See {{count}} new or updated topics" @@ -546,6 +554,7 @@ en: "12": "Sent Items" "13": "Inbox" "14": "Pending" + "15": "Drafts" categories: all: "all categories" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8af8699d4f2..1d034eca822 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -776,6 +776,9 @@ en: no_replies: self: "You have not replied to any posts." others: "No replies." + no_drafts: + self: "You have no drafts." + others: "No drafts." topic_flag_types: spam: diff --git a/config/routes.rb b/config/routes.rb index fa9a7277926..edbade0d01d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -736,6 +736,7 @@ Discourse::Application.routes.draw do get "message-bus/poll" => "message_bus#poll" + resources :drafts, only: [:index] get "draft" => "draft#show" post "draft" => "draft#update" delete "draft" => "draft#destroy" diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 0aef85c9914..b111d5f5112 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -30,6 +30,10 @@ module UserGuardian is_me?(user) || is_admin? end + def can_see_drafts?(user) + is_me?(user) || is_admin? + end + def can_silence_user?(user) user && is_staff? && not(user.staff?) end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 9b95f66e329..72c1c8db3a5 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -2350,6 +2350,24 @@ describe Guardian do 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 context 'when allowed in settings' do before do diff --git a/spec/models/draft_spec.rb b/spec/models/draft_spec.rb index 06ddf3d86ea..90fc10270c3 100644 --- a/spec/models/draft_spec.rb +++ b/spec/models/draft_spec.rb @@ -64,6 +64,34 @@ describe Draft do expect(Draft.count).to eq 0 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 it 'nukes new topic draft after a topic is created' do u = Fabricate(:user) diff --git a/spec/requests/drafts_controller_spec.rb b/spec/requests/drafts_controller_spec.rb new file mode 100644 index 00000000000..bffc3900b54 --- /dev/null +++ b/spec/requests/drafts_controller_spec.rb @@ -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 diff --git a/test/javascripts/acceptance/user-test.js.es6 b/test/javascripts/acceptance/user-test.js.es6 index abd0f933564..b215b402e45 100644 --- a/test/javascripts/acceptance/user-test.js.es6 +++ b/test/javascripts/acceptance/user-test.js.es6 @@ -39,3 +39,18 @@ QUnit.test("Viewing Summary", async assert => { assert.ok(exists(".badges-section .badge-card"), "badges"); 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" + ); +}); diff --git a/test/javascripts/fixtures/draft.js.es6 b/test/javascripts/fixtures/draft.js.es6 new file mode 100644 index 00000000000..7819b4adc65 --- /dev/null +++ b/test/javascripts/fixtures/draft.js.es6 @@ -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 + } +}; diff --git a/test/javascripts/fixtures/drafts.js.es6 b/test/javascripts/fixtures/drafts.js.es6 new file mode 100644 index 00000000000..8aaa88289c3 --- /dev/null +++ b/test/javascripts/fixtures/drafts.js.es6 @@ -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" + } + ] + } +}; diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index a206634abc7..0ec3ec51c2c 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -225,7 +225,15 @@ export default function() { 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) { return response({ queued_post: { id: request.params.queued_post_id } }); diff --git a/test/javascripts/models/user-drafts-test.js.es6 b/test/javascripts/models/user-drafts-test.js.es6 new file mode 100644 index 00000000000..895bdce5508 --- /dev/null +++ b/test/javascripts/models/user-drafts-test.js.es6 @@ -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" + ); +});