Merge branch 'main' into dev/post-quoted-content-component

This commit is contained in:
Sérgio Saquetim
2025-07-21 23:25:41 -03:00
14 changed files with 441 additions and 68 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ GEM
prettier_print (1.2.1)
prism (1.4.0)
racc (1.8.1)
rack (3.1.15)
rack (3.1.16)
rainbow (3.1.1)
regexp_parser (2.10.0)
rubocop (1.75.8)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"tests": {
"requiredPlugins": [
"https://github.com/discourse/discourse-assign"
"discourse-assign"
]
}
}
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class DiscourseSolved::SolvedTopicsController < ::ApplicationController
requires_plugin DiscourseSolved::PLUGIN_NAME
def by_user
params.require(: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)
.joins("LEFT JOIN categories ON categories.id = topics.category_id")
.where(user_id: user.id, deleted_at: nil)
.where(topics: { archetype: Archetype.default, deleted_at: nil })
.where(
"topics.category_id IS NULL OR NOT categories.read_restricted OR topics.category_id IN (:secure_category_ids)",
secure_category_ids: guardian.secure_category_ids,
)
.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
@@ -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
@@ -1,15 +1,110 @@
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";
export default class UserActivitySolved extends UserActivityStreamRoute {
userActionType = 15;
noContentHelpKey = "solved.no_solutions";
class SolvedPostsStream {
@tracked content = [];
@tracked loading = false;
@tracked loaded = false;
@tracked itemsLoaded = 0;
@tracked canLoadMore = true;
constructor({ username, siteCategories }) {
this.username = username;
this.siteCategories = siteCategories;
}
get noContent() {
return this.loaded && this.content.length === 0;
}
findItems() {
if (this.loading || !this.canLoadMore) {
return Promise.resolve();
}
this.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.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;
});
this.content = [...this.content, ...posts];
this.itemsLoaded = this.itemsLoaded + userSolvedPosts.length;
if (userSolvedPosts.length < limit) {
this.canLoadMore = false;
}
})
.finally(() => {
this.loaded = true;
this.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() {
const user = this.modelFor("user");
let title, body;
if (this.isCurrentUser(user)) {
if (this.currentUser && user.id === this.currentUser.id) {
title = i18n("solved.no_solved_topics_title");
body = i18n("solved.no_solved_topics_body");
} else {
@@ -0,0 +1,16 @@
import RouteTemplate from "ember-route-template";
import EmptyState from "discourse/components/empty-state";
import UserStream from "discourse/components/user-stream";
export default RouteTemplate(
<template>
{{#if @controller.model.stream.noContent}}
<EmptyState
@title={{@controller.model.emptyState.title}}
@body={{@controller.model.emptyState.body}}
/>
{{else}}
<UserStream @stream={{@controller.model.stream}} />
{{/if}}
</template>
);
+16
View File
@@ -10,6 +10,7 @@ el:
alt:
solved:
accepted_notification: "αποδεκτό"
solutions: "Λύσεις"
solved:
title: "Λύθηκε"
allow_accepted_answers: "Επιτρέψτε στον κάτοχο και το προσωπικό να επισημάνει μια απάντηση ως λύση"
@@ -29,6 +30,13 @@ el:
all: "όλα"
solved: "λύθηκε"
unsolved: "άλυτο"
no_solved_topics_title: "Δεν έχετε λύσει κανένα θέμα ακόμα"
no_solved_topics_title_others: "Ο χρήστης %{username} δεν έχει λύσει κανένα θέμα ακόμα"
no_answer:
title: Έχει απαντηθεί η ερώτηση σας;
description: "Επισημάνετε την απάντηση και βοηθήστε άλλους χρησιμοποιώντας το κουμπί λύσης κάτω από τη σωστή απάντηση."
notification:
title: "η ανάρτηση σας επισημάνθηκε ως λύση"
topic_statuses:
solved:
help: "Αυτό το θέμα έχει μια λύση"
@@ -36,13 +44,21 @@ el:
advanced:
statuses:
solved: "έχουν επιλυθεί"
unsolved: "δεν έχουν επιλυθεί"
admin:
web_hooks:
solved_event:
group_name: "Επιλύθηκε γεγονός"
api:
scopes:
descriptions:
solved:
answer: Αποδοχή/Απόρριψη μίας λύσης.
discourse_automation:
triggerables:
first_accepted_solution:
max_trust_level:
any: Οποιοδήποτε
fields:
maximum_trust_level:
label: Επίπεδο Εμπιστοσύνης
+5
View File
@@ -5,6 +5,7 @@
# https://translate.discourse.org/
el:
accepted_answer: "Αποδεκτή Απάντηση"
site_settings:
solved_enabled: "Ενεργοποιήστε το πρόσθετο solved, επιτρέψτε στους χρήστες να επιλέγουν λύσεις για θέματα"
accept_all_solutions_trust_level: "Απαιτείται ελάχιστο επίπεδο εμπιστοσύνης για αποδοχή λύσεων σε οποιοδήποτε θέμα (ακόμα και όταν δεν είναι OP)"
@@ -23,3 +24,7 @@ el:
badges:
solved_1:
name: "Λύθηκε!"
discourse_automation:
triggerables:
first_accepted_solution:
title: Πρώτη αποδεκτή λύση
+2
View File
@@ -3,6 +3,8 @@
DiscourseSolved::Engine.routes.draw do
post "/accept" => "answer#accept"
post "/unaccept" => "answer#unaccept"
get "/by_user" => "solved_topics#by_user"
end
Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" }
@@ -5,15 +5,15 @@ module DiscourseSolved
def self.check(post, trust_level:)
return false if post.archetype != Archetype.default
return false if !post&.user&.human?
return true if trust_level == "any"
return false if TrustLevel.compare(post&.user&.trust_level, trust_level.to_i)
if !UserAction.where(user_id: post&.user_id, action_type: UserAction::SOLVED).exists?
return true
if trust_level != "any" && TrustLevel.compare(post&.user&.trust_level, trust_level.to_i)
return false
end
false
!DiscourseSolved::SolvedTopic
.joins(:answer_post)
.where("posts.user_id = ? AND posts.id != ?", post.user_id, post.id)
.exists?
end
end
end
@@ -1,56 +1,43 @@
# frozen_string_literal: true
require "rails_helper"
describe DiscourseSolved::FirstAcceptedPostSolutionValidator do
fab!(:user_tl1) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) }
context "when user is under max trust level" do
context "with no post accepted yet" do
it "validates the post" do
post_1 = create_post(user: user_tl1)
expect(described_class.check(post_1, trust_level: TrustLevel[2])).to eq(true)
end
end
context "with already had accepted posts" do
before do
accepted_post = create_post(user: user_tl1)
DiscourseSolved.accept_answer!(accepted_post, Discourse.system_user)
end
it "doesnt validate the post" do
post_1 = create_post(user: user_tl1)
expect(described_class.check(post_1, trust_level: TrustLevel[2])).to eq(false)
end
end
end
context "when a user is above or equal max trust level" do
context "with no post accepted yet" do
it "doesnt validate the post" do
post_1 = create_post(user: user_tl1)
expect(described_class.check(post_1, trust_level: TrustLevel[1])).to eq(false)
end
end
context "when a post is already accepted" do
before do
accepted_post = create_post(user: user_tl1)
DiscourseSolved.accept_answer!(accepted_post, Discourse.system_user)
end
it "doesnt validate the post" do
post_1 = create_post(user: user_tl1)
expect(described_class.check(post_1, trust_level: TrustLevel[1])).to eq(false)
end
end
end
context "when using any trust level" do
context "when trust level is 'any'" do
it "validates the post" do
post_1 = create_post(user: user_tl1)
expect(described_class.check(post_1, trust_level: "any")).to eq(true)
post = Fabricate(:post, user: user_tl1)
DiscourseSolved.accept_answer!(post, Discourse.system_user)
expect(described_class.check(post, trust_level: "any")).to eq(true)
end
it "invalidates if post user already has an accepted post" do
previously_accepted_post = Fabricate(:post, user: user_tl1)
DiscourseSolved.accept_answer!(previously_accepted_post, Discourse.system_user)
newly_accepted_post = Fabricate(:post, user: user_tl1)
DiscourseSolved.accept_answer!(newly_accepted_post, Discourse.system_user)
expect(described_class.check(newly_accepted_post, trust_level: "any")).to eq(false)
end
end
context "with specified trust level that is not 'any'" do
# the automation indicates "users under this Trust Level will trigger this automation"
it "invalidates if the user is higher than or equal to the specified trust level" do
post = Fabricate(:post, user: user_tl1)
DiscourseSolved.accept_answer!(post, Discourse.system_user)
expect(described_class.check(post, trust_level: TrustLevel[0])).to eq(false)
expect(described_class.check(post, trust_level: TrustLevel[1])).to eq(false)
end
it "validates the post when user is under specified trust level" do
post = Fabricate(:post, user: user_tl1)
DiscourseSolved.accept_answer!(post, Discourse.system_user)
expect(described_class.check(post, trust_level: TrustLevel[2])).to eq(true)
end
end
@@ -0,0 +1,117 @@
# frozen_string_literal: true
describe DiscourseSolved::SolvedTopicsController do
fab!(:user)
fab!(:another_user) { Fabricate(:user) }
fab!(:admin)
fab!(:topic)
fab!(:post) { Fabricate(:post, topic:) }
fab!(:answer_post) { Fabricate(:post, topic:, user:) }
fab!(:solved_topic) { Fabricate(:solved_topic, topic:, answer_post:) }
describe "#by_user" do
context "when accessing with username" do
it "returns solved posts for the specified user" do
sign_in(admin)
get "/solution/by_user.json", params: { username: user.username }
expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_present
expect(result["user_solved_posts"].length).to eq(1)
expect(result["user_solved_posts"][0]["post_id"]).to eq(answer_post.id)
end
it "returns 404 for a non-existent user" do
sign_in(admin)
get "/solution/by_user.json", params: { username: "non-existent-user" }
expect(response.status).to eq(404)
end
it "correctly handles the offset parameter" do
sign_in(admin)
get "/solution/by_user.json", params: { username: user.username, offset: 1 }
expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_empty
end
it "correctly handles the limit parameter" do
Fabricate(:solved_topic, answer_post: Fabricate(:post, user:))
sign_in(admin)
get "/solution/by_user.json", params: { username: user.username, limit: 1 }
expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"].length).to eq(1)
end
end
context "when accessing without username" do
it "returns 400 for the current user" do
sign_in(user)
get "/solution/by_user.json"
expect(response.status).to eq(400)
end
it "returns 400 if not logged in" do
get "/solution/by_user.json"
expect(response.status).to eq(400)
end
end
context "with visibility restrictions" do
context "with private category solved topic" do
fab!(:group) { Fabricate(:group).tap { |g| g.add(user) } }
fab!(:private_category) { Fabricate(:private_category, group:) }
fab!(:private_topic) { Fabricate(:topic, category: private_category) }
fab!(:private_post) { Fabricate(:post, topic: private_topic) }
fab!(:private_answer_post) { Fabricate(:post, topic: private_topic, user: user) }
fab!(:private_solved_topic) do
Fabricate(:solved_topic, topic: private_topic, answer_post: private_answer_post)
end
it "respects category permissions" do
sign_in(another_user)
get "/solution/by_user.json", params: { username: user.username }
expect(response.status).to eq(200)
result = response.parsed_body
# admin sees both solutions
expect(result["user_solved_posts"].length).to eq(1)
sign_in(user)
get "/solution/by_user.json", params: { username: user.username }
expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"].length).to eq(2)
end
end
it "does not return PMs" do
topic.update(archetype: Archetype.private_message, category: nil)
sign_in(user)
get "/solution/by_user.json", params: { username: user.username }
expect(response.status).to eq(200)
result = response.parsed_body
expect(result["user_solved_posts"]).to be_empty
end
end
end
end
+11 -2
View File
@@ -1,10 +1,10 @@
# frozen_string_literal: true
describe "About page", type: :system do
describe "Solved", type: :system do
fab!(:admin)
fab!(:solver) { Fabricate(:user) }
fab!(:accepter) { Fabricate(:user) }
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 }
before do
@@ -62,4 +62,13 @@ describe "About page", type: :system do
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
@@ -9,8 +9,8 @@ acceptance(
needs.user();
needs.pretender((server, helper) => {
server.get("/user_actions.json", () =>
helper.response({ user_actions: [] })
server.get("/solution/by_user.json", () =>
helper.response({ user_solved_posts: [] })
);
});
@@ -18,22 +18,22 @@ acceptance(
await visit(`/u/eviltrout/activity/solved`);
assert
.dom("div.empty-state span.empty-state-title")
.dom(".empty-state .empty-state__title")
.hasText(i18n("solved.no_solved_topics_title"));
assert
.dom("div.empty-state div.empty-state-body")
.dom(".empty-state .empty-state__body")
.hasText(i18n("solved.no_solved_topics_body"));
});
test("When looking at another user's activity", async function (assert) {
await visit(`/u/charlie/activity/solved`);
assert.dom("div.empty-state span.empty-state-title").hasText(
assert.dom(".empty-state .empty-state__title").hasText(
i18n("solved.no_solved_topics_title_others", {
username: "charlie",
})
);
assert.dom("div.empty-state div.empty-state-body").hasNoText();
assert.dom(".empty-state .empty-state__body").doesNotExist();
});
}
);