mirror of
https://github.com/discourse/discourse.git
synced 2026-08-02 09:29:37 -05:00
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:
+66
@@ -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>
|
||||
}
|
||||
+68
@@ -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?
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user