FEATURE: add drafts dropdown menu (#30277)

This change adds a new dropdown trigger next to the "New Topic" button.
When clicked a menu will display a list of topic/post drafts that can be
clicked to resume the draft within the composer.

The "New Topic" button will no longer change text to show "Open Draft"
when a draft topic exists, it will still attempt to load the existing
draft if one exists (this will change later when we support multiple
drafts in a separate PR).

The "My Posts" link in desktop sidebar will now be "My Drafts" and only
appear when the current user has existing drafts.
This commit is contained in:
David Battersby 2025-01-13 13:33:57 +04:00 committed by GitHub
parent 0b3663a16a
commit 47c8197ea1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 325 additions and 155 deletions

View File

@ -16,4 +16,8 @@
{{/if}}
</:tooltip>
</DButtonTooltip>
{{#if @showDrafts}}
<TopicDraftsDropdown />
{{/if}}
{{/if}}

View File

@ -102,6 +102,7 @@
@label={{this.createTopicLabel}}
@btnClass={{this.createTopicClass}}
@canCreateTopicOnTag={{this.canCreateTopicOnTag}}
@showDrafts={{if (gt this.draftCount 0) true false}}
/>
<PluginOutlet

View File

@ -20,6 +20,8 @@ export default class DNavigation extends Component {
@setting("fixed_category_positions") fixedCategoryPositions;
createTopicLabel = "topic.create";
@dependentKeyCompat
get filterType() {
return filterTypeForMode(this.filterMode);
@ -111,11 +113,6 @@ export default class DNavigation extends Component {
return classNames.join(" ");
}
@discourseComputed("hasDraft")
createTopicLabel(hasDraft) {
return hasDraft ? "topic.open_draft" : "topic.create";
}
@discourseComputed("category.can_edit")
showCategoryEdit(canEdit) {
return canEdit;

View File

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

View File

@ -0,0 +1,112 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import DiscourseURL from "discourse/lib/url";
import { i18n } from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
const DRAFTS_LIMIT = 4;
export default class TopicDraftsDropdown extends Component {
@service currentUser;
@service composer;
@tracked drafts = [];
get draftCount() {
return this.currentUser.draft_count;
}
get otherDraftsCount() {
return this.draftCount > DRAFTS_LIMIT ? this.draftCount - DRAFTS_LIMIT : 0;
}
get otherDraftsText() {
return this.otherDraftsCount > 0
? i18n("drafts.dropdown.other_drafts", {
count: this.otherDraftsCount,
})
: "";
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
async onShowMenu() {
try {
const draftsStream = this.currentUser.userDraftsStream;
draftsStream.reset();
await draftsStream.findItems(this.site);
this.drafts = draftsStream.content.slice(0, DRAFTS_LIMIT);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to fetch drafts with error:", error);
}
}
@action
async resumeDraft(draft) {
await this.dMenu.close();
if (draft.postUrl) {
DiscourseURL.routeTo(draft.postUrl);
} else {
this.composer.open({
draft,
draftKey: draft.draft_key,
draftSequence: draft.sequence,
...draft.data,
});
}
}
<template>
<DMenu
@identifier="topic-drafts-menu"
@title={{i18n "drafts.dropdown.title"}}
@icon="chevron-down"
@onShow={{this.onShowMenu}}
@onRegisterApi={{this.onRegisterApi}}
@modalForMobile={{true}}
class="btn-small"
>
<:content>
<DropdownMenu as |dropdown|>
{{#each this.drafts as |draft|}}
<dropdown.item class="topic-drafts-item">
<DButton
@action={{fn this.resumeDraft draft}}
@icon={{if draft.topic_id "reply" "layer-group"}}
@translatedLabel={{draft.title}}
class="btn-secondary"
/>
</dropdown.item>
{{/each}}
<dropdown.divider />
<dropdown.item>
<DButton
@href="/my/activity/drafts"
@model={{this.currentUser}}
class="btn-link view-all-drafts"
>
<span
data-other-drafts={{this.otherDraftsCount}}
>{{this.otherDraftsText}}</span>
<span>{{i18n "drafts.dropdown.view_all"}}</span>
</DButton>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View File

@ -14,7 +14,7 @@ import {
import SectionLink from "discourse/lib/sidebar/section-link";
import AdminSectionLink from "discourse/lib/sidebar/user/community-section/admin-section-link";
import InviteSectionLink from "discourse/lib/sidebar/user/community-section/invite-section-link";
import MyPostsSectionLink from "discourse/lib/sidebar/user/community-section/my-posts-section-link";
import MyDraftsSectionLink from "discourse/lib/sidebar/user/community-section/my-drafts-section-link";
import ReviewSectionLink from "discourse/lib/sidebar/user/community-section/review-section-link";
const SPECIAL_LINKS_MAP = {
@ -22,7 +22,7 @@ const SPECIAL_LINKS_MAP = {
"/about": AboutSectionLink,
"/u": UsersSectionLink,
"/faq": FAQSectionLink,
"/my/activity": MyPostsSectionLink,
"/my/activity": MyDraftsSectionLink,
"/review": ReviewSectionLink,
"/badges": BadgesSectionLink,
"/admin": AdminSectionLink,

View File

@ -4,13 +4,13 @@ import { i18n } from "discourse-i18n";
const USER_DRAFTS_CHANGED_EVENT = "user-drafts:changed";
export default class MyPostsSectionLink extends BaseSectionLink {
export default class MyDraftsSectionLink extends BaseSectionLink {
@tracked draftCount = this.currentUser?.draft_count;
constructor() {
super(...arguments);
if (this.shouldDisplay) {
if (this.currentUser) {
this.appEvents.on(
USER_DRAFTS_CHANGED_EVENT,
this,
@ -20,7 +20,7 @@ export default class MyPostsSectionLink extends BaseSectionLink {
}
teardown() {
if (this.shouldDisplay) {
if (this.currentUser) {
this.appEvents.off(
USER_DRAFTS_CHANGED_EVENT,
this,
@ -38,21 +38,11 @@ export default class MyPostsSectionLink extends BaseSectionLink {
}
get name() {
return "my-posts";
return "my-drafts";
}
get route() {
if (this._hasDraft) {
return "userActivity.drafts";
} else {
return "userActivity.index";
}
}
get currentWhen() {
if (this._hasDraft) {
return "userActivity.index userActivity.drafts";
}
}
get model() {
@ -60,24 +50,11 @@ export default class MyPostsSectionLink extends BaseSectionLink {
}
get title() {
if (this._hasDraft) {
return i18n("sidebar.sections.community.links.my_posts.title_drafts");
} else {
return i18n("sidebar.sections.community.links.my_posts.title");
}
return i18n("sidebar.sections.community.links.my_drafts.title");
}
get text() {
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return i18n("sidebar.sections.community.links.my_posts.content_drafts");
} else {
return i18n(
`sidebar.sections.community.links.${this.overridenName
.toLowerCase()
.replace(" ", "_")}.content`,
{ defaultValue: this.overridenName }
);
}
return i18n("sidebar.sections.community.links.my_drafts.content");
}
get badgeText() {
@ -88,7 +65,7 @@ export default class MyPostsSectionLink extends BaseSectionLink {
if (this.currentUser.new_new_view_enabled) {
return this.draftCount.toString();
} else {
return i18n("sidebar.sections.community.links.my_posts.draft_count", {
return i18n("sidebar.sections.community.links.my_drafts.draft_count", {
count: this.draftCount,
});
}
@ -98,13 +75,6 @@ export default class MyPostsSectionLink extends BaseSectionLink {
return this.draftCount > 0;
}
get defaultPrefixValue() {
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return "pencil";
}
return "user";
}
get suffixCSSClass() {
return "unread";
}
@ -120,6 +90,10 @@ export default class MyPostsSectionLink extends BaseSectionLink {
}
get shouldDisplay() {
return this.currentUser;
return this.currentUser && this._hasDraft;
}
get prefixValue() {
return "far-pen-to-square";
}
}

View File

@ -60,10 +60,6 @@ async function loadDraft(store, opts = {}) {
Draft.clear(draftKey, draftSequence);
}
if (!draft?.title && !draft?.reply) {
return;
}
let attrs = {
draftKey,
draftSequence,

View File

@ -908,7 +908,7 @@ acceptance("Composer", function (needs) {
});
await visit("/latest");
assert.dom("#create-topic").hasText(i18n("topic.open_draft"));
assert.dom("#create-topic").hasText(i18n("topic.create"));
await click("#create-topic");
assert.strictEqual(selectKit(".category-chooser").header().value(), "2");

View File

@ -503,50 +503,13 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
.doesNotExist();
});
test("clicking on my posts link", async function (assert) {
await visit("/t/280");
await click(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
);
test("clicking on my drafts link", async function (assert) {
updateCurrentUser({ draft_count: 1 });
assert.strictEqual(
currentURL(),
`/u/${loggedInUser().username}/activity`,
"should transition to the user's activity url"
);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link.active"
)
.exists({ count: 1 }, "only one link is marked as active");
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.exists("the my posts link is marked as active");
await visit(`/u/${loggedInUser().username}/activity/drafts`);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.doesNotExist(
"the my posts link is not marked as active when user has no drafts and visiting the user activity drafts URL"
);
});
test("clicking on my posts link when user has a draft", async function (assert) {
await visit("/t/280");
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
await click(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts']"
);
assert.strictEqual(
@ -563,45 +526,24 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts'].active"
)
.exists("the my posts link is marked as active");
await visit(`/u/${loggedInUser().username}/activity`);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.exists("the my posts link is marked as active");
.exists("the my drafts link is marked as active");
});
test("my posts title changes when drafts are present", async function (assert) {
test("my drafts link is visible when user has drafts", async function (assert) {
updateCurrentUser({ draft_count: 1 });
await visit("/");
assert
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasAttribute(
"title",
i18n("sidebar.sections.community.links.my_posts.title"),
"displays the default title when no drafts are present"
);
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
assert
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasAttribute(
"title",
i18n("sidebar.sections.community.links.my_posts.title_drafts"),
"displays the draft title when drafts are present"
);
.dom(".sidebar-section-link[data-link-name='my-drafts']")
.exists("my drafts link is displayed when drafts are present");
});
test("my posts changes its text when drafts are present and new new view experiment is enabled", async function (assert) {
updateCurrentUser({
draft_count: 1,
user_option: {
sidebar_show_count_of_new_items: true,
},
@ -609,28 +551,17 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
});
await visit("/");
assert
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasText(
i18n("sidebar.sections.community.links.my_posts.content"),
"displays the default text when no drafts are present"
);
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
assert
.dom(
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-text"
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-text"
)
.hasText(
i18n("sidebar.sections.community.links.my_posts.content_drafts"),
i18n("sidebar.sections.community.links.my_drafts.content"),
"displays the text that's appropriate for when drafts are present"
);
assert
.dom(
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-badge"
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-badge"
)
.hasText("1", "displays the draft count with no text");
});

View File

@ -19,6 +19,7 @@
@import "date-picker";
@import "date-time-input-range";
@import "date-time-input";
@import "drafts-dropdown-menu";
@import "file-size-input";
@import "footer-nav";
@import "form-template-field";

View File

@ -0,0 +1,24 @@
// Styles for the drafts dropdown menu
.topic-drafts-menu-trigger {
margin-left: -0.4em;
}
.topic-drafts-menu-content {
margin-top: -0.4em;
}
.topic-drafts-menu-content .dropdown-menu {
.btn .d-button-label {
@include ellipsis;
}
.view-all-drafts {
display: flex;
justify-content: space-between;
span:first-child {
color: var(--primary-high);
}
}
}

View File

@ -1,3 +1,4 @@
@import "drafts-dropdown-menu";
@import "more-topics";
@import "sidebar/edit-navigation-menu/tags-modal";
@import "sidebar/sidebar-section";

View File

@ -0,0 +1,3 @@
.topic-drafts-menu-content .dropdown-menu {
max-width: 300px;
}

View File

@ -14,9 +14,9 @@ class SidebarUrl < ActiveRecord::Base
segment: SidebarUrl.segments["primary"],
},
{
name: "My Posts",
name: "My Drafts",
path: "/my/activity",
icon: "user",
icon: "far-pen-to-square",
segment: SidebarUrl.segments["primary"],
},
{ name: "Review", path: "/review", icon: "flag", segment: SidebarUrl.segments["primary"] },

View File

@ -497,6 +497,12 @@ en:
confirm: "You already have a draft in progress. What would you like to do with it?"
yes_value: "Discard"
no_value: "Resume editing"
dropdown:
title: "Open the latest drafts menu"
view_all: "view all"
other_drafts:
one: "+%{count} other draft"
other: "+%{count} other drafts"
topic_count_all:
one: "See %{count} new topic"
@ -4973,11 +4979,9 @@ en:
users:
content: "Users"
title: "List of all users"
my_posts:
content: "My Posts"
content_drafts: "My Drafts"
title: "My recent topic activity"
title_drafts: "My unposted drafts"
my_drafts:
content: "My Drafts"
title: "My unposted drafts"
draft_count:
one: "%{count} draft"
other: "%{count} drafts"

View File

@ -24,7 +24,7 @@ RSpec.describe SidebarSection do
expect(community_section.sidebar_section_links.all.map { |link| link.linkable.name }).to eq(
[
"Topics",
"My Posts",
"My Drafts",
"Review",
"Admin",
"Invite",

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
describe "Drafts dropdown", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
let(:composer) { PageObjects::Components::Composer.new }
let(:drafts_dropdown) { PageObjects::Components::DraftsMenu.new }
let(:discard_draft_modal) { PageObjects::Modals::DiscardDraft.new }
before { sign_in(user) }
describe "with no drafts" do
it "does not display drafts dropdown" do
page.visit "/"
expect(drafts_dropdown).to be_hidden
end
it "does not have a my drafts link in sidebar" do
page.visit "/"
expect(page).to have_no_css(".sidebar-section-link[data-link-name='my-drafts']")
end
it "adds a draft dropdown menu when a draft is available" do
page.visit "/new-topic"
composer.fill_content("This is a draft")
expect(drafts_dropdown).to be_visible
end
it "shows a my drafts link in sidebar when a draft is saved" do
page.visit "/new-topic"
composer.fill_content("This is a draft")
composer.close
expect(discard_draft_modal).to be_open
discard_draft_modal.click_save
visit "/"
expect(page).to have_css(".sidebar-section-link[data-link-name='my-drafts']")
end
end
describe "with multiple drafts" do
before do
Draft.set(
user,
Draft::NEW_TOPIC,
0,
{
title: "This is a test topic",
reply: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
}.to_json,
)
5.times do |i|
topic = Fabricate(:topic, user: user)
Draft.set(user, topic.draft_key, 0, { reply: "test reply #{i}" }.to_json)
end
end
it "displays the correct draft count" do
page.visit "/"
drafts_dropdown.open
expect(drafts_dropdown).to be_open
expect(drafts_dropdown.draft_item_count).to eq(4)
expect(drafts_dropdown.other_drafts_count).to eq(2)
drafts_dropdown.find(".topic-drafts-item:first-child").click
expect(drafts_dropdown).to be_closed
expect(composer).to be_opened
composer.create
wait_for { Draft.count == 5 }
page.visit "/"
drafts_dropdown.open
expect(drafts_dropdown.draft_item_count).to eq(4)
expect(drafts_dropdown.other_drafts_count).to eq(1)
end
end
end

View File

@ -23,7 +23,7 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do
visit("/latest")
expect(sidebar.primary_section_icons("community")).to eq(
%w[layer-group user flag wrench paper-plane ellipsis-vertical],
%w[layer-group flag wrench paper-plane ellipsis-vertical],
)
modal = sidebar.click_community_section_more_button.click_customize_community_section_button
@ -32,12 +32,10 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do
modal.save
modal.confirm_update
expect(sidebar.primary_section_links("community")).to eq(
["My Posts", "Topics", "Review", "Admin", "Invite", "More"],
)
expect(sidebar.primary_section_links("community")).to eq(%w[Topics Review Admin Invite More])
expect(sidebar.primary_section_icons("community")).to eq(
%w[user paper-plane flag wrench paper-plane ellipsis-vertical],
%w[paper-plane flag wrench paper-plane ellipsis-vertical],
)
modal = sidebar.click_community_section_more_button.click_customize_community_section_button
@ -45,12 +43,10 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do
expect(sidebar).to have_section("Community")
expect(sidebar.primary_section_links("community")).to eq(
["Topics", "My Posts", "Review", "Admin", "Invite", "More"],
)
expect(sidebar.primary_section_links("community")).to eq(%w[Topics Review Admin Invite More])
expect(sidebar.primary_section_icons("community")).to eq(
%w[layer-group user flag wrench paper-plane ellipsis-vertical],
%w[layer-group flag wrench paper-plane ellipsis-vertical],
)
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
module PageObjects
module Components
class DraftsMenu < PageObjects::Components::Base
MENU_SELECTOR = ".topic-drafts-menu"
def visible?
has_css?(MENU_SELECTOR + "-trigger")
end
def hidden?
has_no_css?(MENU_SELECTOR + "-trigger")
end
def open?
has_css?(MENU_SELECTOR + "-content")
end
def closed?
has_no_css?(MENU_SELECTOR + "-content")
end
def open
find(MENU_SELECTOR + "-trigger").click
end
def draft_item_count
all(MENU_SELECTOR + "-content .topic-drafts-item").size
end
def other_drafts_count
find(MENU_SELECTOR + "-content .view-all-drafts span:first-child")["data-other-drafts"].to_i
end
end
end
end

View File

@ -15,6 +15,10 @@ module PageObjects
def click_save
footer.find("button.save-draft").click
end
def click_discard
footer.find("button.discard-draft").click
end
end
end
end

View File

@ -56,10 +56,8 @@ describe "Viewing sidebar as logged in user", type: :system do
sign_in user
visit("/latest")
links = page.all("#sidebar-section-content-community .sidebar-section-link-wrapper a")
expect(links.map(&:text)).to eq(%w[Tematy Wysłane])
expect(links.map { |link| link[:title] }).to eq(
["Wszystkie tematy", "Moja ostatnia aktywność w temacie"],
)
expect(links.map(&:text)).to eq(%w[Tematy])
expect(links.map { |link| link[:title] }).to eq(["Wszystkie tematy"])
end
end