FEATURE: Allow users to save draft and close composer (#12439)

We previously included this option conditionally when users were replying
or creating a new topic while they had content already in the composer.

This makes the dialog always include three buttons:
  - Close and discard
  - Close and save draft for later
  - Keed editing

This also changes how the backend notifies the frontend when there is
a current draft topic. This is now sent via the `has_topic_draft`
property in the current user serializer.
This commit is contained in:
Penar Musaraj 2021-03-19 09:19:15 -04:00 committed by GitHub
parent 6eab1e83c3
commit d470e4fade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 155 additions and 148 deletions

View File

@ -56,7 +56,8 @@ export default Component.extend({
"fixed", "fixed",
"subtitle", "subtitle",
"rawSubtitle", "rawSubtitle",
"dismissable" "dismissable",
"headerClass"
) )
); );
}, },

View File

@ -23,6 +23,7 @@ export default Component.extend({
title: null, title: null,
subtitle: null, subtitle: null,
role: "dialog", role: "dialog",
headerClass: null,
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -129,6 +130,10 @@ export default Component.extend({
this.set("dismissable", true); this.set("dismissable", true);
} }
if (data.headerClass) {
this.set("headerClass", data.headerClass);
}
if (this.element) { if (this.element) {
const autofocusInputs = this.element.querySelectorAll( const autofocusInputs = this.element.querySelectorAll(
".modal-body input[autofocus]" ".modal-body input[autofocus]"

View File

@ -18,6 +18,7 @@ import discourseComputed, {
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import Draft from "discourse/models/draft"; import Draft from "discourse/models/draft";
import I18n from "I18n"; import I18n from "I18n";
import { iconHTML } from "discourse-common/lib/icon-library";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import bootbox from "bootbox"; import bootbox from "bootbox";
import { buildQuote } from "discourse/lib/quote"; import { buildQuote } from "discourse/lib/quote";
@ -545,9 +546,7 @@ export default Controller.extend({
}, },
cancel() { cancel() {
const differentDraftContext = this.cancelComposer();
this.get("topic.id") !== this.get("model.topic.id");
this.cancelComposer(differentDraftContext);
}, },
save(ignore, event) { save(ignore, event) {
@ -903,13 +902,7 @@ export default Controller.extend({
} }
} }
// If it's a different draft, cancel it and try opening again. return this.cancelComposer()
const differentDraftContext =
opts.post && composerModel.topic
? composerModel.topic.id !== opts.post.topic_id
: true;
return this.cancelComposer(differentDraftContext)
.then(() => this.open(opts)) .then(() => this.open(opts))
.then(resolve, reject); .then(resolve, reject);
} }
@ -1037,18 +1030,19 @@ export default Controller.extend({
return false; return false;
}, },
destroyDraft() { destroyDraft(draftSequence = null) {
const key = this.get("model.draftKey"); const key = this.get("model.draftKey");
if (key) { if (key) {
if (key === "new_topic") { if (key === Composer.NEW_TOPIC_KEY) {
this.send("clearTopicDraft"); this.currentUser.set("has_topic_draft", false);
} }
if (this._saveDraftPromise) { if (this._saveDraftPromise) {
return this._saveDraftPromise.then(() => this.destroyDraft()); return this._saveDraftPromise.then(() => this.destroyDraft());
} }
return Draft.clear(key, this.get("model.draftSequence")).then(() => const sequence = draftSequence || this.get("model.draftSequence");
return Draft.clear(key, sequence).then(() =>
this.appEvents.trigger("draft:destroyed", key) this.appEvents.trigger("draft:destroyed", key)
); );
} else { } else {
@ -1078,9 +1072,12 @@ export default Controller.extend({
{ {
label: I18n.t("drafts.abandon.yes_value"), label: I18n.t("drafts.abandon.yes_value"),
class: "btn-danger", class: "btn-danger",
icon: iconHTML("far-trash-alt"),
callback: () => { callback: () => {
this.destroyDraft(data.draft_sequence).finally(() => {
data.draft = null; data.draft = null;
resolve(data); resolve(data);
});
}, },
}, },
]); ]);
@ -1091,22 +1088,20 @@ export default Controller.extend({
} }
}, },
cancelComposer(differentDraft = false) { cancelComposer() {
this.skipAutoSave = true; this.skipAutoSave = true;
if (this._saveDraftDebounce) { if (this._saveDraftDebounce) {
cancel(this._saveDraftDebounce); cancel(this._saveDraftDebounce);
} }
let promise = new Promise((resolve, reject) => { let promise = new Promise((resolve) => {
if (this.get("model.hasMetaData") || this.get("model.replyDirty")) { if (this.get("model.hasMetaData") || this.get("model.replyDirty")) {
const controller = showModal("discard-draft", { const controller = showModal("discard-draft", {
model: this.model, model: this.model,
modalClass: "discard-draft-modal", modalClass: "discard-draft-modal",
title: "post.abandon.title",
}); });
controller.setProperties({ controller.setProperties({
differentDraft,
onDestroyDraft: () => { onDestroyDraft: () => {
this.destroyDraft() this.destroyDraft()
.then(() => { .then(() => {
@ -1118,15 +1113,16 @@ export default Controller.extend({
}); });
}, },
onSaveDraft: () => { onSaveDraft: () => {
// cancel composer without destroying draft on new draft context this._saveDraft();
if (differentDraft) { if (this.model.draftKey === Composer.NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", true);
}
this.model.clearState(); this.model.clearState();
this.close(); this.close();
resolve(); resolve();
}
reject();
}, },
// needed to resume saving drafts if composer stays open
onDismissModal: () => resolve(),
}); });
} else { } else {
// it is possible there is some sort of crazy draft with no body ... just give up on it // it is possible there is some sort of crazy draft with no body ... just give up on it

View File

@ -1,40 +1,19 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
differentDraft: null,
@discourseComputed()
keyPrefix() {
return this.model.action === "edit" ? "post.abandon_edit" : "post.abandon";
},
@discourseComputed("keyPrefix")
descriptionKey(keyPrefix) {
return `${keyPrefix}.confirm`;
},
@discourseComputed("keyPrefix")
discardKey(keyPrefix) {
return `${keyPrefix}.yes_value`;
},
@discourseComputed("keyPrefix", "differentDraft")
saveKey(keyPrefix, differentDraft) {
return differentDraft
? `${keyPrefix}.no_save_draft`
: `${keyPrefix}.no_value`;
},
actions: { actions: {
_destroyDraft() { destroyDraft() {
this.onDestroyDraft(); this.onDestroyDraft();
this.send("closeModal"); this.send("closeModal");
}, },
_saveDraft() { saveDraftAndClose() {
this.onSaveDraft(); this.onSaveDraft();
this.send("closeModal"); this.send("closeModal");
}, },
dismissModal() {
this.onDismissModal();
this.send("closeModal");
},
}, },
}); });

View File

@ -1,15 +1,6 @@
import NavigationDefaultController from "discourse/controllers/navigation/default"; import NavigationDefaultController from "discourse/controllers/navigation/default";
import discourseComputed from "discourse-common/utils/decorators";
import { inject } from "@ember/controller"; import { inject } from "@ember/controller";
export default NavigationDefaultController.extend({ export default NavigationDefaultController.extend({
discoveryCategories: inject("discovery/categories"), discoveryCategories: inject("discovery/categories"),
@discourseComputed(
"discoveryCategories.model",
"discoveryCategories.model.draft"
)
draft() {
return this.get("discoveryCategories.model.draft");
},
}); });

View File

@ -1,13 +1,6 @@
import Controller, { inject as controller } from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import FilterModeMixin from "discourse/mixins/filter-mode"; import FilterModeMixin from "discourse/mixins/filter-mode";
import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend(FilterModeMixin, { export default Controller.extend(FilterModeMixin, {
discovery: controller(), discovery: controller(),
discoveryTopics: controller("discovery/topics"),
@discourseComputed("discoveryTopics.model", "discoveryTopics.model.draft")
draft: function () {
return this.get("discoveryTopics.model.draft");
},
}); });

View File

@ -29,11 +29,6 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
q: null, q: null,
showInfo: false, showInfo: false,
@discourseComputed("list", "list.draft")
createTopicLabel(list, listDraft) {
return listDraft ? "topic.open_draft" : "topic.create";
},
@discourseComputed( @discourseComputed(
"canCreateTopic", "canCreateTopic",
"category", "category",

View File

@ -1,4 +1,5 @@
import Composer from "discourse/models/composer"; import Composer from "discourse/models/composer";
import Draft from "discourse/models/draft";
import Route from "@ember/routing/route"; import Route from "@ember/routing/route";
import { once } from "@ember/runloop"; import { once } from "@ember/runloop";
import { seenUser } from "discourse/lib/user-presence"; import { seenUser } from "discourse/lib/user-presence";
@ -42,23 +43,6 @@ const DiscourseRoute = Route.extend({
refreshTitle() { refreshTitle() {
once(this, this._refreshTitleOnce); once(this, this._refreshTitleOnce);
}, },
clearTopicDraft() {
// perhaps re-delegate this to root controller in all cases?
// TODO also poison the store so it does not come back from the
// dead
if (this.get("controller.list.draft")) {
this.set("controller.list.draft", null);
}
if (this.controllerFor("discovery/categories").get("model.draft")) {
this.controllerFor("discovery/categories").set("model.draft", null);
}
if (this.controllerFor("discovery/topics").get("model.draft")) {
this.controllerFor("discovery/topics").set("model.draft", null);
}
},
}, },
redirectIfLoginRequired() { redirectIfLoginRequired() {
@ -68,20 +52,24 @@ const DiscourseRoute = Route.extend({
} }
}, },
openTopicDraft(model) { openTopicDraft() {
const composer = this.controllerFor("composer"); const composer = this.controllerFor("composer");
if ( if (
composer.get("model.action") === Composer.CREATE_TOPIC && composer.get("model.action") === Composer.CREATE_TOPIC &&
composer.get("model.draftKey") === model.draft_key composer.get("model.draftKey") === Composer.NEW_TOPIC_KEY
) { ) {
composer.set("model.composeState", Composer.OPEN); composer.set("model.composeState", Composer.OPEN);
} else { } else {
Draft.get(Composer.NEW_TOPIC_KEY).then((data) => {
if (data.draft) {
composer.open({ composer.open({
action: Composer.CREATE_TOPIC, action: Composer.CREATE_TOPIC,
draft: model.draft, draft: data.draft,
draftKey: model.draft_key, draftKey: Composer.NEW_TOPIC_KEY,
draftSequence: model.draft_sequence, draftSequence: data.draft_sequence,
});
}
}); });
} }
}, },

View File

@ -118,9 +118,8 @@ const DiscoveryCategoriesRoute = DiscourseRoute.extend(OpenComposer, {
}, },
createTopic() { createTopic() {
const model = this.controllerFor("discovery/categories").get("model"); if (this.get("currentUser.has_topic_draft")) {
if (model.draft) { this.openTopicDraft();
this.openTopicDraft(model);
} else { } else {
this.openComposer(this.controllerFor("discovery/categories")); this.openComposer(this.controllerFor("discovery/categories"));
} }

View File

@ -59,9 +59,8 @@ export default DiscourseRoute.extend(OpenComposer, {
}, },
createTopic() { createTopic() {
const model = this.controllerFor("discovery/topics").get("model"); if (this.get("currentUser.has_topic_draft")) {
if (model.draft) { this.openTopicDraft();
this.openTopicDraft(model);
} else { } else {
this.openComposer(this.controllerFor("discovery/topics")); this.openComposer(this.controllerFor("discovery/topics"));
} }

View File

@ -180,11 +180,10 @@ export default DiscourseRoute.extend(FilterModeMixin, {
}, },
createTopic() { createTopic() {
const controller = this.controllerFor("tag.show"); if (this.get("currentUser.has_topic_draft")) {
this.openTopicDraft();
if (controller.get("list.draft")) {
this.openTopicDraft(controller.get("list"));
} else { } else {
const controller = this.controllerFor("tag.show");
const composerController = this.controllerFor("composer"); const composerController = this.controllerFor("composer");
composerController composerController
.open({ .open({

View File

@ -1,7 +1,7 @@
<div class="modal-outer-container"> <div class="modal-outer-container">
<div class="modal-middle-container"> <div class="modal-middle-container">
<div class="modal-inner-container"> <div class="modal-inner-container">
<div class="modal-header"> <div class="modal-header {{headerClass}}">
{{#if dismissable}} {{#if dismissable}}
{{d-button icon="times" action=(route-action "closeModal" "initiatedByCloseButton") class="btn-flat modal-close close" title="modal.close"}} {{d-button icon="times" action=(route-action "closeModal" "initiatedByCloseButton") class="btn-flat modal-close close" title="modal.close"}}
{{/if}} {{/if}}
@ -23,7 +23,8 @@
panel=panel panel=panel
panelsLength=panels.length panelsLength=panels.length
selectedPanel=selectedPanel selectedPanel=selectedPanel
onSelectPanel=onSelectPanel}} onSelectPanel=onSelectPanel
}}
{{/each}} {{/each}}
</ul> </ul>
{{/if}} {{/if}}

View File

@ -1,10 +1,11 @@
{{#d-modal-body}} {{#d-modal-body dismissable=false headerClass="empty"}}
<div class="instructions"> <div class="instructions">
{{i18n descriptionKey}} {{i18n "post.cancel_composer.confirm"}}
</div> </div>
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">
{{d-button label=discardKey class="btn-danger" action=(action "_destroyDraft")}} {{d-button icon="far-trash-alt" label="post.cancel_composer.discard" class="btn-danger" action=(action "destroyDraft")}}
{{d-button label=saveKey action=(action "_saveDraft")}} {{d-button label="post.cancel_composer.save_draft" class="save-draft" action=(action "saveDraftAndClose")}}
{{d-button label="post.cancel_composer.keep_editing" class="keep-editing" action=(action "dismissModal")}}
</div> </div>

View File

@ -5,6 +5,6 @@
createCategory=(route-action "createCategory") createCategory=(route-action "createCategory")
reorderCategories=(route-action "reorderCategories") reorderCategories=(route-action "reorderCategories")
canCreateTopic=canCreateTopic canCreateTopic=canCreateTopic
hasDraft=draft hasDraft=currentUser.has_topic_draft
createTopic=(route-action "createTopic")}} createTopic=(route-action "createTopic")}}
{{/d-section}} {{/d-section}}

View File

@ -24,7 +24,7 @@
canCreateTopic=canCreateTopic canCreateTopic=canCreateTopic
createTopic=(route-action "createTopic") createTopic=(route-action "createTopic")
createTopicDisabled=cannotCreateTopicOnCategory createTopicDisabled=cannotCreateTopicOnCategory
hasDraft=draft hasDraft=currentUser.has_topic_draft
editCategory=(route-action "editCategory" category)}} editCategory=(route-action "editCategory" category)}}
{{plugin-outlet name="category-navigation" args=(hash category=category)}} {{plugin-outlet name="category-navigation" args=(hash category=category)}}

View File

@ -2,6 +2,6 @@
{{d-navigation {{d-navigation
filterMode=filterMode filterMode=filterMode
canCreateTopic=canCreateTopic canCreateTopic=canCreateTopic
hasDraft=draft hasDraft=currentUser.has_topic_draft
createTopic=(route-action "createTopic")}} createTopic=(route-action "createTopic")}}
{{/d-section}} {{/d-section}}

View File

@ -10,7 +10,7 @@
{{d-navigation {{d-navigation
filterMode=filterMode filterMode=filterMode
canCreateTopic=canCreateTopic canCreateTopic=canCreateTopic
hasDraft=draft hasDraft=currentUser.has_topic_draft
createTopic=(route-action "createTopic") createTopic=(route-action "createTopic")
category=category category=category
tag=tag tag=tag

View File

@ -291,12 +291,22 @@ acceptance("Composer", function (needs) {
await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".topic-post:nth-of-type(1) button.show-more-actions");
await click(".topic-post:nth-of-type(1) button.edit"); await click(".topic-post:nth-of-type(1) button.edit");
await click(".modal-footer button:nth-of-type(2)"); await click(".modal-footer button.keep-editing");
assert.ok(invisible(".discard-draft-modal.modal"));
assert.ok(!visible(".discard-draft-modal.modal"));
assert.equal( assert.equal(
queryAll(".d-editor-input").val(), queryAll(".d-editor-input").val(),
"this is the content of my reply" "this is the content of my reply",
"composer does not switch when using Keep Editing button"
);
await click(".topic-post:nth-of-type(1) button.edit");
await click(".modal-footer button.save-draft");
assert.ok(invisible(".discard-draft-modal.modal"));
assert.equal(
queryAll(".d-editor-input").val(),
queryAll(".topic-post:nth-of-type(1) .cooked > p").text(),
"composer has contents of post to be edited"
); );
}); });
@ -590,10 +600,16 @@ acceptance("Composer", function (needs) {
"it pops up a confirmation dialog" "it pops up a confirmation dialog"
); );
assert.equal( assert.equal(
queryAll(".modal-footer button:nth-of-type(2)").text().trim(), queryAll(".modal-footer button.save-draft").text().trim(),
I18n.t("post.abandon.no_value") I18n.t("post.cancel_composer.save_draft"),
"has save draft button"
); );
await click(".modal-footer button:nth-of-type(1)"); assert.equal(
queryAll(".modal-footer button.keep-editing").text().trim(),
I18n.t("post.cancel_composer.keep_editing"),
"has keep editing button"
);
await click(".modal-footer button.save-draft");
assert.equal( assert.equal(
queryAll(".d-editor-input").val().indexOf("This is the second post."), queryAll(".d-editor-input").val().indexOf("This is the second post."),
0, 0,
@ -615,14 +631,20 @@ acceptance("Composer", function (needs) {
"it pops up a confirmation dialog" "it pops up a confirmation dialog"
); );
assert.equal( assert.equal(
queryAll(".modal-footer button:nth-of-type(2)").text().trim(), queryAll(".modal-footer button.save-draft").text().trim(),
I18n.t("post.abandon.no_save_draft") I18n.t("post.cancel_composer.save_draft"),
"has save draft button"
); );
await click(".modal-footer button:nth-of-type(2)"); assert.equal(
queryAll(".modal-footer button.keep-editing").text().trim(),
I18n.t("post.cancel_composer.keep_editing"),
"has keep editing button"
);
await click(".modal-footer button.save-draft");
assert.equal( assert.equal(
queryAll(".d-editor-input").val(), queryAll(".d-editor-input").val(),
"", "",
"it populates the input with the post text" "it clears the composer input"
); );
}); });

View File

@ -54,8 +54,10 @@
.modal-header { .modal-header {
display: flex; display: flex;
&:not(.empty) {
padding: 10px 15px; padding: 10px 15px;
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
}
align-items: center; align-items: center;
.title { .title {

View File

@ -132,6 +132,12 @@ class Draft < ActiveRecord::Base
data if current_sequence == draft_sequence data if current_sequence == draft_sequence
end 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) def self.clear(user, key, sequence)
return if !user || !user.id || !User.human_user_id?(user.id) return if !user || !user.id || !User.human_user_id?(user.id)

View File

@ -51,6 +51,7 @@ class CurrentUserSerializer < BasicUserSerializer
:featured_topic, :featured_topic,
:skip_new_user_tips, :skip_new_user_tips,
:do_not_disturb_until, :do_not_disturb_until,
:has_topic_draft,
def groups def groups
object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name } } object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name } }
@ -238,4 +239,12 @@ class CurrentUserSerializer < BasicUserSerializer
def featured_topic def featured_topic
object.user_profile.featured_topic object.user_profile.featured_topic
end end
def has_topic_draft
true
end
def include_has_topic_draft?
Draft.has_topic_draft(object)
end
end end

View File

@ -346,9 +346,9 @@ en:
new_private_message: "New private message draft" new_private_message: "New private message draft"
topic_reply: "Draft reply" topic_reply: "Draft reply"
abandon: abandon:
confirm: "You already opened another draft in this topic. Are you sure you want to abandon it?" confirm: "You have a draft in progress for this topic. What would you like to do with it?"
yes_value: "Yes, abandon" yes_value: "Discard"
no_value: "No, keep" no_value: "Resume editing"
topic_count_latest: topic_count_latest:
one: "See %{count} new or updated topic" one: "See %{count} new or updated topic"
@ -2917,18 +2917,11 @@ en:
attachment_upload_not_allowed_for_new_user: "Sorry, new users can not upload attachments." attachment_upload_not_allowed_for_new_user: "Sorry, new users can not upload attachments."
attachment_download_requires_login: "Sorry, you need to be logged in to download attachments." attachment_download_requires_login: "Sorry, you need to be logged in to download attachments."
abandon_edit: cancel_composer:
confirm: "Are you sure you want to discard your changes?" confirm: "What would you like to do with your post?"
no_value: "No, keep" discard: "Discard"
no_save_draft: "No, save draft" save_draft: "Save draft for later"
yes_value: "Yes, discard edit" keep_editing: "Keep editing"
abandon:
title: "Abandon Draft"
confirm: "Are you sure you want to abandon your post?"
no_value: "No, keep"
no_save_draft: "No, save draft"
yes_value: "Yes, abandon"
via_email: "this post arrived via email" via_email: "this post arrived via email"
via_auto_generated_email: "this post arrived via an auto generated email" via_auto_generated_email: "this post arrived via an auto generated email"

View File

@ -138,4 +138,32 @@ RSpec.describe CurrentUserSerializer do
expect(payload[:groups]).to eq([{ id: public_group.id, name: public_group.name }]) expect(payload[:groups]).to eq([{ id: public_group.id, name: public_group.name }])
end end
end end
context "#has_topic_draft" do
fab!(:user) { Fabricate(:user) }
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
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
end end