FEATURE: confirm solved topic change to non solved category (#37601)

This change adds a confirmation modal when moving the topic from a
solved category to a non-solved category when the topic has an accepted
solution. If the admin continues then the solved answer data is removed
from the answer post and the post stream is refreshed to remove the
solved data from the UI (ie. checkbox to mark as solved).

The admin also has the option of hiding the modal in future by selecting
the checkbox when confirming, which uses local storage to record their
preference.

How the modal looks:

<img width="550" alt="Confirmation modal when moving from solved
category"
src="https://github.com/user-attachments/assets/a016f0b8-e7a2-4adb-8ee2-1a2efec230ba"
/>

Internal ref - /t/173671
This commit is contained in:
David Battersby
2026-02-09 13:47:17 +04:00
committed by GitHub
parent 03c45bbdab
commit d32439da43
6 changed files with 429 additions and 1 deletions
@@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { i18n } from "discourse-i18n";
export default class CategoryChangeSolvedConfirmationModal extends Component {
@tracked dontShowAgain = false;
@action
toggleDontShowAgain(event) {
this.dontShowAgain = event.target.checked;
}
@action
confirm() {
this.args.closeModal({
confirmed: true,
dontShowAgain: this.dontShowAgain,
});
}
@action
cancel() {
this.args.closeModal({ confirmed: false });
}
<template>
<DModal
@closeModal={{this.cancel}}
@title={{i18n "solved.confirm_category_change_solution_title"}}
class="category-change-solved-confirmation-modal"
>
<:body>
<p>{{i18n "solved.confirm_category_change_solution"}}</p>
<div class="control-group">
<label
class="checkbox-label category-change-solution-dont-show-again"
>
<Input
@type="checkbox"
@checked={{this.dontShowAgain}}
{{on "change" this.toggleDontShowAgain}}
/>
{{i18n "solved.dont_show_again"}}
</label>
</div>
</:body>
<:footer>
<DButton
@action={{this.confirm}}
@label="solved.confirm_category_change_solution_confirm"
class="btn-primary"
/>
<DButton
@action={{this.cancel}}
@label="solved.confirm_category_change_solution_cancel"
class="btn-transparent"
/>
</:footer>
</DModal>
</template>
}
@@ -0,0 +1,68 @@
import { action } from "@ember/object";
import { withPluginApi } from "discourse/lib/plugin-api";
import Category from "discourse/models/category";
import CategoryChangeSolvedConfirmationModal from "../components/modal/category-change-solved-confirmation";
const STORAGE_KEY = "discourse-solved-hide-category-change-confirmation";
export default {
name: "solved-category-change-confirmation",
initialize() {
withPluginApi((api) => {
api.modifyClass(
"controller:topic",
(Superclass) =>
class extends Superclass {
@action
async finishedEditingTopic() {
if (!this.editingTopic) {
return;
}
let isSolved = (id) =>
Category.findById(id)?.enable_accepted_answers;
const props = this.get("buffered.buffer");
let solvedStateChanged = false;
if (
props.category_id !== undefined &&
!this.siteSettings.allow_solved_on_all_topics
) {
const oldSolved = isSolved(this.model.category_id);
const newSolved = isSolved(props.category_id);
solvedStateChanged = oldSolved !== newSolved;
if (this.model.accepted_answer && oldSolved && !newSolved) {
const showConfirmation =
localStorage.getItem(STORAGE_KEY) !== "true";
if (showConfirmation) {
const result = await this.modal.show(
CategoryChangeSolvedConfirmationModal
);
if (!result?.confirmed) {
return;
}
if (result.dontShowAgain) {
localStorage.setItem(STORAGE_KEY, "true");
}
}
}
}
await super.finishedEditingTopic();
if (solvedStateChanged) {
this.model.postStream.refresh({ forceLoad: true });
}
}
}
);
});
},
};
@@ -44,6 +44,10 @@ en:
other: "Move posts"
move_post_cancel: "Cancel"
dont_show_again: "Got it, don't show me this again"
confirm_category_change_solution_title: "Solution will be removed"
confirm_category_change_solution: "The new category does not have solutions enabled. Changing the category will remove the accepted solution from this topic."
confirm_category_change_solution_confirm: "Change category"
confirm_category_change_solution_cancel: "Cancel"
no_answer:
title: Has your question been answered?
+11 -1
View File
@@ -303,7 +303,17 @@ after_initialize do
old_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[0], tag_changes[0])
new_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[1], tag_changes[1])
options[:refresh_stream] = true if old_allowed != new_allowed
if old_allowed != new_allowed
options[:refresh_stream] = true
if !new_allowed
topic = topic_changes.topic
if topic.solved.present?
post = topic.solved.answer_post
DiscourseSolved.unaccept_answer!(post, topic: topic) if post
end
end
end
end
query = <<~SQL
@@ -31,6 +31,68 @@ describe PostRevisor do
expect(messages.first.data[:refresh_stream]).to eq(true)
end
describe "Unaccepting answer on category change" do
fab!(:topic) { Fabricate(:topic, category: category_solved) }
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:reply) { Fabricate(:post, topic: topic, post_number: 2) }
before do
SiteSetting.solved_enabled = true
DiscourseSolved.accept_answer!(reply, admin)
topic.reload
end
it "unaccepts the answer when category changes from solved to unsolved" do
described_class.new(post).revise!(admin, { category_id: category.id })
expect(topic.reload.solved).to be_nil
end
it "keeps the answer when category changes to another solved category" do
another_solved =
Fabricate(:category_with_definition).tap do |c|
c.upsert_custom_fields("enable_accepted_answers" => "true")
end
DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache
described_class.new(post).revise!(admin, { category_id: another_solved.id })
topic.reload
expect(topic.solved).to be_present
expect(topic.solved.answer_post_id).to eq(reply.id)
end
it "keeps the answer when allow_solved_on_all_topics is true" do
SiteSetting.allow_solved_on_all_topics = true
described_class.new(post).revise!(admin, { category_id: category.id })
topic.reload
expect(topic.solved).to be_present
expect(topic.solved.answer_post_id).to eq(reply.id)
end
end
describe "Unaccepting answer on tag change" do
before do
SiteSetting.solved_enabled = true
SiteSetting.tagging_enabled = true
end
fab!(:solved_tag, :tag)
fab!(:topic) { Fabricate(:topic, tags: [solved_tag]) }
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:reply) { Fabricate(:post, topic: topic, post_number: 2) }
it "unaccepts the answer when the solved tag is removed" do
SiteSetting.enable_solved_tags = solved_tag.name
DiscourseSolved.accept_answer!(reply, admin)
described_class.new(post).revise!(admin, tags: [])
expect(topic.reload.solved).to be_nil
end
end
describe "Allowing solved via tags" do
before do
SiteSetting.solved_enabled = true
@@ -0,0 +1,218 @@
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { cloneJSON } from "discourse/lib/object";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { i18n } from "discourse-i18n";
import { postStreamWithAcceptedAnswerExcerpt } from "../helpers/discourse-solved-helpers";
function solvedTopicFixture() {
const topic = cloneJSON(postStreamWithAcceptedAnswerExcerpt("an answer"));
topic.category_id = 100;
return topic;
}
function unsolvedTopicFixture() {
const topic = cloneJSON(postStreamWithAcceptedAnswerExcerpt("an answer"));
topic.category_id = 100;
topic.accepted_answer = null;
topic.post_stream.posts.forEach((p) => {
p.accepted_answer = false;
p.can_accept_answer = false;
});
return topic;
}
const STORAGE_KEY = "discourse-solved-hide-category-change-confirmation";
acceptance("Discourse Solved | Category Change Confirmation", function (needs) {
needs.user({ admin: true });
needs.settings({
solved_enabled: true,
allow_solved_on_all_topics: false,
});
needs.hooks.beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
needs.hooks.afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
needs.site({
categories: [
{
id: 100,
name: "Solved Category",
slug: "solved-category",
color: "0088CC",
text_color: "FFFFFF",
permission: 1,
custom_fields: { enable_accepted_answers: "true" },
},
{
id: 200,
name: "Unsolved Category",
slug: "unsolved-category",
color: "FF0000",
text_color: "FFFFFF",
permission: 1,
custom_fields: {},
},
{
id: 300,
name: "Another Solved Category",
slug: "another-solved-category",
color: "00FF00",
text_color: "FFFFFF",
permission: 1,
custom_fields: { enable_accepted_answers: "true" },
},
],
});
needs.pretender((server, helper) => {
server.get("/t/50.json", () => helper.response(solvedTopicFixture()));
server.get("/t/51.json", () => helper.response(unsolvedTopicFixture()));
server.get("/t/23.json", () => helper.response(solvedTopicFixture()));
server.put("/t/test-solved/50", () =>
helper.response({ basic_topic: { id: 50, title: "Test solved" } })
);
});
test("shows confirmation modal when changing from solved to unsolved category", async function (assert) {
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
const categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.exists("confirmation modal is shown");
assert
.dom(".category-change-solved-confirmation-modal .d-modal__title")
.hasText(i18n("solved.confirm_category_change_solution_title"));
});
test("does not show modal when topic has no accepted answer", async function (assert) {
await visit("/t/test-solved/51");
await click("#topic-title .d-icon-pencil");
const categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.doesNotExist("confirmation modal is not shown");
});
test("canceling modal prevents category change", async function (assert) {
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
const categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.exists("confirmation modal is shown");
await click(
".category-change-solved-confirmation-modal .d-modal__footer .btn-transparent"
);
assert
.dom(".category-change-solved-confirmation-modal")
.doesNotExist("modal is closed");
assert
.dom("#topic-title .d-icon-pencil")
.doesNotExist("still in editing mode");
});
test("confirming modal proceeds with category change", async function (assert) {
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
const categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.exists("confirmation modal is shown");
await click(
".category-change-solved-confirmation-modal .d-modal__footer .btn-primary"
);
assert
.dom(".category-change-solved-confirmation-modal")
.doesNotExist("modal is closed");
});
test("does not show modal when changing to another solved category", async function (assert) {
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
const categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(300);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.doesNotExist(
"confirmation modal is not shown for solved-to-solved change"
);
});
test("respects 'don't show again' preference", async function (assert) {
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
let categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.exists("confirmation modal is shown");
await click(".category-change-solution-dont-show-again input");
await click(
".category-change-solved-confirmation-modal .d-modal__footer .btn-primary"
);
await visit("/t/test-solved/50");
await click("#topic-title .d-icon-pencil");
categoryChooser = selectKit(".title-wrapper .category-chooser");
await categoryChooser.expand();
await categoryChooser.selectRowByValue(200);
await click("#topic-title .submit-edit");
assert
.dom(".category-change-solved-confirmation-modal")
.doesNotExist("confirmation modal is not shown second time");
});
});