mirror of
https://github.com/discourse/discourse-solved.git
synced 2025-07-02 03:42:12 +00:00
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.
This commit is contained in:
parent
436ee57801
commit
5aed0b41c5
@ -13,9 +13,9 @@ class DiscourseSolved::AnswerController < ::ApplicationController
|
||||
|
||||
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
|
||||
|
@ -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 {
|
||||
</template>
|
||||
}
|
||||
|
||||
function acceptPost(post, acceptingUser) {
|
||||
async function acceptPost(post) {
|
||||
if (!post.can_accept_answer || post.accepted_answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = post.topic;
|
||||
|
||||
clearAccepted(topic);
|
||||
try {
|
||||
const acceptedAnswer = await ajax("/solution/accept", {
|
||||
type: "POST",
|
||||
data: { id: post.id },
|
||||
});
|
||||
|
||||
post.setProperties({
|
||||
can_unaccept_answer: true,
|
||||
can_accept_answer: false,
|
||||
accepted_answer: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
ajax("/solution/accept", {
|
||||
type: "POST",
|
||||
data: { id: post.id },
|
||||
}).catch(popupAjaxError);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<span class="extra-buttons">
|
||||
{{#if @post.can_unaccept_answer}}
|
||||
{{#if (and @post.can_accept_answer @post.accepted_answer)}}
|
||||
{{#if this.solvedBy}}
|
||||
<DTooltip @identifier="post-action-menu__solved-accepted-tooltip">
|
||||
<:trigger>
|
||||
@ -92,6 +80,7 @@ export default class SolvedUnacceptAnswerButton extends Component {
|
||||
class="post-action-menu__solved-accepted accepted fade-out"
|
||||
...attributes
|
||||
@action={{this.unacceptAnswer}}
|
||||
@disabled={{this.saving}}
|
||||
@icon="square-check"
|
||||
@label="solved.solution"
|
||||
@title="solved.unaccept_answer"
|
||||
@ -111,3 +100,22 @@ export default class SolvedUnacceptAnswerButton extends Component {
|
||||
</span>
|
||||
</template>
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
});
|
||||
|
@ -21,7 +21,9 @@ module DiscourseSolved
|
||||
|
||||
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?
|
||||
|
@ -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
|
||||
|
@ -11,45 +11,6 @@ module DiscourseSolved::TopicViewSerializerExtension
|
||||
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
|
||||
|
13
plugin.rb
13
plugin.rb
@ -112,7 +112,14 @@ after_initialize do
|
||||
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 @@ after_initialize do
|
||||
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 @@ after_initialize do
|
||||
.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
|
||||
|
Loading…
x
Reference in New Issue
Block a user