From 855745b4f8119ec17b944f465b557d6558c471ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 20 Jun 2025 18:45:07 -0300 Subject: [PATCH 01/11] DEV: Use the `PostQuotedContent` component to render the accepted answer Refactored the `solved-accepted-answer` component to utilize the `PostQuotedContent` component, simplifying the structure and removing redundant code. Updated system tests to verify behavior of expandable accepted answer quotes. Added new compatibility entry for version `< 3.5.0.beta7-dev` and included TODO comments regarding event handling for Glimmer Post Stream. --- .discourse-compatibility | 1 + .../solved-accept-answer-button.gjs | 1 + .../components/solved-accepted-answer.gjs | 137 +++++------------- .../solved-unaccept-answer-button.gjs | 1 + .../initializers/extend-for-solved-button.gjs | 7 +- spec/system/solved_spec.rb | 6 + 6 files changed, 55 insertions(+), 98 deletions(-) diff --git a/.discourse-compatibility b/.discourse-compatibility index afd28417..1f323689 100644 --- a/.discourse-compatibility +++ b/.discourse-compatibility @@ -1,3 +1,4 @@ +< 3.5.0.beta7-dev: 03804e1065d6a3afa7e5f6060e6f342eb2b94374 < 3.5.0.beta5-dev: a8c534f11832d6bb8590ce5001119654fe6f335f < 3.5.0.beta3-dev: 4f0234f5be3aaa77db277e0f224cd9750d2713cd < 3.5.0.beta2-dev: e82c6ae1ca38ccebb34669148f8de93a3028906e diff --git a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs index d402a6b5..b7147a2f 100644 --- a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs @@ -27,6 +27,7 @@ export default class SolvedAcceptAnswerButton extends Component { post.get("topic.postStream.posts").forEach((p) => { p.set("topic_accepted_answer", true); + // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event this.appEvents.trigger("post-stream:refresh", { id: p.id }); }); } diff --git a/assets/javascripts/discourse/components/solved-accepted-answer.gjs b/assets/javascripts/discourse/components/solved-accepted-answer.gjs index fcafef58..73edaa67 100644 --- a/assets/javascripts/discourse/components/solved-accepted-answer.gjs +++ b/assets/javascripts/discourse/components/solved-accepted-answer.gjs @@ -1,14 +1,7 @@ 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 PostQuotedContent from "discourse/components/post/quoted-content"; import { iconHTML } from "discourse/lib/icon-library"; import { formatUsername } from "discourse/lib/utilities"; import { i18n } from "discourse-i18n"; @@ -17,7 +10,9 @@ export default class SolvedAcceptedAnswer extends Component { @service siteSettings; @service store; - @tracked expanded = false; + get topic() { + return this.args.post.topic; + } get acceptedAnswer() { return this.topic.accepted_answer; @@ -27,49 +22,45 @@ export default class SolvedAcceptedAnswer extends Component { 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; + get collapsedContent() { + if (!this.hasExcerpt) { + return null; + } + return htmlSafe(this.acceptedAnswer.excerpt); + } + + get htmlAccepter() { if (!this.siteSettings.show_who_marked_solved) { return; } - const formattedUsername = - this.siteSettings.display_name_on_posts && name - ? name - : formatUsername(username); + const { accepter_username, accepter_name } = this.acceptedAnswer; + const displayName = this.#getDisplayName(accepter_username, accepter_name); + + if (!displayName) { + return; + } return htmlSafe( i18n("solved.marked_solved_by", { - username: formattedUsername, - username_lower: username.toLowerCase(), + username: displayName, + username_lower: accepter_username.toLowerCase(), }) ); } get htmlSolvedBy() { - const username = this.acceptedAnswer.username; - const name = this.acceptedAnswer.name; - const postNumber = this.acceptedAnswer.post_number; - + const { username, name, post_number: postNumber } = this.acceptedAnswer; if (!username || !postNumber) { return; } - const displayedUser = - this.siteSettings.display_name_on_posts && name - ? name - : formatUsername(username); - + const displayedUser = this.#getDisplayName(username, name); const data = { icon: iconHTML("square-check", { class: "accepted" }), username_lower: username.toLowerCase(), @@ -82,40 +73,28 @@ export default class SolvedAcceptedAnswer extends Component { return htmlSafe(i18n("solved.accepted_html", data)); } - @action - toggleExpandedPost() { - if (!this.hasExcerpt) { - return; + #getDisplayName(username, name) { + if (!username) { + return null; } - 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); + return this.siteSettings.display_name_on_posts && name + ? name + : formatUsername(username); } } diff --git a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs index 3c47156e..afc261d5 100644 --- a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs @@ -44,6 +44,7 @@ export default class SolvedUnacceptAnswerButton extends Component { post.get("topic.postStream.posts").forEach((p) => { p.set("topic_accepted_answer", false); + // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event this.appEvents.trigger("post-stream:refresh", { id: p.id }); }); } diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs index cd732e11..8b2362d2 100644 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -31,7 +31,12 @@ function customizePost(api) { return args.post.post_number === 1 && args.post.topic.accepted_answer; } - + } ); diff --git a/spec/system/solved_spec.rb b/spec/system/solved_spec.rb index dc17f7a9..32de5601 100644 --- a/spec/system/solved_spec.rb +++ b/spec/system/solved_spec.rb @@ -29,6 +29,8 @@ find(".post-action-menu__solved-unaccepted").click expect(topic_page).to have_css(".post-action-menu__solved-accepted") + + expect(topic_page).to have_css("aside.accepted-answer.quote[data-expanded='false']") expect(topic_page.find(".title .accepted-answer--solver")).to have_content( "Solved by #{solver.username}", ) @@ -36,6 +38,10 @@ "Marked as solved by #{accepter.username}", ) expect(topic_page.find("blockquote")).to have_content("The answer is 42") + + # ensure the quoted post can be expanded + topic_page.find("aside.accepted-answer.quote button.quote-toggle").click + expect(topic_page).to have_css("aside.accepted-answer.quote[data-expanded='true']") end end end From f03f64a33c85bb242ddf10e98c9fd406374710fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 20 Jun 2025 19:07:35 -0300 Subject: [PATCH 02/11] DEV: Refactor topic model to use tracked properties for accepted answer logic Refactored the `topic` model to replace legacy Ember property access (`get/set`) with modern tracked properties. Updated related logic in `add-topic-list-class` and `solved-unaccept-answer-button` to align with these changes. --- .../components/solved-unaccept-answer-button.gjs | 2 +- .../discourse/initializers/add-topic-list-class.js | 2 +- .../initializers/extend-for-solved-button.gjs | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs index afc261d5..f021426c 100644 --- a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs @@ -22,7 +22,7 @@ function unacceptPost(post) { accepted_answer: false, }); - topic.set("accepted_answer", undefined); + topic.accepted_answer = undefined; ajax("/solution/unaccept", { type: "POST", diff --git a/assets/javascripts/discourse/initializers/add-topic-list-class.js b/assets/javascripts/discourse/initializers/add-topic-list-class.js index d857f7a9..1a1fc0a0 100644 --- a/assets/javascripts/discourse/initializers/add-topic-list-class.js +++ b/assets/javascripts/discourse/initializers/add-topic-list-class.js @@ -8,7 +8,7 @@ export default { api.registerValueTransformer( "topic-list-item-class", ({ value, context }) => { - if (context.topic.get("has_accepted_answer")) { + if (context.topic.has_accepted_answer) { value.push("status-solved"); } return value; diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs index 8b2362d2..ca027770 100644 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -1,4 +1,5 @@ import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; import { withSilencedDeprecations } from "discourse/lib/deprecated"; import { withPluginApi } from "discourse/lib/plugin-api"; import RenderGlimmer from "discourse/widgets/render-glimmer"; @@ -24,6 +25,15 @@ function customizePost(api) { "topic_accepted_answer" ); + api.modifyClass( + "model:topic", + (Superclass) => + class extends Superclass { + @tracked accepted_answer; + @tracked has_accepted_answer; + } + ); + api.renderAfterWrapperOutlet( "post-content-cooked-html", class extends Component { From 436ee57801cf312e0fbda442f4b57a2d5d806915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Fri, 20 Jun 2025 19:07:55 -0300 Subject: [PATCH 03/11] DEV: Refactor spec to improve readability of `accepted answer` tests Refactored `solved_spec.rb` to make `accepted answer` assertions more explicit and structured. Consolidated selector handling and clarified expectation of the expanded quote behavior. --- spec/system/solved_spec.rb | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/spec/system/solved_spec.rb b/spec/system/solved_spec.rb index 32de5601..f3c7af58 100644 --- a/spec/system/solved_spec.rb +++ b/spec/system/solved_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - describe "About page", type: :system do fab!(:admin) fab!(:solver) { Fabricate(:user) } @@ -16,32 +15,31 @@ end %w[enabled disabled].each do |value| - before { SiteSetting.glimmer_post_stream_mode = value } - context "when glimmer_post_stream_mode=#{value}" do + before { SiteSetting.glimmer_post_stream_mode = value } + it "accepts post as solution and shows in OP" do sign_in(accepter) - topic_page.visit_topic(topic, post_number: 2) expect(topic_page).to have_css(".post-action-menu__solved-unaccepted") - find(".post-action-menu__solved-unaccepted").click expect(topic_page).to have_css(".post-action-menu__solved-accepted") - expect(topic_page).to have_css("aside.accepted-answer.quote[data-expanded='false']") + accepted_answer_quote = topic_page.find("aside.accepted-answer.quote") + expect(accepted_answer_quote["data-expanded"]).to eq("false") + expect(accepted_answer_quote.find("blockquote")).to have_content("The answer is 42") + 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") - # ensure the quoted post can be expanded - topic_page.find("aside.accepted-answer.quote button.quote-toggle").click - expect(topic_page).to have_css("aside.accepted-answer.quote[data-expanded='true']") + accepted_answer_quote.find("button.quote-toggle").click + expect(accepted_answer_quote["data-expanded"]).to eq("true") end end end From 5aed0b41c55b222f185c0b5c7cd4305a4ba26a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 24 Jun 2025 01:08:02 -0300 Subject: [PATCH 04/11] FEATURE: Real-time updates for accepted and unaccepted solutions Introduce real-time message bus updates for accepted and unaccepted solutions, ensuring live synchronization across users. Key changes: - Publish solution acceptance/unacceptance updates via MessageBus. - Refactor `accepted_answer_post_info` and related logic for cleaner handling of accepted answer data. - Update both backend and frontend to support reactive updates when solutions are toggled. - Add loading states for Accept/Unaccept buttons to enhance UX during async operations. --- .../discourse_solved/answer_controller.rb | 4 +- .../solved-accept-answer-button.gjs | 63 ++++++--------- .../solved-unaccept-answer-button.gjs | 78 ++++++++++--------- .../initializers/extend-for-solved-button.gjs | 70 +++++++++++++---- lib/discourse_solved/guardian_extensions.rb | 4 +- lib/discourse_solved/topic_extension.rb | 39 ++++++++++ .../topic_view_serializer_extension.rb | 41 +--------- plugin.rb | 13 +++- 8 files changed, 177 insertions(+), 135 deletions(-) diff --git a/app/controllers/discourse_solved/answer_controller.rb b/app/controllers/discourse_solved/answer_controller.rb index 0ca76546..67ac86d2 100644 --- a/app/controllers/discourse_solved/answer_controller.rb +++ b/app/controllers/discourse_solved/answer_controller.rb @@ -13,9 +13,9 @@ def accept guardian.ensure_can_accept_answer!(topic, post) - DiscourseSolved.accept_answer!(post, current_user, topic: topic) + accepted_answer = DiscourseSolved.accept_answer!(post, current_user, topic: topic) - render json: success_json + render_json_dump(accepted_answer) end def unaccept diff --git a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs index b7147a2f..c205e4c0 100644 --- a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs @@ -1,4 +1,5 @@ import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import { service } from "@ember/service"; import DButton from "discourse/components/d-button"; @@ -13,6 +14,8 @@ export default class SolvedAcceptAnswerButton extends Component { @service appEvents; @service currentUser; + @tracked saving = false; + get showLabel() { return this.currentUser?.id === this.args.post.topicCreatedById; } @@ -21,13 +24,17 @@ export default class SolvedAcceptAnswerButton extends Component { acceptAnswer() { const post = this.args.post; - acceptPost(post, this.currentUser); + this.saving = true; + try { + acceptPost(post, this.currentUser); + } finally { + this.saving = false; + } this.appEvents.trigger("discourse-solved:solution-toggled", post); + // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event post.get("topic.postStream.posts").forEach((p) => { - p.set("topic_accepted_answer", true); - // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event this.appEvents.trigger("post-stream:refresh", { id: p.id }); }); } @@ -37,6 +44,7 @@ export default class SolvedAcceptAnswerButton extends Component { class="post-action-menu__solved-unaccepted unaccepted" ...attributes @action={{this.acceptAnswer}} + @disabled={{this.saving}} @icon="far-square-check" @label={{if this.showLabel "solved.solution"}} @title="solved.accept_answer" @@ -44,42 +52,21 @@ export default class SolvedAcceptAnswerButton extends Component { } -function acceptPost(post, acceptingUser) { - const topic = post.topic; - - clearAccepted(topic); - - post.setProperties({ - can_unaccept_answer: true, - can_accept_answer: false, - accepted_answer: true, - }); +async function acceptPost(post) { + if (!post.can_accept_answer || post.accepted_answer) { + return; + } - topic.set("accepted_answer", { - username: post.username, - name: post.name, - post_number: post.post_number, - excerpt: post.cooked, - accepter_username: acceptingUser.username, - accepter_name: acceptingUser.name, - }); + const topic = post.topic; - ajax("/solution/accept", { - type: "POST", - data: { id: post.id }, - }).catch(popupAjaxError); -} + try { + const acceptedAnswer = await ajax("/solution/accept", { + type: "POST", + data: { id: post.id }, + }); -function clearAccepted(topic) { - const posts = topic.get("postStream.posts"); - posts.forEach((post) => { - if (post.get("post_number") > 1) { - post.setProperties({ - accepted_answer: false, - can_accept_answer: true, - can_unaccept_answer: false, - topic_accepted_answer: false, - }); - } - }); + topic.setAcceptedSolution(acceptedAnswer); + } catch (e) { + popupAjaxError(e); + } } diff --git a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs index f021426c..e662728a 100644 --- a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs @@ -1,7 +1,9 @@ import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import { service } from "@ember/service"; import { htmlSafe } from "@ember/template"; +import { and, not } from "truth-helpers"; import DButton from "discourse/components/d-button"; import icon from "discourse/helpers/d-icon"; import { ajax } from "discourse/lib/ajax"; @@ -10,44 +12,11 @@ import { formatUsername } from "discourse/lib/utilities"; import { i18n } from "discourse-i18n"; import DTooltip from "float-kit/components/d-tooltip"; -function unacceptPost(post) { - if (!post.can_unaccept_answer) { - return; - } - const topic = post.topic; - - post.setProperties({ - can_accept_answer: true, - can_unaccept_answer: false, - accepted_answer: false, - }); - - topic.accepted_answer = undefined; - - ajax("/solution/unaccept", { - type: "POST", - data: { id: post.id }, - }).catch(popupAjaxError); -} - export default class SolvedUnacceptAnswerButton extends Component { @service appEvents; @service siteSettings; - @action - unacceptAnswer() { - const post = this.args.post; - - unacceptPost(post); - - this.appEvents.trigger("discourse-solved:solution-toggled", post); - - post.get("topic.postStream.posts").forEach((p) => { - p.set("topic_accepted_answer", false); - // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event - this.appEvents.trigger("post-stream:refresh", { id: p.id }); - }); - } + @tracked saving = false; get solvedBy() { if (!this.siteSettings.show_who_marked_solved) { @@ -68,9 +37,28 @@ export default class SolvedUnacceptAnswerButton extends Component { } } + @action + async unacceptAnswer() { + const post = this.args.post; + + this.saving = true; + try { + await unacceptPost(post); + } finally { + this.saving = false; + } + + this.appEvents.trigger("discourse-solved:solution-toggled", post); + + // TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event + post.get("topic.postStream.posts").forEach((p) => { + this.appEvents.trigger("post-stream:refresh", { id: p.id }); + }); + } + } + +async function unacceptPost(post) { + if (!post.can_accept_answer || !post.accepted_answer) { + return; + } + + const topic = post.topic; + + try { + await ajax("/solution/unaccept", { + type: "POST", + data: { id: post.id }, + }); + + topic.setAcceptedSolution(undefined); + } catch (e) { + popupAjaxError(e); + } +} diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs index ca027770..7241a7c6 100644 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -11,19 +11,11 @@ import SolvedUnacceptAnswerButton from "../components/solved-unaccept-answer-but function initializeWithApi(api) { customizePost(api); customizePostMenu(api); + handleMessages(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.modifyClass( "model:topic", @@ -31,8 +23,41 @@ function customizePost(api) { class extends Superclass { @tracked accepted_answer; @tracked has_accepted_answer; + + setAcceptedSolution(acceptedAnswer) { + this.postStream?.posts?.forEach((post) => { + if (!acceptedAnswer) { + post.setProperties({ + accepted_answer: false, + topic_accepted_answer: false, + }); + } else if (post.post_number > 1) { + post.setProperties( + acceptedAnswer.post_number === post.post_number + ? { + accepted_answer: true, + topic_accepted_answer: true, + } + : { + accepted_answer: false, + topic_accepted_answer: true, + } + ); + } + }); + + this.accepted_answer = acceptedAnswer; + } } ); +} + +function customizePost(api) { + api.addTrackedPostProperties( + "can_accept_answer", + "accepted_answer", + "topic_accepted_answer" + ); api.renderAfterWrapperOutlet( "post-content-cooked-html", @@ -84,10 +109,10 @@ function customizePostMenu(api) { }) => { let solvedButton; - if (post.can_accept_answer) { - solvedButton = SolvedAcceptAnswerButton; - } else if (post.accepted_answer) { + if (post.accepted_answer) { solvedButton = SolvedUnacceptAnswerButton; + } else if (post.can_accept_answer) { + solvedButton = SolvedAcceptAnswerButton; } solvedButton && @@ -110,19 +135,32 @@ function customizePostMenu(api) { ); } +function handleMessages(api) { + const handleMessages = async (controller, message) => { + const topic = controller.model; + + if (topic) { + topic.setAcceptedSolution(message.accepted_answer); + } + }; + + api.registerCustomPostMessageCallback("accepted_solution", handleMessages); + api.registerCustomPostMessageCallback("unaccepted_solution", handleMessages); +} + export default { name: "extend-for-solved-button", initialize() { - withPluginApi("1.34.0", initializeWithApi); + withPluginApi(initializeWithApi); - withPluginApi("0.8.10", (api) => { + withPluginApi((api) => { api.replaceIcon( "notification.solved.accepted_notification", "square-check" ); }); - withPluginApi("0.11.0", (api) => { + withPluginApi((api) => { api.addAdvancedSearchOptions({ statusOptions: [ { @@ -137,7 +175,7 @@ export default { }); }); - withPluginApi("0.11.7", (api) => { + withPluginApi((api) => { api.addSearchSuggestion("status:solved"); api.addSearchSuggestion("status:unsolved"); }); diff --git a/lib/discourse_solved/guardian_extensions.rb b/lib/discourse_solved/guardian_extensions.rb index 9f96b441..6ea7fec9 100644 --- a/lib/discourse_solved/guardian_extensions.rb +++ b/lib/discourse_solved/guardian_extensions.rb @@ -21,7 +21,9 @@ def allow_accepted_answers?(category_id, tag_names = []) def can_accept_answer?(topic, post) return false if !authenticated? - return false if !topic || topic.private_message? || !post || post.whisper? + if !topic || topic.private_message? || !post || post.post_number <= 1 || post.whisper? + return false + end return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name)) return true if is_staff? diff --git a/lib/discourse_solved/topic_extension.rb b/lib/discourse_solved/topic_extension.rb index cfd6757c..e9020a3e 100644 --- a/lib/discourse_solved/topic_extension.rb +++ b/lib/discourse_solved/topic_extension.rb @@ -4,4 +4,43 @@ module DiscourseSolved::TopicExtension extend ActiveSupport::Concern prepended { has_one :solved, class_name: "DiscourseSolved::SolvedTopic", dependent: :destroy } + + def accepted_answer_post_info + return nil unless solved + + answer_post = solved.answer_post + + answer_post_user = answer_post.user + accepter = solved.accepter + + excerpt = + if SiteSetting.solved_quote_length > 0 + PrettyText.excerpt( + answer_post.cooked, + SiteSetting.solved_quote_length, + keep_emoji_images: true, + ) + else + nil + end + + accepted_answer = { + post_number: answer_post.post_number, + username: answer_post_user.username, + name: answer_post_user.name, + excerpt:, + } + + if SiteSetting.show_who_marked_solved + accepted_answer[:accepter_name] = accepter.name + accepted_answer[:accepter_username] = accepter.username + end + + if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts + accepted_answer[:name] = nil + accepted_answer[:accepter_name] = nil + end + + accepted_answer + end end diff --git a/lib/discourse_solved/topic_view_serializer_extension.rb b/lib/discourse_solved/topic_view_serializer_extension.rb index cadfb49c..d771af82 100644 --- a/lib/discourse_solved/topic_view_serializer_extension.rb +++ b/lib/discourse_solved/topic_view_serializer_extension.rb @@ -11,45 +11,6 @@ def include_accepted_answer? end def accepted_answer - accepted_answer_post_info - end - - private - - def accepted_answer_post_info - solved = object.topic.solved - answer_post = solved.answer_post - answer_post_user = answer_post.user - accepter = solved.accepter - - excerpt = - if SiteSetting.solved_quote_length > 0 - PrettyText.excerpt( - answer_post.cooked, - SiteSetting.solved_quote_length, - keep_emoji_images: true, - ) - else - nil - end - - accepted_answer = { - post_number: answer_post.post_number, - username: answer_post_user.username, - name: answer_post_user.name, - excerpt:, - } - - if SiteSetting.show_who_marked_solved - accepted_answer[:accepter_name] = accepter.name - accepted_answer[:accepter_username] = accepter.username - end - - if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts - accepted_answer[:name] = nil - accepted_answer[:accepter_name] = nil - end - - accepted_answer + object.topic.accepted_answer_post_info end end diff --git a/plugin.rb b/plugin.rb index c37e9844..1358fac2 100644 --- a/plugin.rb +++ b/plugin.rb @@ -112,7 +112,14 @@ def self.accept_answer!(post, acting_user, topic: nil) WebHook.enqueue_solved_hooks(:accepted_solution, post, payload) end + accepted_answer = topic.reload.accepted_answer_post_info + + message = { type: :accepted_solution, accepted_answer: } + DiscourseEvent.trigger(:accepted_solution, post) + MessageBus.publish("/topic/#{topic.id}", message) + + accepted_answer end end @@ -140,7 +147,9 @@ def self.unaccept_answer!(post, topic: nil) payload = WebHook.generate_payload(:post, post) WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload) end + DiscourseEvent.trigger(:unaccepted_solution, post) + MessageBus.publish("/topic/#{topic.id}", type: :unaccepted_solution) end end @@ -252,9 +261,7 @@ def self.skip_db? .count end add_to_serializer(:user_summary, :solved_count) { object.solved_count } - add_to_serializer(:post, :can_accept_answer) do - scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer - end + add_to_serializer(:post, :can_accept_answer) { scope.can_accept_answer?(topic, object) } add_to_serializer(:post, :can_unaccept_answer) do scope.can_accept_answer?(topic, object) && accepted_answer end From ea194b1f4f4e6e4755d6e5bfffb342f28690ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 24 Jun 2025 01:28:59 -0300 Subject: [PATCH 05/11] DEV: Update tests to use `topic_with_op` fabrication for topic consistency This ensures tests work with topics that include an original post by default, improving test reliability and coverage. Adjustments were made to guardian_extensions_spec and solved_spec to reflect this. --- spec/integration/solved_spec.rb | 4 ++-- spec/lib/guardian_extensions_spec.rb | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/integration/solved_spec.rb b/spec/integration/solved_spec.rb index 9a65f764..a6769a22 100644 --- a/spec/integration/solved_spec.rb +++ b/spec/integration/solved_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe "Managing Posts solved status" do - let(:topic) { Fabricate(:topic) } + let(:topic) { Fabricate(:topic_with_op) } fab!(:user) { Fabricate(:trust_level_4) } let(:p1) { Fabricate(:post, topic: topic) } @@ -238,7 +238,7 @@ it "gives priority to category's solved_topics_auto_close_hours setting" do freeze_time custom_auto_close_category = Fabricate(:category) - topic_2 = Fabricate(:topic, category: custom_auto_close_category) + topic_2 = Fabricate(:topic_with_op, category: custom_auto_close_category) post_2 = Fabricate(:post, topic: topic_2) custom_auto_close_category.custom_fields["solved_topics_auto_close_hours"] = 4 custom_auto_close_category.save_custom_fields diff --git a/spec/lib/guardian_extensions_spec.rb b/spec/lib/guardian_extensions_spec.rb index a3354b50..1ac6f995 100644 --- a/spec/lib/guardian_extensions_spec.rb +++ b/spec/lib/guardian_extensions_spec.rb @@ -5,7 +5,7 @@ describe DiscourseSolved::GuardianExtensions do fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } fab!(:other_user) { Fabricate(:user, refresh_auto_groups: true) } - fab!(:topic) + fab!(:topic) { Fabricate(:topic_with_op) } fab!(:post) { Fabricate(:post, topic: topic, user: other_user) } let(:guardian) { user.guardian } @@ -17,9 +17,10 @@ expect(Guardian.new.can_accept_answer?(topic, post)).to eq(false) end - it "returns false if the topic is nil, the post is nil, or for whispers" do + it "returns false if the topic is nil, the post is nil, for the first post or for whispers" do expect(guardian.can_accept_answer?(nil, post)).to eq(false) expect(guardian.can_accept_answer?(topic, nil)).to eq(false) + expect(guardian.can_accept_answer?(topic, topic.first_post)).to eq(false) post.update!(post_type: Post.types[:whisper]) expect(guardian.can_accept_answer?(topic, post)).to eq(false) From 25920252f39a4ab174d2b8ea71ac5ce60d4716d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 24 Jun 2025 01:31:09 -0300 Subject: [PATCH 06/11] Fix linting issues --- .../discourse/components/solved-unaccept-answer-button.gjs | 2 +- .../discourse/initializers/extend-for-solved-button.gjs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs index e662728a..6c4d6fb0 100644 --- a/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-unaccept-answer-button.gjs @@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import { service } from "@ember/service"; import { htmlSafe } from "@ember/template"; -import { and, not } from "truth-helpers"; +import { and } from "truth-helpers"; import DButton from "discourse/components/d-button"; import icon from "discourse/helpers/d-icon"; import { ajax } from "discourse/lib/ajax"; diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs index 7241a7c6..c94c2c6b 100644 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -136,7 +136,7 @@ function customizePostMenu(api) { } function handleMessages(api) { - const handleMessages = async (controller, message) => { + const callback = async (controller, message) => { const topic = controller.model; if (topic) { @@ -144,8 +144,8 @@ function handleMessages(api) { } }; - api.registerCustomPostMessageCallback("accepted_solution", handleMessages); - api.registerCustomPostMessageCallback("unaccepted_solution", handleMessages); + api.registerCustomPostMessageCallback("accepted_solution", callback); + api.registerCustomPostMessageCallback("unaccepted_solution", callback); } export default { From 0365e44a3352e0f9b2bf0d45baf1dddd5a5d1d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= Date: Tue, 24 Jun 2025 16:21:40 -0300 Subject: [PATCH 07/11] FIx failing tests --- .../solved-accept-answer-button.gjs | 4 +- .../components/solved-accepted-answer.gjs | 46 +++++++++++-------- .../initializers/extend-for-solved-button.gjs | 4 +- assets/stylesheets/solutions.scss | 4 -- spec/system/solved_spec.rb | 19 ++++++++ .../discourse-solved-post-menu-test.js | 6 ++- .../acceptance/discourse-solved-test.js | 5 +- .../helpers/discourse-solved-helpers.js | 3 +- 8 files changed, 59 insertions(+), 32 deletions(-) diff --git a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs index c205e4c0..7a107bef 100644 --- a/assets/javascripts/discourse/components/solved-accept-answer-button.gjs +++ b/assets/javascripts/discourse/components/solved-accept-answer-button.gjs @@ -21,12 +21,12 @@ export default class SolvedAcceptAnswerButton extends Component { } @action - acceptAnswer() { + async acceptAnswer() { const post = this.args.post; this.saving = true; try { - acceptPost(post, this.currentUser); + await acceptPost(post, this.currentUser); } finally { this.saving = false; } diff --git a/assets/javascripts/discourse/components/solved-accepted-answer.gjs b/assets/javascripts/discourse/components/solved-accepted-answer.gjs index 73edaa67..4903b1c0 100644 --- a/assets/javascripts/discourse/components/solved-accepted-answer.gjs +++ b/assets/javascripts/discourse/components/solved-accepted-answer.gjs @@ -2,6 +2,7 @@ import Component from "@glimmer/component"; import { service } from "@ember/service"; import { htmlSafe } from "@ember/template"; import PostQuotedContent from "discourse/components/post/quoted-content"; +import concatClass from "discourse/helpers/concat-class"; import { iconHTML } from "discourse/lib/icon-library"; import { formatUsername } from "discourse/lib/utilities"; import { i18n } from "discourse-i18n"; @@ -84,26 +85,31 @@ export default class SolvedAcceptedAnswer extends Component { } } diff --git a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs index c94c2c6b..840a6244 100644 --- a/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs +++ b/assets/javascripts/discourse/initializers/extend-for-solved-button.gjs @@ -63,7 +63,9 @@ function customizePost(api) { "post-content-cooked-html", class extends Component { static shouldRender(args) { - return args.post.post_number === 1 && args.post.topic.accepted_answer; + return ( + args.post?.post_number === 1 && args.post?.topic?.accepted_answer + ); }