FIX: Use SolvedTopics to list posts in /activity/solved instead of user actions

This commit is contained in:
Nat 2025-06-26 17:29:32 +08:00
parent cee0ffc199
commit ec76a37e9f
No known key found for this signature in database
GPG Key ID: 4938B35D927EC773
6 changed files with 262 additions and 7 deletions

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class DiscourseSolved::SolvedTopicsController < ::ApplicationController
requires_plugin DiscourseSolved::PLUGIN_NAME
def by_user
params.permit(:username)
user =
fetch_user_from_params(
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
)
raise Discourse::NotFound unless guardian.public_can_see_profiles?
raise Discourse::NotFound unless guardian.can_see_profile?(user)
offset = [0, params[:offset].to_i].max
limit = params.fetch(:limit, 30).to_i
posts =
Post
.joins(
"INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id",
)
.joins(:topic)
.where(user_id: user.id, deleted_at: nil)
.where(topics: { archetype: Archetype.default, deleted_at: nil })
.includes(:user, topic: %i[category tags])
.order("discourse_solved_solved_topics.created_at DESC")
.offset(offset)
.limit(limit)
render_serialized(posts, DiscourseSolved::SolvedPostSerializer, root: "user_solved_posts")
end
end

View File

@ -0,0 +1,87 @@
# frozen_string_literal: true
class DiscourseSolved::SolvedPostSerializer < ApplicationSerializer
attributes :created_at,
:archived,
:avatar_template,
:category_id,
:closed,
:cooked,
:excerpt,
:name,
:post_id,
:post_number,
:post_type,
:raw,
:slug,
:topic_id,
:topic_title,
:truncated,
:url,
:user_id,
:username
def archived
object.topic.archived
end
def avatar_template
object.user&.avatar_template
end
def category_id
object.topic.category_id
end
def closed
object.topic.closed
end
def excerpt
@excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true)
end
def name
object.user&.name
end
def include_name?
SiteSetting.enable_names?
end
def post_id
object.id
end
def slug
Slug.for(object.topic.title)
end
def include_slug?
object.topic.title.present?
end
def topic_title
object.topic.title
end
def truncated
true
end
def include_truncated?
cooked.length > 300
end
def url
"#{Discourse.base_url}#{object.url}"
end
def user_id
object.user_id
end
def username
object.user&.username
end
end

View File

@ -1,15 +1,119 @@
import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class UserActivitySolved extends UserActivityStreamRoute { class SolvedPostsStream extends EmberObject {
userActionType = 15; @tracked content = [];
noContentHelpKey = "solved.no_solutions"; @tracked loading = false;
@tracked loaded = false;
@tracked itemsLoaded = 0;
@tracked canLoadMore = true;
constructor(args) {
super(args);
this.username = args.username;
this.siteCategories = args.siteCategories;
}
get noContent() {
return this.loaded && this.content.length === 0;
}
findItems() {
if (this.loading || !this.canLoadMore) {
return Promise.resolve();
}
this.set("loading", true);
const limit = 20;
return ajax(
`/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}`
)
.then((result) => {
const userSolvedPosts = result.user_solved_posts || [];
if (userSolvedPosts.length === 0) {
this.set("canLoadMore", false);
return;
}
const posts = userSolvedPosts.map((p) => {
const post = EmberObject.create(p);
post.set("titleHtml", post.topic_title);
post.set("postUrl", post.url);
if (post.category_id && this.siteCategories) {
post.set(
"category",
this.siteCategories.find((c) => c.id === post.category_id)
);
}
return post;
});
// Add to existing content
if (this.content.pushObjects) {
this.content.pushObjects(posts);
} else {
this.content = this.content.concat(posts);
}
this.set("itemsLoaded", this.itemsLoaded + userSolvedPosts.length);
if (userSolvedPosts.length < limit) {
this.set("canLoadMore", false);
}
})
.finally(() => {
this.setProperties({
loaded: true,
loading: false,
});
});
}
}
export default class UserActivitySolved extends DiscourseRoute {
@service site;
@service currentUser;
model() {
const user = this.modelFor("user");
const stream = new SolvedPostsStream({
username: user.username,
siteCategories: this.site.categories,
});
return stream.findItems().then(() => {
return {
stream,
emptyState: this.emptyState(),
};
});
}
setupController(controller, model) {
controller.setProperties({
model,
emptyState: this.emptyState(),
});
}
renderTemplate() {
this.render("user-activity-solved");
}
emptyState() { emptyState() {
const user = this.modelFor("user"); const user = this.modelFor("user");
let title, body; let title, body;
if (this.isCurrentUser(user)) { if (this.currentUser && user.id === this.currentUser.id) {
title = i18n("solved.no_solved_topics_title"); title = i18n("solved.no_solved_topics_title");
body = i18n("solved.no_solved_topics_body"); body = i18n("solved.no_solved_topics_body");
} else { } else {

View File

@ -0,0 +1,19 @@
import RouteTemplate from "ember-route-template";
import UserStream from "discourse/components/user-stream";
export default RouteTemplate(
<template>
{{#if @controller.model.stream.noContent}}
<div class="empty-state">
<span class="empty-state-title">
{{@controller.model.emptyState.title}}
</span>
<div class="empty-state-body">
{{{@controller.model.emptyState.body}}}
</div>
</div>
{{/if}}
<UserStream @stream={{@controller.model.stream}} />
</template>
);

View File

@ -3,6 +3,8 @@
DiscourseSolved::Engine.routes.draw do DiscourseSolved::Engine.routes.draw do
post "/accept" => "answer#accept" post "/accept" => "answer#accept"
post "/unaccept" => "answer#unaccept" post "/unaccept" => "answer#unaccept"
get "/by_user" => "solved_topics#by_user"
end end
Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" } Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" }

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
describe "About page", type: :system do describe "Solved", type: :system do
fab!(:admin) fab!(:admin)
fab!(:solver) { Fabricate(:user) } fab!(:solver) { Fabricate(:user) }
fab!(:accepter) { Fabricate(:user) } fab!(:accepter) { Fabricate(:user) }
fab!(:topic) { Fabricate(:post, user: admin).topic } fab!(:topic) { Fabricate(:post, user: admin).topic }
fab!(:post1) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") } fab!(:solver_post) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") }
let(:topic_page) { PageObjects::Pages::Topic.new } let(:topic_page) { PageObjects::Pages::Topic.new }
before do before do
@ -39,4 +39,13 @@ describe "About page", type: :system do
end end
end end
end end
it "shows the solved post in user activity at /my/activity/solved" do
Fabricate(:solved_topic, topic:, answer_post: solver_post, accepter:)
sign_in(solver)
visit "/my/activity/solved"
expect(page.find(".post-list")).to have_content(solver_post.cooked)
end
end end