diff --git a/assets/javascripts/discourse/components/topic-level-assign-menu.js b/assets/javascripts/discourse/components/topic-level-assign-menu.js new file mode 100644 index 0000000..adc1d56 --- /dev/null +++ b/assets/javascripts/discourse/components/topic-level-assign-menu.js @@ -0,0 +1,227 @@ +import { getOwner } from "@ember/application"; +import { htmlSafe } from "@ember/template"; +import { renderAvatar } from "discourse/helpers/user-avatar"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import I18n from "I18n"; + +const DEPENDENT_KEYS = [ + "topic.assigned_to_user", + "topic.assigned_to_group", + "currentUser.can_assign", + "topic.assigned_to_user.username", +]; + +export default { + id: "reassign", + dependentKeys: DEPENDENT_KEYS, + classNames: ["reassign"], + + async action(id) { + if (!this.currentUser?.can_assign) { + return; + } + + const taskActions = getOwner(this).lookup("service:task-actions"); + const firstPostId = this.topic.postStream.firstPostId; + + switch (id) { + case "unassign": { + this.set("topic.assigned_to_user", null); + this.set("topic.assigned_to_group", null); + + await taskActions.unassign(this.topic.id); + this.appEvents.trigger("post-stream:refresh", { id: firstPostId }); + break; + } + case "reassign-self": { + this.set("topic.assigned_to_user", null); + this.set("topic.assigned_to_group", null); + + await taskActions.reassignUserToTopic(this.currentUser, this.topic); + this.appEvents.trigger("post-stream:refresh", { id: firstPostId }); + break; + } + case "reassign": { + await taskActions.showAssignModal(this.topic, { + targetType: "Topic", + isAssigned: this.topic.isAssigned(), + onSuccess: () => + this.appEvents.trigger("post-stream:refresh", { id: firstPostId }), + }); + break; + } + default: { + if (id.startsWith("unassign-from-post-")) { + const postId = extractPostId(id); + await taskActions.unassign(postId, "Post"); + delete this.topic.indirectly_assigned_to[postId]; + this.appEvents.trigger("post-stream:refresh", { id: firstPostId }); + } + } + } + }, + + noneItem() { + const topic = this.topic; + + if (topic.assigned_to_user || topic.hasAssignedPosts()) { + return unassignUsersButton(topic.uniqueAssignees()); + } else if (topic.assigned_to_group) { + return unassignGroupButton(topic.assigned_to_group); + } + }, + content() { + const content = []; + + if (this.topic.isAssigned()) { + content.push(unassignFromTopicButton(this.topic)); + } + + if (this.topic.hasAssignedPosts()) { + content.push(...unassignFromPostButtons(this.topic)); + } + + if (this.topic.isAssigned() && !this.topic.isAssignedTo(this.currentUser)) { + content.push(reassignToSelfButton()); + } + + content.push(editAssignmentsButton()); + + return content; + }, + + displayed() { + return ( + this.currentUser?.can_assign && + !this.site.mobileView && + (this.topic.isAssigned() || this.topic.hasAssignedPosts()) + ); + }, +}; + +function unassignGroupButton(group) { + const label = I18n.t("discourse_assign.unassign.title"); + return { + id: null, + name: I18n.t("discourse_assign.reassign_modal.title"), + label: htmlSafe( + `${label} @${group.name}...` + ), + }; +} + +function unassignUsersButton(users) { + let avatars = ""; + if (users.length === 1) { + avatars = avatarHtml(users[0], "tiny"); + } else if (users.length > 1) { + avatars = + avatarHtml(users[0], "tiny", "overlap") + avatarHtml(users[1], "tiny"); + } + + const label = `${I18n.t( + "discourse_assign.topic_level_menu.unassign_with_ellipsis" + )}`; + + return { + id: null, + name: htmlSafe( + I18n.t("discourse_assign.topic_level_menu.unassign_with_ellipsis") + ), + label: htmlSafe(`${avatars}${label}`), + }; +} + +function avatarHtml(user, size, classes) { + return renderAvatar(user, { + imageSize: size, + extraClasses: classes, + ignoreTitle: true, + }); +} + +function extractPostId(buttonId) { + // buttonId format is "unassign-from-post-${postId}" + const start = buttonId.lastIndexOf("-") + 1; + return buttonId.substring(start); +} + +function editAssignmentsButton() { + const icon = iconHTML("pencil-alt"); + const label = I18n.t("discourse_assign.topic_level_menu.edit_assignments"); + return { + id: "reassign", + name: htmlSafe(label), + label: htmlSafe(`${icon} ${label}`), + }; +} + +function reassignToSelfButton() { + const icon = iconHTML("user-plus"); + const label = I18n.t( + "discourse_assign.topic_level_menu.reassign_topic_to_me" + ); + return { + id: "reassign-self", + name: htmlSafe(label), + label: htmlSafe(`${icon} ${label}`), + }; +} + +function unassignFromTopicButton(topic) { + const username = + topic.assigned_to_user?.username || topic.assigned_to_group?.name; + const icon = topic.assigned_to_user + ? avatarHtml(topic.assigned_to_user, "small") + : iconHTML("user-times"); + const label = I18n.t( + "discourse_assign.topic_level_menu.unassign_from_topic", + { username } + ); + + return { + id: "unassign", + name: htmlSafe(label), + label: htmlSafe(`${icon} ${label}`), + }; +} + +function unassignFromPostButtons(topic) { + if (!topic.hasAssignedPosts()) { + return []; + } + + const max_buttons = 10; + return Object.entries(topic.indirectly_assigned_to) + .slice(0, max_buttons) + .map(([postId, assignment]) => unassignFromPostButton(postId, assignment)); +} + +function unassignFromPostButton(postId, assignment) { + let assignee, icon; + const assignedToUser = !!assignment.assigned_to.username; + if (assignedToUser) { + assignee = assignment.assigned_to.username; + icon = avatarHtml(assignment.assigned_to, "small"); + } else { + assignee = assignment.assigned_to.name; + icon = iconHTML("group-times"); + } + + const label = I18n.t("discourse_assign.topic_level_menu.unassign_from_post", { + assignee, + post_number: assignment.post_number, + }); + const dataName = I18n.t( + "discourse_assign.topic_level_menu.unassign_from_post_help", + { + assignee, + post_number: assignment.post_number, + } + ); + return { + id: `unassign-from-post-${postId}`, + name: htmlSafe(dataName), + label: htmlSafe(`${icon} ${label}`), + }; +} diff --git a/assets/javascripts/discourse/initializers/extend-for-assigns.js b/assets/javascripts/discourse/initializers/extend-for-assigns.js index 77d6890..dcde7df 100644 --- a/assets/javascripts/discourse/initializers/extend-for-assigns.js +++ b/assets/javascripts/discourse/initializers/extend-for-assigns.js @@ -16,6 +16,7 @@ import discourseComputed from "discourse-common/utils/decorators"; import I18n from "I18n"; import BulkAssign from "../components/bulk-actions/assign-user"; import BulkActionsAssignUser from "../components/bulk-actions/bulk-assign-user"; +import TopicLevelAssignMenu from "../components/topic-level-assign-menu"; const PLUGIN_ID = "discourse-assign"; @@ -37,142 +38,52 @@ function defaultTitle(topic) { } } -function includeIsAssignedOnTopic(api) { +function extendTopicModel(api) { api.modifyClass("model:topic", { pluginId: PLUGIN_ID, + + assignees() { + const result = []; + + if (this.assigned_to_user) { + result.push(this.assigned_to_user); + } + + const postAssignees = this.assignedPosts().map((p) => p.assigned_to); + result.push(...postAssignees); + return result; + }, + + uniqueAssignees() { + const map = new Map(); + this.assignees().forEach((user) => map.set(user.username, user)); + return [...map.values()]; + }, + + assignedPosts() { + if (!this.indirectly_assigned_to) { + return []; + } + + return Object.values(this.indirectly_assigned_to); + }, + isAssigned() { return this.assigned_to_user || this.assigned_to_group; }, + + isAssignedTo(user) { + return this.assigned_to_user?.username === user.username; + }, + + hasAssignedPosts() { + return !!this.assignedPosts().length; + }, }); } function registerTopicFooterButtons(api) { - registerTopicFooterDropdown({ - id: "reassign", - - async action(id) { - if (!this.currentUser?.can_assign) { - return; - } - - const taskActions = getOwner(this).lookup("service:task-actions"); - - switch (id) { - case "unassign": { - this.set("topic.assigned_to_user", null); - this.set("topic.assigned_to_group", null); - - await taskActions.unassign(this.topic.id); - - this.appEvents.trigger("post-stream:refresh", { - id: this.topic.postStream.firstPostId, - }); - break; - } - case "reassign-self": { - this.set("topic.assigned_to_user", null); - this.set("topic.assigned_to_group", null); - - await taskActions.reassignUserToTopic(this.currentUser, this.topic); - - this.appEvents.trigger("post-stream:refresh", { - id: this.topic.postStream.firstPostId, - }); - break; - } - case "reassign": { - await taskActions.showAssignModal(this.topic, { - targetType: "Topic", - isAssigned: this.topic.isAssigned(), - onSuccess: () => - this.appEvents.trigger("post-stream:refresh", { - id: this.topic.postStream.firstPostId, - }), - }); - break; - } - } - }, - - noneItem() { - const user = this.topic.assigned_to_user; - const group = this.topic.assigned_to_group; - const label = I18n.t("discourse_assign.unassign.title_w_ellipsis"); - const groupLabel = I18n.t("discourse_assign.unassign.title"); - - if (user) { - return { - id: null, - name: I18n.t("discourse_assign.reassign_modal.title"), - label: htmlSafe( - `${renderAvatar(user, { - imageSize: "tiny", - ignoreTitle: true, - })}${label}` - ), - }; - } else if (group) { - return { - id: null, - name: I18n.t("discourse_assign.reassign_modal.title"), - label: htmlSafe( - `${groupLabel} @${group.name}...` - ), - }; - } - }, - dependentKeys: DEPENDENT_KEYS, - classNames: ["reassign"], - content() { - const content = [ - { - id: "unassign", - name: I18n.t("discourse_assign.unassign.help", { - username: - this.topic.assigned_to_user?.username || - this.topic.assigned_to_group?.name, - }), - label: htmlSafe( - `${iconHTML("user-times")} ${I18n.t( - "discourse_assign.unassign.title" - )}` - ), - }, - ]; - if ( - this.topic.isAssigned() && - this.topic.assigned_to_user?.username !== this.currentUser.username - ) { - content.push({ - id: "reassign-self", - name: I18n.t("discourse_assign.reassign.to_self_help"), - label: htmlSafe( - `${iconHTML("user-plus")} ${I18n.t( - "discourse_assign.reassign.to_self" - )}` - ), - }); - } - content.push({ - id: "reassign", - name: I18n.t("discourse_assign.reassign.help"), - label: htmlSafe( - `${iconHTML("group-plus")} ${I18n.t( - "discourse_assign.reassign.title_w_ellipsis" - )}` - ), - }); - return content; - }, - - displayed() { - return ( - this.currentUser?.can_assign && - !this.site.mobileView && - this.topic.isAssigned() - ); - }, - }); + registerTopicFooterDropdown(TopicLevelAssignMenu); api.registerTopicFooterButton({ id: "assign", @@ -224,7 +135,11 @@ function registerTopicFooterButtons(api) { classNames: ["assign"], dependentKeys: DEPENDENT_KEYS, displayed() { - return this.currentUser?.can_assign && !this.topic.isAssigned(); + return ( + this.currentUser?.can_assign && + !this.topic.isAssigned() && + !this.topic.hasAssignedPosts() + ); }, }); @@ -918,7 +833,7 @@ export default { } withPluginApi("0.13.0", (api) => { - includeIsAssignedOnTopic(api); + extendTopicModel(api); initialize(api); registerTopicFooterButtons(api); diff --git a/assets/stylesheets/assigns.scss b/assets/stylesheets/assigns.scss index be0519e..ffd7d4d 100644 --- a/assets/stylesheets/assigns.scss +++ b/assets/stylesheets/assigns.scss @@ -174,10 +174,19 @@ width: 1.33em; height: 1.33em; } + .avatar.overlap { + z-index: 1; + margin-right: -0.4em; + } + .d-icon, i.fa { color: var(--primary-medium); } + .d-icon { + margin-left: 0.165em; + margin-right: 0.165em; + } } // Group assigns sidebar nav diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b079061..8905016 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -57,6 +57,13 @@ en: help: "Edit assignment details" reassign_modal: title: "Reassign Topic" + topic_level_menu: + edit_assignments: "Edit assignments..." + reassign_topic_to_me: "Reassign topic to me" + unassign_with_ellipsis: "Unassign..." + unassign_from_post: "Unassign @%{assignee} from #%{post_number}" + unassign_from_post_help: "Unassign @%{assignee} from post #%{post_number}" + unassign_from_topic: "Unassign @%{username} from topic" assign_modal: title: "Assign Topic" reassign_title: "Reassign Topic" diff --git a/test/javascripts/acceptance/post-popup-menu-test.js b/test/javascripts/acceptance/post-popup-menu-test.js index 10e7f60..4dd6637 100644 --- a/test/javascripts/acceptance/post-popup-menu-test.js +++ b/test/javascripts/acceptance/post-popup-menu-test.js @@ -1,33 +1,14 @@ import { click, fillIn, visit } from "@ember/test-helpers"; import { test } from "qunit"; -import topicFixtures from "discourse/tests/fixtures/topic"; import { acceptance, publishToMessageBus, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; -import { cloneJSON } from "discourse-common/lib/object"; +import topicWithAssignedPost from "../fixtures/topic-with-assigned-post"; -const username = "eviltrout"; const new_assignee_username = "new_assignee"; -function topicWithAssignedPostResponse() { - const topic = cloneJSON(topicFixtures["/t/28830/1.json"]); - const secondPost = topic.post_stream.posts[1]; - - topic["indirectly_assigned_to"] = { - [secondPost.id]: { - assigned_to: { - username, - }, - post_number: 1, - }, - }; - secondPost["assigned_to_user"] = { username }; - - return topic; -} - const selectors = { assignedTo: ".post-stream article#post_2 .assigned-to", moreButton: ".post-stream .topic-post .more-button", @@ -42,7 +23,7 @@ const selectors = { }, }; -const topic = topicWithAssignedPostResponse(); +const topic = topicWithAssignedPost(); const post = topic.post_stream.posts[1]; acceptance("Discourse Assign | Post popup menu", function (needs) { @@ -111,7 +92,7 @@ acceptance("Discourse Assign | Post popup menu", function (needs) { }, }); - // todo: we can skip this one for now, I can fix it in a core PR + // todo: we can skip this one for now, It will be fixed it in a core PR // assert.dom(".popup-menu").doesNotExist("The popup menu is closed"); assert diff --git a/test/javascripts/acceptance/topic-level-assign-menu-test.js b/test/javascripts/acceptance/topic-level-assign-menu-test.js new file mode 100644 index 0000000..d57ca3d --- /dev/null +++ b/test/javascripts/acceptance/topic-level-assign-menu-test.js @@ -0,0 +1,49 @@ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { + acceptance, + publishToMessageBus, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import topicWithAssignedPost from "../fixtures/topic-with-assigned-post"; + +const topic = topicWithAssignedPost(); +const post = topic.post_stream.posts[1]; + +acceptance("Discourse Assign | Topic level assign menu", function (needs) { + needs.user(); + needs.settings({ + assign_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/t/44.json", () => helper.response(topic)); + server.put("/assign/unassign", () => { + return helper.response({ success: true }); + }); + }); + + needs.hooks.beforeEach(() => { + updateCurrentUser({ can_assign: true }); + }); + + test("Unassign button unassigns the post", async function (assert) { + await visit("/t/assignment-topic/44"); + + await click("#topic-footer-dropdown-reassign .btn"); + await click(`li[data-value='unassign-from-post-${post.id}']`); + await publishToMessageBus("/staff/topic-assignment", { + type: "unassigned", + topic_id: topic.id, + post_id: post.id, + assigned_type: "User", + }); + + assert + .dom("#topic-footer-dropdown-reassign-body ul[role='menu']") + .doesNotExist("The menu is closed"); + assert + .dom(".post-stream article#post_2 .assigned-to") + .doesNotExist("The post is unassigned"); + }); +}); diff --git a/test/javascripts/fixtures/topic-with-assigned-post.js b/test/javascripts/fixtures/topic-with-assigned-post.js new file mode 100644 index 0000000..278585a --- /dev/null +++ b/test/javascripts/fixtures/topic-with-assigned-post.js @@ -0,0 +1,20 @@ +import topicFixtures from "discourse/tests/fixtures/topic"; +import { cloneJSON } from "discourse-common/lib/object"; + +export default function topicWithAssignedPost() { + const username = "eviltrout"; + const topic = cloneJSON(topicFixtures["/t/28830/1.json"]); + const secondPost = topic.post_stream.posts[1]; + + topic["indirectly_assigned_to"] = { + [secondPost.id]: { + assigned_to: { + username, + }, + post_number: 1, + }, + }; + secondPost["assigned_to_user"] = { username }; + + return topic; +}