From 8e72136f54fc6fff4b6301a21271eb75e7bf703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= <1108771+megothss@users.noreply.github.com> Date: Mon, 5 May 2025 15:12:39 -0300 Subject: [PATCH] DEV: Add compatibility with the Glimmer Post Stream (#363) --- .../components/solved-accepted-answer.gjs | 166 ++++++++++++++++ .../initializers/extend-for-solved-button.gjs | 136 +++++++++++++ .../initializers/extend-for-solved-button.js | 186 ------------------ assets/stylesheets/solutions.scss | 6 +- spec/system/solved_spec.rb | 32 +-- .../acceptance/discourse-solved-test.js | 62 +++--- 6 files changed, 361 insertions(+), 227 deletions(-) create mode 100644 assets/javascripts/discourse/components/solved-accepted-answer.gjs create mode 100644 assets/javascripts/discourse/initializers/extend-for-solved-button.gjs delete mode 100644 assets/javascripts/discourse/initializers/extend-for-solved-button.js diff --git a/assets/javascripts/discourse/components/solved-accepted-answer.gjs b/assets/javascripts/discourse/components/solved-accepted-answer.gjs new file mode 100644 index 0000000..fcafef5 --- /dev/null +++ b/assets/javascripts/discourse/components/solved-accepted-answer.gjs @@ -0,0 +1,166 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import AsyncContent from "discourse/components/async-content"; +import PostCookedHtml from "discourse/components/post/cooked-html"; +import concatClass from "discourse/helpers/concat-class"; +import icon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { iconHTML } from "discourse/lib/icon-library"; +import { formatUsername } from "discourse/lib/utilities"; +import { i18n } from "discourse-i18n"; + +export default class SolvedAcceptedAnswer extends Component { + @service siteSettings; + @service store; + + @tracked expanded = false; + + get acceptedAnswer() { + return this.topic.accepted_answer; + } + + get quoteId() { + return `accepted-answer-${this.topic.id}-${this.acceptedAnswer.post_number}`; + } + + get topic() { + return this.args.post.topic; + } + + get hasExcerpt() { + return !!this.acceptedAnswer.excerpt; + } + + get htmlAccepter() { + const username = this.acceptedAnswer.accepter_username; + const name = this.acceptedAnswer.accepter_name; + + if (!this.siteSettings.show_who_marked_solved) { + return; + } + + const formattedUsername = + this.siteSettings.display_name_on_posts && name + ? name + : formatUsername(username); + + return htmlSafe( + i18n("solved.marked_solved_by", { + username: formattedUsername, + username_lower: username.toLowerCase(), + }) + ); + } + + get htmlSolvedBy() { + const username = this.acceptedAnswer.username; + const name = this.acceptedAnswer.name; + const postNumber = this.acceptedAnswer.post_number; + + if (!username || !postNumber) { + return; + } + + const displayedUser = + this.siteSettings.display_name_on_posts && name + ? name + : formatUsername(username); + + const data = { + icon: iconHTML("square-check", { class: "accepted" }), + username_lower: username.toLowerCase(), + username: displayedUser, + post_path: `${this.topic.url}/${postNumber}`, + post_number: postNumber, + user_path: this.store.createRecord("user", { username }).path, + }; + + return htmlSafe(i18n("solved.accepted_html", data)); + } + + @action + toggleExpandedPost() { + if (!this.hasExcerpt) { + return; + } + + this.expanded = !this.expanded; + } + + @action + async loadExpandedAcceptedAnswer(postNumber) { + const acceptedAnswer = await ajax( + `/posts/by_number/${this.topic.id}/${postNumber}` + ); + + return this.store.createRecord("post", acceptedAnswer); + } + + +} diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs new file mode 100644 index 0000000..207d73f --- /dev/null +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -0,0 +1,136 @@ +import Component from "@glimmer/component"; +import { computed } from "@ember/object"; +import { withSilencedDeprecations } from "discourse/lib/deprecated"; +import { iconHTML } from "discourse/lib/icon-library"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { formatUsername } from "discourse/lib/utilities"; +import Topic from "discourse/models/topic"; +import User from "discourse/models/user"; +import PostCooked from "discourse/widgets/post-cooked"; +import RenderGlimmer from "discourse/widgets/render-glimmer"; +import { i18n } from "discourse-i18n"; +import SolvedAcceptAnswerButton from "../components/solved-accept-answer-button"; +import SolvedAcceptedAnswer from "../components/solved-accepted-answer"; +import SolvedUnacceptAnswerButton from "../components/solved-unaccept-answer-button"; + +function initializeWithApi(api) { + customizePost(api); + customizePostMenu(api); + + if (api.addDiscoveryQueryParam) { + api.addDiscoveryQueryParam("solved", { replace: true, refreshModel: true }); + } +} + +function customizePost(api) { + api.addTrackedPostProperties( + "can_accept_answer", + "can_unaccept_answer", + "accepted_answer", + "topic_accepted_answer" + ); + + api.renderAfterWrapperOutlet( + "post-content-cooked-html", + class extends Component { + static shouldRender(args) { + return args.post.post_number === 1 && args.post.topic.accepted_answer; + } + + + } + ); + + withSilencedDeprecations("discourse.post-stream-widget-overrides", () => + customizeWidgetPost(api) + ); +} + +function customizeWidgetPost(api) { + api.decorateWidget("post-contents:after-cooked", (helper) => { + let post = helper.getModel(); + + if (helper.attrs.post_number === 1 && post?.topic?.accepted_answer) { + return new RenderGlimmer( + helper.widget, + "div", + , + { post } + ); + } + }); +} + +function customizePostMenu(api) { + api.registerValueTransformer( + "post-menu-buttons", + ({ + value: dag, + context: { + post, + firstButtonKey, + secondLastHiddenButtonKey, + lastHiddenButtonKey, + }, + }) => { + let solvedButton; + + if (post.can_accept_answer) { + solvedButton = SolvedAcceptAnswerButton; + } else if (post.accepted_answer) { + solvedButton = SolvedUnacceptAnswerButton; + } + + solvedButton && + dag.add( + "solved", + solvedButton, + post.topic_accepted_answer && !post.accepted_answer + ? { + before: lastHiddenButtonKey, + after: secondLastHiddenButtonKey, + } + : { + before: [ + "assign", // button added by the assign plugin + firstButtonKey, + ], + } + ); + } + ); +} + +export default { + name: "extend-for-solved-button", + initialize() { + withPluginApi("1.34.0", initializeWithApi); + + withPluginApi("0.8.10", (api) => { + api.replaceIcon( + "notification.solved.accepted_notification", + "square-check" + ); + }); + + withPluginApi("0.11.0", (api) => { + api.addAdvancedSearchOptions({ + statusOptions: [ + { + name: i18n("search.advanced.statuses.solved"), + value: "solved", + }, + { + name: i18n("search.advanced.statuses.unsolved"), + value: "unsolved", + }, + ], + }); + }); + + withPluginApi("0.11.7", (api) => { + api.addSearchSuggestion("status:solved"); + api.addSearchSuggestion("status:unsolved"); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.js b/assets/javascripts/discourse/initializers/extend-for-solved-button.js deleted file mode 100644 index fff26d8..0000000 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.js +++ /dev/null @@ -1,186 +0,0 @@ -import { computed } from "@ember/object"; -import { iconHTML } from "discourse/lib/icon-library"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { formatUsername } from "discourse/lib/utilities"; -import Topic from "discourse/models/topic"; -import User from "discourse/models/user"; -import PostCooked from "discourse/widgets/post-cooked"; -import { i18n } from "discourse-i18n"; -import SolvedAcceptAnswerButton, { - acceptAnswer, -} from "../components/solved-accept-answer-button"; -import SolvedUnacceptAnswerButton, { - unacceptAnswer, -} from "../components/solved-unaccept-answer-button"; - -function initializeWithApi(api) { - customizePostMenu(api); - - api.includePostAttributes( - "can_accept_answer", - "can_unaccept_answer", - "accepted_answer", - "topic_accepted_answer" - ); - - if (api.addDiscoveryQueryParam) { - api.addDiscoveryQueryParam("solved", { replace: true, refreshModel: true }); - } - - api.decorateWidget("post-contents:after-cooked", (dec) => { - if (dec.attrs.post_number === 1) { - const postModel = dec.getModel(); - if (postModel) { - const topic = postModel.topic; - if (topic.accepted_answer) { - const hasExcerpt = !!topic.accepted_answer.excerpt; - const excerpt = hasExcerpt - ? `
${topic.accepted_answer.excerpt}
` - : ""; - const solvedQuote = ` - `; - - const cooked = new PostCooked({ cooked: solvedQuote }, dec); - return dec.rawHtml(cooked.init()); - } - } - } - }); - - api.attachWidgetAction("post", "acceptAnswer", function () { - acceptAnswer(this.model, this.appEvents, this.currentUser); - }); - - api.attachWidgetAction("post", "unacceptAnswer", function () { - unacceptAnswer(this.model, this.appEvents); - }); -} - -function customizePostMenu(api) { - api.registerValueTransformer( - "post-menu-buttons", - ({ - value: dag, - context: { - post, - firstButtonKey, - secondLastHiddenButtonKey, - lastHiddenButtonKey, - }, - }) => { - let solvedButton; - - if (post.can_accept_answer) { - solvedButton = SolvedAcceptAnswerButton; - } else if (post.accepted_answer) { - solvedButton = SolvedUnacceptAnswerButton; - } - - solvedButton && - dag.add( - "solved", - solvedButton, - post.topic_accepted_answer && !post.accepted_answer - ? { - before: lastHiddenButtonKey, - after: secondLastHiddenButtonKey, - } - : { - before: [ - "assign", // button added by the assign plugin - firstButtonKey, - ], - } - ); - } - ); -} - -export default { - name: "extend-for-solved-button", - initialize() { - Topic.reopen({ - // keeping this here cause there is complex localization - solvedByHtml: computed("accepted_answer", "id", function () { - const username = this.get("accepted_answer.username"); - const name = this.get("accepted_answer.name"); - const postNumber = this.get("accepted_answer.post_number"); - - if (!username || !postNumber) { - return ""; - } - - const displayedUser = - this.siteSettings.display_name_on_posts && name - ? name - : formatUsername(username); - - return i18n("solved.accepted_html", { - icon: iconHTML("square-check", { class: "accepted" }), - username_lower: username.toLowerCase(), - username: displayedUser, - post_path: `${this.url}/${postNumber}`, - post_number: postNumber, - user_path: User.create({ username }).path, - }); - }), - accepterHtml: computed("accepted_answer", function () { - const username = this.get("accepted_answer.accepter_username"); - const name = this.get("accepted_answer.accepter_name"); - if (!this.siteSettings.show_who_marked_solved) { - return ""; - } - const formattedUsername = - this.siteSettings.display_name_on_posts && name - ? name - : formatUsername(username); - return i18n("solved.marked_solved_by", { - username: formattedUsername, - username_lower: username.toLowerCase(), - }); - }), - }); - - withPluginApi("1.34.0", initializeWithApi); - - withPluginApi("0.8.10", (api) => { - api.replaceIcon( - "notification.solved.accepted_notification", - "square-check" - ); - }); - - withPluginApi("0.11.0", (api) => { - api.addAdvancedSearchOptions({ - statusOptions: [ - { - name: i18n("search.advanced.statuses.solved"), - value: "solved", - }, - { - name: i18n("search.advanced.statuses.unsolved"), - value: "unsolved", - }, - ], - }); - }); - - withPluginApi("0.11.7", (api) => { - api.addSearchSuggestion("status:solved"); - api.addSearchSuggestion("status:unsolved"); - }); - }, -}; diff --git a/assets/stylesheets/solutions.scss b/assets/stylesheets/solutions.scss index 7859860..c0b3e18 100644 --- a/assets/stylesheets/solutions.scss +++ b/assets/stylesheets/solutions.scss @@ -58,10 +58,14 @@ $solved-color: var(--success); } aside.quote.accepted-answer { - .title { + > .title { display: flex; justify-content: space-between; align-items: flex-start; + + &.quote__title--can-toggle-content { + cursor: pointer; + } } .accepted-answer--solver-accepter { diff --git a/spec/system/solved_spec.rb b/spec/system/solved_spec.rb index 39ed81d..dc17f7a 100644 --- a/spec/system/solved_spec.rb +++ b/spec/system/solved_spec.rb @@ -15,22 +15,28 @@ describe "About page", type: :system do SiteSetting.show_who_marked_solved = true end - it "accepts post as solution and shows in OP" do - sign_in(accepter) + %w[enabled disabled].each do |value| + before { SiteSetting.glimmer_post_stream_mode = value } - topic_page.visit_topic(topic, post_number: 2) + context "when glimmer_post_stream_mode=#{value}" do + it "accepts post as solution and shows in OP" do + sign_in(accepter) - expect(topic_page).to have_css(".post-action-menu__solved-unaccepted") + topic_page.visit_topic(topic, post_number: 2) - find(".post-action-menu__solved-unaccepted").click + expect(topic_page).to have_css(".post-action-menu__solved-unaccepted") - expect(topic_page).to have_css(".post-action-menu__solved-accepted") - expect(topic_page.find(".title .accepted-answer--solver")).to have_content( - "Solved by #{solver.username}", - ) - expect(topic_page.find(".title .accepted-answer--accepter")).to have_content( - "Marked as solved by #{accepter.username}", - ) - expect(topic_page.find("blockquote")).to have_content("The answer is 42") + find(".post-action-menu__solved-unaccepted").click + + expect(topic_page).to have_css(".post-action-menu__solved-accepted") + expect(topic_page.find(".title .accepted-answer--solver")).to have_content( + "Solved by #{solver.username}", + ) + expect(topic_page.find(".title .accepted-answer--accepter")).to have_content( + "Marked as solved by #{accepter.username}", + ) + expect(topic_page.find("blockquote")).to have_content("The answer is 42") + end + end end end diff --git a/test/javascripts/acceptance/discourse-solved-test.js b/test/javascripts/acceptance/discourse-solved-test.js index 2c62034..48401c4 100644 --- a/test/javascripts/acceptance/discourse-solved-test.js +++ b/test/javascripts/acceptance/discourse-solved-test.js @@ -8,38 +8,46 @@ import pretender, { import { acceptance } from "discourse/tests/helpers/qunit-helpers"; import { postStreamWithAcceptedAnswerExcerpt } from "../helpers/discourse-solved-helpers"; -acceptance("Discourse Solved Plugin", function (needs) { - needs.user(); +["enabled", "disabled"].forEach((postStreamMode) => { + acceptance( + `Discourse Solved Plugin (glimmer_post_stream_mode = ${postStreamMode})`, + function (needs) { + needs.settings({ + glimmer_post_stream_mode: postStreamMode, + }); + needs.user(); - test("A topic with an accepted answer shows an excerpt of the answer, if provided", async function (assert) { - pretender.get("/t/11.json", () => - response(postStreamWithAcceptedAnswerExcerpt("this is an excerpt")) - ); + test("A topic with an accepted answer shows an excerpt of the answer, if provided", async function (assert) { + pretender.get("/t/11.json", () => + response(postStreamWithAcceptedAnswerExcerpt("this is an excerpt")) + ); - pretender.get("/t/12.json", () => - response(postStreamWithAcceptedAnswerExcerpt(null)) - ); + pretender.get("/t/12.json", () => + response(postStreamWithAcceptedAnswerExcerpt(null)) + ); - await visit("/t/with-excerpt/11"); - assert.dom(".quote blockquote").hasText("this is an excerpt"); + await visit("/t/with-excerpt/11"); + assert.dom(".quote blockquote").hasText("this is an excerpt"); - await visit("/t/without-excerpt/12"); - assert.dom(".quote blockquote").doesNotExist(); - assert.dom(".quote .title.title-only").exists(); - }); + await visit("/t/without-excerpt/12"); + assert.dom(".quote blockquote").doesNotExist(); + assert.dom(".quote .title.title-only").exists(); + }); - test("Full page search displays solved status", async function (assert) { - pretender.get("/search", () => { - const fixtures = cloneJSON(fixturesByUrl["/search.json"]); - fixtures.topics[0].has_accepted_answer = true; - return response(fixtures); - }); + test("Full page search displays solved status", async function (assert) { + pretender.get("/search", () => { + const fixtures = cloneJSON(fixturesByUrl["/search.json"]); + fixtures.topics[0].has_accepted_answer = true; + return response(fixtures); + }); - await visit("/search"); - await fillIn(".search-query", "discourse"); - await click(".search-cta"); + await visit("/search"); + await fillIn(".search-query", "discourse"); + await click(".search-cta"); - assert.dom(".fps-topic").exists({ count: 1 }, "has one post"); - assert.dom(".topic-statuses .solved").exists("shows the right icon"); - }); + assert.dom(".fps-topic").exists({ count: 1 }, "has one post"); + assert.dom(".topic-statuses .solved").exists("shows the right icon"); + }); + } + ); });