FEATURE: Multiple Draft Topics (#30790)

Allows users to save multiple topic and personal message drafts,
allowing more flexibility around content creation.

The "New Topic" button will now always start a fresh topic. Drafts can
be resumed from the drafts dropdown menu or using the "My Drafts" link
in the sidebar.

Since drafts require a unique `draft_key` and `user_id` combination, we
have updated the format of the draft key for both topics and personal
messages. They will now have a prefix like "new_topic_" or
"new_message_" with the timestamp of when the composer was first opened
appended.
This commit is contained in:
David Battersby 2025-01-29 10:23:26 +04:00 committed by GitHub
parent 29e48a6478
commit c64b5d6d7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 132 additions and 275 deletions

View File

@ -82,32 +82,28 @@ export default class DNavigation extends Component {
@discourseComputed(
"createTopicDisabled",
"hasDraft",
"categoryReadOnlyBanner",
"canCreateTopicOnTag",
"tag.id"
)
createTopicButtonDisabled(
createTopicDisabled,
hasDraft,
categoryReadOnlyBanner,
canCreateTopicOnTag,
tagId
) {
if (tagId && !canCreateTopicOnTag) {
return true;
} else if (categoryReadOnlyBanner && !hasDraft) {
} else if (categoryReadOnlyBanner) {
return false;
}
return createTopicDisabled;
}
@discourseComputed("categoryReadOnlyBanner", "hasDraft")
createTopicClass(categoryReadOnlyBanner, hasDraft) {
@discourseComputed("categoryReadOnlyBanner")
createTopicClass(categoryReadOnlyBanner) {
let classNames = ["btn-default"];
if (hasDraft) {
classNames.push("open-draft");
} else if (categoryReadOnlyBanner) {
if (categoryReadOnlyBanner) {
classNames.push("disabled");
}
return classNames.join(" ");
@ -192,7 +188,7 @@ export default class DNavigation extends Component {
@action
clickCreateTopicButton() {
if (this.categoryReadOnlyBanner && !this.hasDraft) {
if (this.categoryReadOnlyBanner) {
this.dialog.alert({ message: htmlSafe(this.categoryReadOnlyBanner) });
} else {
this.createTopic();

View File

@ -45,7 +45,6 @@
@canCreateTopicOnTag={{@canCreateTopicOnTag}}
@createTopic={{@createTopic}}
@createTopicDisabled={{@createTopicDisabled}}
@hasDraft={{this.currentUser.has_topic_draft}}
@draftCount={{this.currentUser.draft_count}}
@editCategory={{this.editCategory}}
@showCategoryAdmin={{@showCategoryAdmin}}

View File

@ -190,7 +190,7 @@
<DiscourseLinkedText
@action={{fn
this.composer.openNewTopic
(hash category=@category preferDraft=true)
(hash category=@category)
}}
@text="topic.suggest_create_topic"
/>

View File

@ -296,7 +296,6 @@ export default class ReviewableItem extends Component {
action: Composer.EDIT,
draftKey: post.get("topic.draft_key"),
draftSequence: post.get("topic.draft_sequence"),
skipDraftCheck: true,
skipJumpOnSave: true,
};

View File

@ -17,7 +17,6 @@ import dIcon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ClickTrack from "discourse/lib/click-track";
import DiscourseURL from "discourse/lib/url";
import { NEW_TOPIC_KEY } from "discourse/models/composer";
import Draft from "discourse/models/draft";
import Post from "discourse/models/post";
import { i18n } from "discourse-i18n";
@ -127,10 +126,6 @@ export default class UserStreamComponent extends Component {
try {
await Draft.clear(draft.draft_key, draft.sequence);
this.args.stream.remove(draft);
if (draft.draft_key === NEW_TOPIC_KEY) {
this.currentUser.has_topic_draft = false;
}
} catch (error) {
popupAjaxError(error);
}

View File

@ -26,9 +26,7 @@ export default class CategoriesController extends Controller {
@action
createTopic() {
this.composer.openNewTopic({
preferDraft: true,
});
this.composer.openNewTopic();
}
@action

View File

@ -170,7 +170,6 @@ export default class DiscoveryListController extends Controller {
.filter(Boolean)
.reject((t) => ["none", "all"].includes(t))
.join(","),
preferDraft: true,
});
}

View File

@ -1310,7 +1310,7 @@ export default class Composer extends RestModel {
return true;
}
saveDraft(user) {
saveDraft() {
if (!this.canSaveDraft) {
return Promise.resolve();
}
@ -1339,10 +1339,6 @@ export default class Composer extends RestModel {
draftConflictUser: result.conflict_user,
});
} else {
if (this.draftKey === NEW_TOPIC_KEY && user) {
user.set("has_topic_draft", true);
}
this.setProperties({
draftStatus: null,
draftConflictUser: null,

View File

@ -38,13 +38,11 @@ export default class UserDraft extends RestModel {
@discourseComputed("draft_key")
draftType(draftKey) {
switch (draftKey) {
case NEW_TOPIC_KEY:
return i18n("drafts.new_topic");
case NEW_PRIVATE_MESSAGE_KEY:
return i18n("drafts.new_private_message");
default:
return false;
if (draftKey.startsWith(NEW_TOPIC_KEY)) {
return i18n("drafts.new_topic");
} else if (draftKey.startsWith(NEW_PRIVATE_MESSAGE_KEY)) {
return i18n("drafts.new_private_message");
}
return false;
}
}

View File

@ -76,8 +76,8 @@ export default class UserDraftsStream extends RestModel {
draft.excerpt = excerpt(cooked.toString(), 300);
draft.post_number = draft.data.postId || null;
if (
draft.draft_key === NEW_PRIVATE_MESSAGE_KEY ||
draft.draft_key === NEW_TOPIC_KEY
draft.draft_key.startsWith(NEW_PRIVATE_MESSAGE_KEY) ||
draft.draft_key.startsWith(NEW_TOPIC_KEY)
) {
draft.title = draft.data.title;
}

View File

@ -137,7 +137,7 @@ export default class ApplicationRoute extends DiscourseRoute {
action: Composer.PRIVATE_MESSAGE,
recipients,
archetypeId: "private_message",
draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
draftKey: this.composer.privateMessageDraftKey,
draftSequence: 0,
reply,
title,

View File

@ -38,6 +38,7 @@ import { escapeExpression } from "discourse/lib/utilities";
import Category from "discourse/models/category";
import Composer, {
CREATE_TOPIC,
NEW_PRIVATE_MESSAGE_KEY,
NEW_TOPIC_KEY,
SAVE_ICONS,
SAVE_LABELS,
@ -138,6 +139,14 @@ export default class ComposerService extends Service {
return this.model?.composeState === Composer.OPEN;
}
get topicDraftKey() {
return NEW_TOPIC_KEY + "_" + new Date().getTime();
}
get privateMessageDraftKey() {
return NEW_PRIVATE_MESSAGE_KEY + "_" + new Date().getTime();
}
@on("init")
_setupPreview() {
const val = this.site.mobileView
@ -577,8 +586,8 @@ export default class ComposerService extends Service {
if (opts.fallbackToNewTopic) {
return await this.open({
action: Composer.CREATE_TOPIC,
draftKey: Composer.NEW_TOPIC_KEY,
action: CREATE_TOPIC,
draftKey: this.topicDraftKey,
...(opts.openOpts || {}),
});
}
@ -1179,10 +1188,6 @@ export default class ComposerService extends Service {
);
}
if (this.get("model.draftKey") === Composer.NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", false);
}
if (result.responseJson.route_to) {
// TODO: await this:
this.destroyDraft();
@ -1274,7 +1279,6 @@ export default class ComposerService extends Service {
@param {Number} [opts.prioritizedCategoryId]
@param {Number} [opts.formTemplateId]
@param {String} [opts.draftSequence]
@param {Boolean} [opts.skipDraftCheck]
@param {Boolean} [opts.skipJumpOnSave] Option to skip navigating to the post when saved in this composer session
@param {Boolean} [opts.skipFormTemplate] Option to skip the form template even if configured for the category
**/
@ -1369,28 +1373,10 @@ export default class ComposerService extends Service {
composerModel.setProperties({ unlistTopic: false, whisper: false });
}
// we need a draft sequence for the composer to work
if (opts.draftSequence === undefined) {
let data = await Draft.get(opts.draftKey);
if (opts.skipDraftCheck) {
data.draft = undefined;
} else {
data = await this.confirmDraftAbandon(data);
}
opts.draft ||= data.draft;
opts.draftSequence = data.draft_sequence;
await this._setModel(composerModel, opts);
return;
}
await this._setModel(composerModel, opts);
// otherwise, do the draft check async
if (!opts.draft && !opts.skipDraftCheck) {
if (!opts.draft) {
let data = await Draft.get(opts.draftKey);
data = await this.confirmDraftAbandon(data);
@ -1406,49 +1392,19 @@ export default class ComposerService extends Service {
}
}
async #openNewTopicDraft() {
if (
this.model?.action === Composer.CREATE_TOPIC &&
this.model?.draftKey === Composer.NEW_TOPIC_KEY
) {
this.set("model.composeState", Composer.OPEN);
} else {
const data = await Draft.get(Composer.NEW_TOPIC_KEY);
if (data.draft) {
return this.open({
action: Composer.CREATE_TOPIC,
draft: data.draft,
draftKey: Composer.NEW_TOPIC_KEY,
draftSequence: data.draft_sequence,
});
}
}
}
@action
async openNewTopic({
title,
body,
category,
tags,
formTemplate,
preferDraft = false,
} = {}) {
if (preferDraft && this.currentUser.has_topic_draft) {
return this.#openNewTopicDraft();
} else {
return this.open({
prioritizedCategoryId: category?.id,
topicCategoryId: category?.id,
formTemplateId: formTemplate?.id,
topicTitle: title,
topicBody: body,
topicTags: tags,
action: CREATE_TOPIC,
draftKey: NEW_TOPIC_KEY,
draftSequence: 0,
});
}
async openNewTopic({ title, body, category, tags, formTemplate } = {}) {
return this.open({
prioritizedCategoryId: category?.id,
topicCategoryId: category?.id,
formTemplateId: formTemplate?.id,
topicTitle: title,
topicBody: body,
topicTags: tags,
action: CREATE_TOPIC,
draftKey: this.topicDraftKey,
draftSequence: 0,
});
}
@action
@ -1459,7 +1415,7 @@ export default class ComposerService extends Service {
topicTitle: title,
topicBody: body,
archetypeId: "private_message",
draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
draftKey: this.privateMessageDraftKey,
hasGroups,
});
}
@ -1570,10 +1526,6 @@ export default class ComposerService extends Service {
return;
}
if (key === Composer.NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", false);
}
if (this._saveDraftPromise) {
await this._saveDraftPromise;
return await this.destroyDraft();
@ -1717,12 +1669,10 @@ export default class ComposerService extends Service {
}
}
this._saveDraftPromise = this.model
.saveDraft(this.currentUser)
.finally(() => {
this._lastDraftSaved = Date.now();
this._saveDraftPromise = null;
});
this._saveDraftPromise = this.model.saveDraft().finally(() => {
this._lastDraftSaved = Date.now();
this._saveDraftPromise = null;
});
}
}

View File

@ -385,12 +385,15 @@ acceptance("Composer Actions With New Topic Draft", function (needs) {
needs.hooks.afterEach(() => toggleCheckDraftPopup(false));
test("shared draft", async function (assert) {
updateCurrentUser({ has_topic_draft: true });
updateCurrentUser({ draft_count: 1 });
stubDraftResponse();
toggleCheckDraftPopup(true);
await visit("/");
await click("button.open-draft");
await click("button.topic-drafts-menu-trigger");
await click(
".topic-drafts-menu-content .topic-drafts-item:first-child button"
);
await fillIn(
"#reply-title",

View File

@ -16,10 +16,7 @@ import LinkLookup from "discourse/lib/link-lookup";
import { cloneJSON } from "discourse/lib/object";
import { withPluginApi } from "discourse/lib/plugin-api";
import { translateModKey } from "discourse/lib/utilities";
import Composer, {
CREATE_TOPIC,
NEW_TOPIC_KEY,
} from "discourse/models/composer";
import Composer, { CREATE_TOPIC } from "discourse/models/composer";
import Draft from "discourse/models/draft";
import { toggleCheckDraftPopup } from "discourse/services/composer";
import TopicFixtures from "discourse/tests/fixtures/topic";
@ -27,7 +24,6 @@ import pretender, { response } from "discourse/tests/helpers/create-pretender";
import {
acceptance,
metaModifier,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { i18n } from "discourse-i18n";
@ -910,27 +906,6 @@ acceptance("Composer", function (needs) {
assert.strictEqual(privateMessageUsers.header().value(), "codinghorror");
});
test("Loads tags and category from draft payload", async function (assert) {
updateCurrentUser({ has_topic_draft: true });
sinon.stub(Draft, "get").resolves({
draft:
'{"reply":"Hey there","action":"createTopic","title":"Draft topic","categoryId":2,"tags":["fun", "xmark"],"archetypeId":"regular","metaData":null,"composerTime":25269,"typingTime":8100}',
draft_sequence: 0,
draft_key: NEW_TOPIC_KEY,
});
await visit("/latest");
assert.dom("#create-topic").hasText(i18n("topic.create"));
await click("#create-topic");
assert.strictEqual(selectKit(".category-chooser").header().value(), "2");
assert.strictEqual(
selectKit(".mini-tag-chooser").header().value(),
"fun,xmark"
);
});
test("Deleting the text content of the first post in a private message", async function (assert) {
await visit("/t/34");

View File

@ -296,7 +296,6 @@ export default {
day_6_start_time: 480,
day_6_end_time: 1020,
},
has_topic_draft: true,
},
},
"/u/eviltrout/card.json": {

View File

@ -289,7 +289,6 @@ export default class ComposerActions extends DropdownSelectBoxComponent {
options.action = PRIVATE_MESSAGE;
options.recipients = recipients.join(",");
options.archetypeId = "private_message";
options.skipDraftCheck = true;
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
}
@ -297,14 +296,12 @@ export default class ComposerActions extends DropdownSelectBoxComponent {
replyToTopicSelected(options) {
options.action = REPLY;
options.topic = _topicSnapshot;
options.skipDraftCheck = true;
this._openComposer(options);
}
replyToPostSelected(options) {
options.action = REPLY;
options.post = _postSnapshot;
options.skipDraftCheck = true;
this._openComposer(options);
}
@ -326,7 +323,6 @@ export default class ComposerActions extends DropdownSelectBoxComponent {
options.action = CREATE_TOPIC;
options.categoryId = this.get("composerModel.topic.category.id");
options.disableScopedCategory = true;
options.skipDraftCheck = true;
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
}
@ -352,7 +348,6 @@ export default class ComposerActions extends DropdownSelectBoxComponent {
options.action = PRIVATE_MESSAGE;
options.recipients = usernames;
options.archetypeId = "private_message";
options.skipDraftCheck = true;
this._replyFromExisting(options, _postSnapshot, _topicSnapshot);
}
@ -362,7 +357,6 @@ export default class ComposerActions extends DropdownSelectBoxComponent {
options.categoryId = this.get("composerModel.categoryId");
options.topicTitle = this.get("composerModel.title");
options.tags = this.get("composerModel.tags");
options.skipDraftCheck = true;
this._openComposer(options);
}

View File

@ -9,7 +9,7 @@ class Draft < ActiveRecord::Base
has_many :upload_references, as: :target, dependent: :delete_all
validates :draft_key, length: { maximum: 25 }
validates :draft_key, length: { maximum: 40 }
after_commit :update_draft_count, on: %i[create destroy]
@ -131,12 +131,6 @@ class Draft < ActiveRecord::Base
data if current_sequence == draft_sequence
end
def self.has_topic_draft(user)
return if !user || !user.id || !User.human_user_id?(user.id)
Draft.where(user_id: user.id, draft_key: NEW_TOPIC).present?
end
def self.clear(user, key, sequence)
if !user || !user.id || !User.human_user_id?(user.id)
raise StandardError.new("user not present")

View File

@ -450,11 +450,7 @@ class Topic < ActiveRecord::Base
end
def advance_draft_sequence
if self.private_message?
DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
else
DraftSequence.next!(user, Draft::NEW_TOPIC)
end
DraftSequence.next!(user, self.draft_key)
end
def ensure_topic_has_a_category

View File

@ -218,17 +218,16 @@ class UserStat < ActiveRecord::Base
def self.update_draft_count(user_id = nil)
if user_id.present?
draft_count, has_topic_draft =
DB.query_single <<~SQL, user_id: user_id, new_topic: Draft::NEW_TOPIC
draft_count = DB.query_single(<<~SQL, user_id: user_id).first
UPDATE user_stats
SET draft_count = (SELECT COUNT(*) FROM drafts WHERE user_id = :user_id)
WHERE user_id = :user_id
RETURNING draft_count, (SELECT 1 FROM drafts WHERE user_id = :user_id AND draft_key = :new_topic)
RETURNING draft_count
SQL
MessageBus.publish(
"/user-drafts/#{user_id}",
{ draft_count: draft_count, has_topic_draft: !!has_topic_draft },
{ draft_count: draft_count },
user_ids: [user_id],
)
else

View File

@ -63,7 +63,6 @@ class CurrentUserSerializer < BasicUserSerializer
:ignored_users,
:featured_topic,
:do_not_disturb_until,
:has_topic_draft,
:can_review,
:draft_count,
:pending_posts_count,
@ -306,14 +305,6 @@ class CurrentUserSerializer < BasicUserSerializer
BasicTopicSerializer.new(object.user_profile.featured_topic, scope: scope, root: false).as_json
end
def has_topic_draft
true
end
def include_has_topic_draft?
Draft.has_topic_draft(object)
end
def unseen_reviewable_count
Reviewable.unseen_reviewable_count(object)
end

View File

@ -372,15 +372,21 @@ RSpec.describe PostCreator do
end
it "clears the draft if advanced_draft is true" do
creator = PostCreator.new(user, basic_topic_params.merge(advance_draft: true))
Draft.set(user, Draft::NEW_TOPIC, 0, "test")
draft_key = Draft::NEW_TOPIC + "_#{Time.now.to_i}"
creator = PostCreator.new(user, basic_topic_params.merge(draft_key: draft_key))
Draft.set(user, draft_key, 0, "test")
expect(Draft.where(user: user).size).to eq(1)
expect { creator.create }.to change { Draft.count }.by(-1)
end
it "does not clear the draft if advanced_draft is false" do
creator = PostCreator.new(user, basic_topic_params.merge(advance_draft: false))
Draft.set(user, Draft::NEW_TOPIC, 0, "test")
draft_key = Draft::NEW_TOPIC + "_#{Time.now.to_i}"
creator =
PostCreator.new(
user,
basic_topic_params.merge(advance_draft: false, draft_key: draft_key),
)
Draft.set(user, draft_key, 0, "test")
expect(Draft.where(user: user).size).to eq(1)
expect { creator.create }.not_to change { Draft.count }
end

View File

@ -3,11 +3,12 @@
RSpec.describe DraftSequence do
fab!(:user)
fab!(:upload)
let!(:topic_draft_key) { Draft::NEW_TOPIC + "_0001" }
describe ".next" do
it "should produce next sequence for a key" do
expect(DraftSequence.next!(user, "test")).to eq 1
expect(DraftSequence.next!(user, "test")).to eq 2
expect(DraftSequence.next!(user, topic_draft_key)).to eq 1
expect(DraftSequence.next!(user, topic_draft_key)).to eq 2
end
it "should not produce next sequence for non-human user" do
@ -18,7 +19,7 @@ RSpec.describe DraftSequence do
it "deletes old drafts and associated upload references" do
Draft.set(
user,
Draft::NEW_TOPIC,
topic_draft_key,
0,
{
reply: "[#{upload.original_filename}|attachment](#{upload.short_url})",
@ -33,7 +34,7 @@ RSpec.describe DraftSequence do
}.to_json,
)
expect { DraftSequence.next!(user, Draft::NEW_TOPIC) }.to change { Draft.count }.by(
expect { DraftSequence.next!(user, topic_draft_key) }.to change { Draft.count }.by(
-1,
).and change { UploadReference.count }.by(-1).and change {
user.reload.user_stat.draft_count

View File

@ -2,8 +2,11 @@
RSpec.describe Draft do
fab!(:user)
fab!(:post)
let(:topic_key_1) { Draft::NEW_TOPIC + "_0001" }
let(:topic_key_2) { Draft::NEW_TOPIC + "_0002" }
let(:pm_key_1) { Draft::NEW_PRIVATE_MESSAGE + "_0001" }
let(:pm_key_2) { Draft::NEW_PRIVATE_MESSAGE + "_0002" }
it { is_expected.to have_many(:upload_references).dependent(:delete_all) }
@ -62,7 +65,7 @@ RSpec.describe Draft do
it "uses the user id and key correctly" do
Draft.set(user, "test", 0, "data")
expect(Draft.get(Fabricate.build(:coding_horror), "test", 0)).to eq nil
expect(Draft.get(Fabricate(:user), "test", 0)).to eq nil
end
it "should overwrite draft data correctly" do
@ -130,17 +133,15 @@ RSpec.describe Draft do
end
it "can cleanup old drafts" do
key = Draft::NEW_TOPIC
Draft.set(user, key, 0, "draft")
Draft.set(user, topic_key_1, 0, "draft")
Draft.cleanup!
expect(Draft.count).to eq 1
expect(user.user_stat.draft_count).to eq(1)
seq = DraftSequence.next!(user, key)
seq = DraftSequence.next!(user, topic_key_1)
Draft.set(user, key, seq, "draft")
Draft.set(user, topic_key_1, seq, "updated draft")
DraftSequence.update_all("sequence = sequence + 1")
draft = Draft.first
@ -154,7 +155,7 @@ RSpec.describe Draft do
expect(user.reload.user_stat.draft_count).to eq(0)
expect(UploadReference.count).to eq(0)
Draft.set(Fabricate(:user), Draft::NEW_TOPIC, 0, "draft")
Draft.set(Fabricate(:user), topic_key_1, 0, "draft")
Draft.cleanup!
@ -169,22 +170,20 @@ RSpec.describe Draft do
end
it "updates draft count when a draft is created or destroyed" do
Draft.set(Fabricate(:user), Draft::NEW_TOPIC, 0, "data")
Draft.set(Fabricate(:user), topic_key_1, 0, "data")
messages =
MessageBus.track_publish("/user-drafts/#{user.id}") do
Draft.set(user, Draft::NEW_TOPIC, 0, "data")
Draft.set(user, topic_key_1, 0, "data")
end
expect(messages.first.data[:draft_count]).to eq(1)
expect(messages.first.data[:has_topic_draft]).to eq(true)
expect(messages.first.user_ids).to contain_exactly(user.id)
messages =
MessageBus.track_publish("/user-drafts/#{user.id}") { Draft.where(user: user).destroy_all }
expect(messages.first.data[:draft_count]).to eq(0)
expect(messages.first.data[:has_topic_draft]).to eq(false)
expect(messages.first.user_ids).to contain_exactly(user.id)
end
@ -213,34 +212,49 @@ RSpec.describe Draft do
end
end
describe "key expiry" do
it "nukes new topic draft after a topic is created" do
Draft.set(user, Draft::NEW_TOPIC, 0, "my draft")
_t = Fabricate(:topic, user: user, advance_draft: true)
s = DraftSequence.current(user, Draft::NEW_TOPIC)
expect(Draft.get(user, Draft::NEW_TOPIC, s)).to eq nil
expect(Draft.count).to eq 0
describe "multiple drafts" do
it "allows new topic draft when a topic draft exists" do
Draft.set(user, topic_key_1, 0, "topic draft 1")
Draft.set(user, topic_key_2, 0, "topic draft 2")
seq_1 = DraftSequence.current(user, topic_key_1)
seq_2 = DraftSequence.current(user, topic_key_2)
expect(Draft.get(user, topic_key_1, seq_1)).to eq "topic draft 1"
expect(Draft.get(user, topic_key_2, seq_2)).to eq "topic draft 2"
expect(Draft.count).to eq 2
end
it "nukes new pm draft after a pm is created" do
Draft.set(user, Draft::NEW_PRIVATE_MESSAGE, 0, "my draft")
t =
Fabricate(
:topic,
user: user,
archetype: Archetype.private_message,
category_id: nil,
advance_draft: true,
)
s = DraftSequence.current(t.user, Draft::NEW_PRIVATE_MESSAGE)
expect(Draft.get(user, Draft::NEW_PRIVATE_MESSAGE, s)).to eq nil
it "allows new pm draft when a pm draft exists" do
Draft.set(user, pm_key_1, 0, "pm draft 1")
Draft.set(user, pm_key_2, 0, "pm draft 2")
seq_1 = DraftSequence.current(user, pm_key_1)
seq_2 = DraftSequence.current(user, pm_key_2)
expect(Draft.get(user, pm_key_1, seq_1)).to eq "pm draft 1"
expect(Draft.get(user, pm_key_2, seq_2)).to eq "pm draft 2"
expect(Draft.count).to eq 2
end
it "does not nuke new topic draft after a pm is created" do
Draft.set(user, Draft::NEW_TOPIC, 0, "my draft")
t = Fabricate(:topic, user: user, archetype: Archetype.private_message, category_id: nil)
s = DraftSequence.current(t.user, Draft::NEW_TOPIC)
expect(Draft.get(user, Draft::NEW_TOPIC, s)).to eq "my draft"
it "allows both topic drafts and PM drafts" do
Draft.set(user, topic_key_1, 0, "my topic draft 1")
Draft.set(user, topic_key_2, 0, "my topic draft 2")
Draft.set(user, pm_key_1, 0, "my pm draft 1")
Draft.set(user, pm_key_2, 0, "my pm draft 2")
new_topic =
Fabricate(:topic, user: user, archetype: Archetype.private_message, category_id: nil)
new_pm = Fabricate(:private_message_topic, user: user)
topic_seq_1 = DraftSequence.current(new_topic.user, topic_key_1)
topic_seq_2 = DraftSequence.current(new_topic.user, topic_key_2)
pm_seq_1 = DraftSequence.current(new_pm.user, pm_key_1)
pm_seq_2 = DraftSequence.current(new_pm.user, pm_key_2)
expect(Draft.get(user, topic_key_1, topic_seq_1)).to eq "my topic draft 1"
expect(Draft.get(user, topic_key_2, topic_seq_2)).to eq "my topic draft 2"
expect(Draft.get(user, pm_key_1, pm_seq_1)).to eq "my pm draft 1"
expect(Draft.get(user, pm_key_2, pm_seq_2)).to eq "my pm draft 2"
expect(Draft.count).to eq 4
end
it "nukes the post draft when a post is created" do
@ -269,10 +283,11 @@ RSpec.describe Draft do
end
it "increases revision each time you set" do
Draft.set(user, "new_topic", 0, "hello")
Draft.set(user, "new_topic", 0, "goodbye")
Draft.set(user, topic_key_1, 0, "hello")
Draft.set(user, topic_key_1, 0, "goodbye")
expect(Draft.find_by(user_id: user.id, draft_key: "new_topic").revisions).to eq(2)
expect(Draft.count).to eq 1
expect(Draft.find_by(user_id: user.id, draft_key: topic_key_1).revisions).to eq(2)
end
it "handles owner switching gracefully" do
@ -306,5 +321,5 @@ RSpec.describe Draft do
end
end
it { is_expected.to validate_length_of(:draft_key).is_at_most(25) }
it { is_expected.to validate_length_of(:draft_key).is_at_most(40) }
end

View File

@ -128,28 +128,6 @@ RSpec.describe CurrentUserSerializer do
end
end
describe "#has_topic_draft" do
it "is not included by default" do
payload = serializer.as_json
expect(payload).not_to have_key(:has_topic_draft)
end
it "returns true when user has a draft" do
Draft.set(user, Draft::NEW_TOPIC, 0, "test1")
payload = serializer.as_json
expect(payload[:has_topic_draft]).to eq(true)
end
it "clearing a draft removes has_topic_draft from payload" do
sequence = Draft.set(user, Draft::NEW_TOPIC, 0, "test1")
Draft.clear(user, Draft::NEW_TOPIC, sequence)
payload = serializer.as_json
expect(payload).not_to have_key(:has_topic_draft)
end
end
describe "#can_ignore_users" do
let(:guardian) { Guardian.new(user) }
let(:payload) { serializer.as_json }

View File

@ -81,41 +81,17 @@ describe "Composer - discard draft modal", type: :system do
it "destroys draft" do
visit "/new-topic"
composer.fill_title("this is a test topic")
composer.fill_content("a b c d e f g")
wait_for(timeout: 5) { Draft.count == 1 }
composer.close
expect(discard_draft_modal).to be_open
discard_draft_modal.click_save
discard_draft_modal.click_discard
wait_for(timeout: 5) { Draft.last != nil }
draft_key = Draft.last.draft_key
visit "/new-topic"
expect(dialog).to be_open
expect(page).to have_content(I18n.t("js.drafts.abandon.confirm"))
dialog.click_danger
wait_for(timeout: 5) { Draft.find_by(draft_key: draft_key) == nil }
end
it "resumes draft when using /new-message" do
visit "/new-message"
composer.fill_content("a b c d e f g")
composer.close
expect(discard_draft_modal).to be_open
discard_draft_modal.click_save
visit "/new-message"
expect(dialog).to be_open
expect(page).to have_content(I18n.t("js.drafts.abandon.confirm"))
dialog.click_button I18n.t("js.drafts.abandon.no_value")
expect(composer).to be_opened
expect(composer).to have_content("a b c d e f g")
wait_for(timeout: 5) { Draft.count == 0 }
end
end
end