DEV: Add compatibility with the Glimmer Post Stream (#363)

This commit is contained in:
Sérgio Saquetim 2025-05-05 15:12:39 -03:00 committed by GitHub
parent ae01ad30c3
commit 8e72136f54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 361 additions and 227 deletions

View File

@ -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);
}
<template>
<aside
class="quote accepted-answer"
data-post={{this.acceptedAnswer.post_number}}
data-topic={{this.topic.id}}
data-expanded={{this.expanded}}
>
{{! template-lint-disable no-invalid-interactive }}
<div
class={{concatClass
"title"
(unless this.hasExcerpt "title-only")
(if this.hasExcerpt "quote__title--can-toggle-content")
}}
{{on "click" this.toggleExpandedPost}}
>
<div class="accepted-answer--solver-accepter">
<div class="accepted-answer--solver">
{{this.htmlSolvedBy}}
</div>
<div class="accepted-answer--accepter">
{{this.htmlAccepter}}
</div>
</div>
{{#if this.hasExcerpt}}
<div class="quote-controls">
<button
aria-controls={{this.quoteId}}
aria-expanded={{this.expanded}}
class="quote-toggle btn-flat"
type="button"
>
{{icon
(if this.expanded "chevron-up" "chevron-down")
title="post.expand_collapse"
}}
</button>
</div>
{{/if}}
</div>
{{#if this.hasExcerpt}}
<blockquote id={{this.quoteId}}>
{{#if this.expanded}}
<AsyncContent
@asyncData={{this.loadExpandedAcceptedAnswer}}
@context={{this.acceptedAnswer.post_number}}
>
<:content as |expandedAnswer|>
<div class="expanded-quote" data-post-id={{expandedAnswer.id}}>
<PostCookedHtml
@post={{expandedAnswer}}
@streamElement={{false}}
/>
</div>
</:content>
</AsyncContent>
{{else}}
{{htmlSafe this.acceptedAnswer.excerpt}}
{{/if}}
</blockquote>
{{/if}}
</aside>
</template>
}

View File

@ -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;
}
<template><SolvedAcceptedAnswer @post={{@outletArgs.post}} /></template>
}
);
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",
<template><SolvedAcceptedAnswer @post={{@data.post}} /></template>,
{ 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");
});
},
};

View File

@ -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
? ` <blockquote> ${topic.accepted_answer.excerpt} </blockquote> `
: "";
const solvedQuote = `
<aside class='quote accepted-answer' data-post="${topic.get("accepted_answer").post_number}" data-topic="${topic.id}">
<div class='title ${hasExcerpt ? "" : "title-only"}'>
<div class="accepted-answer--solver-accepter">
<div class="accepted-answer--solver">
${topic.solvedByHtml}
<\/div>
<div class="accepted-answer--accepter">
${topic.accepterHtml}
<\/div>
</div>
<div class="quote-controls"><\/div>
</div>
${excerpt}
</aside>`;
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");
});
},
};

View File

@ -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 {

View File

@ -15,6 +15,10 @@ describe "About page", type: :system do
SiteSetting.show_who_marked_solved = true
end
%w[enabled disabled].each do |value|
before { SiteSetting.glimmer_post_stream_mode = value }
context "when glimmer_post_stream_mode=#{value}" do
it "accepts post as solution and shows in OP" do
sign_in(accepter)
@ -34,3 +38,5 @@ describe "About page", type: :system do
expect(topic_page.find("blockquote")).to have_content("The answer is 42")
end
end
end
end

View File

@ -8,7 +8,13 @@ import pretender, {
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { postStreamWithAcceptedAnswerExcerpt } from "../helpers/discourse-solved-helpers";
acceptance("Discourse Solved Plugin", function (needs) {
["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) {
@ -42,4 +48,6 @@ acceptance("Discourse Solved Plugin", function (needs) {
assert.dom(".fps-topic").exists({ count: 1 }, "has one post");
assert.dom(".topic-statuses .solved").exists("shows the right icon");
});
}
);
});