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

View File

@ -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;

View File

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

View File

@ -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 (

View File

@ -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",

View File

@ -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");
}
}
});

View File

@ -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);
});
}
});

View File

@ -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() {

View File

@ -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");
} }
); );

View File

@ -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;
}
}
});

View File

@ -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();

View File

@ -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() {

View File

@ -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}}

View File

@ -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}}

View File

@ -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>

View File

@ -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 {

View File

@ -11,7 +11,8 @@
} }
.time, .time,
.delete-info { .delete-info,
.draft-type {
margin-right: 8px; margin-right: 8px;
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"
);
});

View File

@ -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
}
};

View File

@ -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"
}
]
}
};

View File

@ -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 } });

View File

@ -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"
);
});