Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 39baa0b

Browse files
committed
FEATURE: Show 'marked as solved by' in OP when a topic is solved
1 parent b6c14fb commit 39baa0b

File tree

7 files changed

+256
-97
lines changed

7 files changed

+256
-97
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Component from "@glimmer/component";
2+
import { service } from "@ember/service";
3+
import { htmlSafe } from "@ember/template";
4+
import { not } from "truth-helpers";
5+
import concatClass from "discourse/helpers/concat-class";
6+
import { iconHTML } from "discourse/lib/icon-library";
7+
import { formatUsername } from "discourse/lib/utilities";
8+
import User from "discourse/models/user";
9+
import { i18n } from "discourse-i18n";
10+
11+
export default class SolvedPost extends Component {
12+
static shouldRender(args) {
13+
return args.post?.post_number === 1 && args.post?.topic?.accepted_answer;
14+
}
15+
16+
@service siteSettings;
17+
18+
get answerPostPath() {
19+
return `${this.args.outletArgs.post.topic.url}/${this.answerPostNumber}`;
20+
}
21+
22+
get acceptedAnswer() {
23+
return this.args.outletArgs.post.topic.accepted_answer;
24+
}
25+
26+
get answerPostNumber() {
27+
return this.acceptedAnswer?.post_number;
28+
}
29+
30+
get topicId() {
31+
return this.args.outletArgs.post.topic.id;
32+
}
33+
34+
get hasExcerpt() {
35+
return !!this.solvedExcerpt;
36+
}
37+
38+
get solvedExcerpt() {
39+
return this.acceptedAnswer?.excerpt;
40+
}
41+
42+
get username() {
43+
return this.acceptedAnswer?.username;
44+
}
45+
46+
get displayedUser() {
47+
const { name, username } = this.acceptedAnswer || {};
48+
return this.siteSettings.display_name_on_posts && name
49+
? name
50+
: formatUsername(username);
51+
}
52+
53+
get title() {
54+
return i18n("solved.accepted_html", {
55+
icon: iconHTML("square-check", { class: "accepted" }),
56+
username_lower: this.username?.toLowerCase(),
57+
username: this.displayedUser,
58+
post_path: this.answerPostPath,
59+
post_number: this.answerPostNumber,
60+
user_path: User.create({ username: this.username }).path,
61+
});
62+
}
63+
64+
get accepter() {
65+
const accepterUsername = this.acceptedAnswer?.accepter_username;
66+
const accepterName = this.acceptedAnswer?.accepter_name;
67+
const formattedUsername = this.siteSettings.display_name_on_posts && accepterName
68+
? accepterName
69+
: formatUsername(accepterUsername);
70+
return i18n("solved.marked_solved_by", {
71+
username: formattedUsername,
72+
username_lower: accepterUsername.toLowerCase()
73+
});
74+
}
75+
76+
<template>
77+
<div class="cooked">
78+
<aside class="quote accepted-answer"
79+
data-post={{this.answerPostNumber}}
80+
data-topic={{this.topicId}}>
81+
<div
82+
class={{concatClass "title" (unless this.hasExcerpt "title-only") }}
83+
>
84+
<div class="accepted-answer--solver">
85+
{{htmlSafe this.title}}
86+
</div>
87+
<div class="accepted-answer--accepter">
88+
{{htmlSafe this.accepter}}
89+
</div>
90+
<div class="quote-controls"></div>
91+
</div>
92+
{{#if this.hasExcerpt}}
93+
<blockquote>
94+
{{this.solvedExcerpt}}
95+
</blockquote>
96+
{{/if}}
97+
</aside>
98+
</div>
99+
</template>
100+
}

assets/javascripts/discourse/initializers/extend-for-solved-button.js

Lines changed: 3 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { computed } from "@ember/object";
21
import discourseComputed from "discourse/lib/decorators";
32
import { withSilencedDeprecations } from "discourse/lib/deprecated";
4-
import { iconHTML, iconNode } from "discourse/lib/icon-library";
3+
import { iconNode } from "discourse/lib/icon-library";
54
import { withPluginApi } from "discourse/lib/plugin-api";
6-
import { formatUsername } from "discourse/lib/utilities";
7-
import Topic from "discourse/models/topic";
8-
import User from "discourse/models/user";
9-
import PostCooked from "discourse/widgets/post-cooked";
105
import { i18n } from "discourse-i18n";
116
import SolvedAcceptAnswerButton, {
127
acceptAnswer,
138
} from "../components/solved-accept-answer-button";
9+
import SolvedPost from "../components/solved-post";
1410
import SolvedUnacceptAnswerButton, {
1511
unacceptAnswer,
1612
} from "../components/solved-unaccept-answer-button";
@@ -29,42 +25,7 @@ function initializeWithApi(api) {
2925
api.addDiscoveryQueryParam("solved", { replace: true, refreshModel: true });
3026
}
3127

32-
api.decorateWidget("post-contents:after-cooked", (dec) => {
33-
if (dec.attrs.post_number === 1) {
34-
const postModel = dec.getModel();
35-
if (postModel) {
36-
const topic = postModel.topic;
37-
if (topic.accepted_answer) {
38-
const hasExcerpt = !!topic.accepted_answer.excerpt;
39-
40-
const withExcerpt = `
41-
<aside class='quote accepted-answer' data-post="${
42-
topic.get("accepted_answer").post_number
43-
}" data-topic="${topic.id}">
44-
<div class='title'>
45-
${topic.acceptedAnswerHtml} <div class="quote-controls"><\/div>
46-
</div>
47-
<blockquote>
48-
${topic.accepted_answer.excerpt}
49-
</blockquote>
50-
</aside>`;
51-
52-
const withoutExcerpt = `
53-
<aside class='quote accepted-answer'>
54-
<div class='title title-only'>
55-
${topic.acceptedAnswerHtml}
56-
</div>
57-
</aside>`;
58-
59-
const cooked = new PostCooked(
60-
{ cooked: hasExcerpt ? withExcerpt : withoutExcerpt },
61-
dec
62-
);
63-
return dec.rawHtml(cooked.init());
64-
}
65-
}
66-
}
67-
});
28+
api.renderBeforeWrapperOutlet("post-menu", SolvedPost);
6829

6930
api.attachWidgetAction("post", "acceptAnswer", function () {
7031
acceptAnswer(this.model, this.appEvents);
@@ -171,33 +132,6 @@ function customizeWidgetPostMenu(api) {
171132
export default {
172133
name: "extend-for-solved-button",
173134
initialize() {
174-
Topic.reopen({
175-
// keeping this here cause there is complex localization
176-
acceptedAnswerHtml: computed("accepted_answer", "id", function () {
177-
const username = this.get("accepted_answer.username");
178-
const name = this.get("accepted_answer.name");
179-
const postNumber = this.get("accepted_answer.post_number");
180-
181-
if (!username || !postNumber) {
182-
return "";
183-
}
184-
185-
const displayedUser =
186-
this.siteSettings.display_name_on_posts && name
187-
? name
188-
: formatUsername(username);
189-
190-
return i18n("solved.accepted_html", {
191-
icon: iconHTML("square-check", { class: "accepted" }),
192-
username_lower: username.toLowerCase(),
193-
username: displayedUser,
194-
post_path: `${this.url}/${postNumber}`,
195-
post_number: postNumber,
196-
user_path: User.create({ username }).path,
197-
});
198-
}),
199-
});
200-
201135
withPluginApi("2.0.0", (api) => {
202136
withSilencedDeprecations("discourse.hbr-topic-list-overrides", () => {
203137
let topicStatusIcons;

assets/stylesheets/solutions.scss

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,37 @@ $solved-color: green;
6262
font-size: 13px;
6363
}
6464

65-
aside.quote .title.title-only {
66-
padding: 12px;
65+
aside.quote.accepted-answer {
66+
.title {
67+
display: flex;
68+
flex-wrap: wrap;
69+
70+
&.title-only {
71+
padding: 12px;
72+
}
73+
}
74+
75+
.accepted-answer--solver {
76+
margin-right: auto;
77+
}
78+
79+
.accepted-answer--accepter {
80+
font-size: var(--font-down-1);
81+
margin-left: auto;
82+
margin-top: auto;
83+
}
84+
85+
@media screen and (max-width: 768px) {
86+
.accepted-answer--accepter {
87+
width: 100%;
88+
margin-top: 0.25em;
89+
order: 3;
90+
}
91+
92+
.quote-controls {
93+
order: 2;
94+
}
95+
}
6796
}
6897

6998
.user-card-metadata-outlet.accepted-answers {

config/locales/client.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ en:
3333
no_solved_topics_title: "You haven’t solved any topics yet"
3434
no_solved_topics_title_others: "%{username} has not solved any topics yet"
3535
no_solved_topics_body: "When you provide a helpful reply to a topic, your reply might be selected as the solution by the topic owner or staff."
36+
marked_solved_by: "Marked as solved by <a href data-user-card='%{username_lower}'>%{username}</a></span>"
3637

3738
no_answer:
3839
title: Has your question been answered?

lib/discourse_solved/topic_view_serializer_extension.rb

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,46 @@ module DiscourseSolved::TopicViewSerializerExtension
66
prepended { attributes :accepted_answer }
77

88
def include_accepted_answer?
9-
SiteSetting.solved_enabled? && accepted_answer_post_id
9+
SiteSetting.solved_enabled? && object.topic.solved.present?
1010
end
1111

1212
def accepted_answer
13-
if info = accepted_answer_post_info
14-
{ post_number: info[0], username: info[1], excerpt: info[2], name: info[3] }
15-
end
13+
accepted_answer_post_info
1614
end
1715

1816
private
1917

2018
def accepted_answer_post_info
21-
post_info =
22-
if post = object.posts.find { |p| p.post_number == accepted_answer_post_id }
23-
[post.post_number, post.user.username, post.cooked, post.user.name]
24-
else
25-
Post
26-
.where(id: accepted_answer_post_id, topic_id: object.topic.id)
27-
.joins(:user)
28-
.pluck("post_number", "username", "cooked", "name")
29-
.first
30-
end
31-
32-
if post_info
33-
post_info[2] = if SiteSetting.solved_quote_length > 0
34-
PrettyText.excerpt(post_info[2], SiteSetting.solved_quote_length, keep_emoji_images: true)
19+
solved = object.topic.solved
20+
answer_post = solved.answer_post
21+
answer_post_user = answer_post.user
22+
accepter = solved.accepter
23+
24+
excerpt =
25+
if SiteSetting.solved_quote_length > 0
26+
PrettyText.excerpt(
27+
answer_post.cooked,
28+
SiteSetting.solved_quote_length,
29+
keep_emoji_images: true,
30+
)
3531
else
3632
nil
3733
end
3834

39-
post_info[3] = nil if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts
40-
41-
post_info
35+
accepted_answer = {
36+
post_number: answer_post.post_number,
37+
username: answer_post_user.username,
38+
name: answer_post_user.name,
39+
accepter_username: accepter.username,
40+
accepter_name: accepter.name,
41+
excerpt:,
42+
}
43+
44+
if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts
45+
accepted_answer[:name] = nil
46+
accepted_answer[:accepter_name] = nil
4247
end
43-
end
4448

45-
def accepted_answer_post_id
46-
object.topic.solved&.answer_post_id
49+
accepted_answer
4750
end
4851
end

spec/requests/topics_controller_spec.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,21 @@ def schema_json(answerCount)
6565

6666
it "should include user name in output with the corresponding site setting" do
6767
SiteSetting.display_name_on_posts = true
68-
Fabricate(:solved_topic, topic: topic, answer_post: p2)
68+
accepter = Fabricate(:user)
69+
Fabricate(:solved_topic, topic: topic, answer_post: p2, accepter:)
6970

7071
get "/t/#{topic.slug}/#{topic.id}.json"
7172

7273
expect(response.parsed_body["accepted_answer"]["name"]).to eq(p2.user.name)
7374
expect(response.parsed_body["accepted_answer"]["username"]).to eq(p2.user.username)
75+
expect(response.parsed_body["accepted_answer"]["accepter_name"]).to eq(accepter.name)
76+
expect(response.parsed_body["accepted_answer"]["accepter_username"]).to eq(accepter.username)
7477

7578
# enable_names is default ON, this ensures disabling it also disables names here
7679
SiteSetting.enable_names = false
7780
get "/t/#{topic.slug}/#{topic.id}.json"
7881
expect(response.parsed_body["accepted_answer"]["name"]).to eq(nil)
79-
expect(response.parsed_body["accepted_answer"]["username"]).to eq(p2.user.username)
82+
expect(response.parsed_body["accepted_answer"]["accepter_name"]).to eq(nil)
8083
end
8184

8285
it "should not include user name when site setting is disabled" do

0 commit comments

Comments
 (0)