FEATURE: Make it possible to reassign posts on the topic level assign modal (#562)

This adds a new dropdown to the topic level assign modal. A topic may contain several assignments, the topic itself may be assigned and also some of the replies may be assigned. With this new dropdown, it's possible to edit all the assignments from this modal.

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Andrei Prigorshnev 2024-04-16 16:08:39 +04:00 committed by GitHub
parent 6070678a1c
commit c1d4633ba4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 637 additions and 192 deletions

View File

@ -0,0 +1,36 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import Assignment from "./assignment";
export default class AssignUserForm extends Component {
@tracked showValidationErrors = false;
constructor() {
super(...arguments);
this.args.formApi.submit = this.assign;
}
get assigneeIsEmpty() {
return !this.args.model.username && !this.args.model.group_name;
}
@action
async assign() {
if (this.assigneeIsEmpty) {
this.showValidationErrors = true;
return;
}
await this.args.onSubmit();
}
<template>
<Assignment
@assignment={{@model}}
@onSubmit={{this.assign}}
@showValidationErrors={{this.showValidationErrors}}
/>
</template>
}

View File

@ -1,56 +0,0 @@
<div class="control-group {{if this.assigneeError 'assignee-error'}}">
<label>{{i18n "discourse_assign.assign_modal.assignee_label"}}</label>
<AssigneeChooser
autocomplete="off"
@id="assignee-chooser"
@value={{this.assigneeName}}
@onChange={{this.assignUsername}}
@showUserStatus={{true}}
@options={{hash
mobilePlacementStrategy="absolute"
includeGroups=true
customSearchOptions=(hash
assignableGroups=true defaultSearchResults=this.taskActions.suggestions
)
groupMembersOf=this.taskActions.allowedGroups
maximum=1
tabindex=1
expandedOnInsert=(not this.assigneeName)
caretUpIcon="search"
caretDownIcon="search"
}}
/>
{{#if this.assigneeError}}
<span class="error-label">
{{d-icon "exclamation-triangle"}}
{{i18n "discourse_assign.assign_modal.choose_assignee"}}
</span>
{{/if}}
</div>
{{#if this.siteSettings.enable_assign_status}}
<div class="control-group assign-status">
<label>{{i18n "discourse_assign.assign_modal.status_label"}}</label>
<ComboBox
@id="assign-status"
@content={{this.availableStatuses}}
@value={{this.status}}
@onChange={{fn (mut @model.status)}}
/>
</div>
{{/if}}
<div class="control-group assign-status">
<label>
{{i18n "discourse_assign.assign_modal.note_label"}}&nbsp;<span
class="label-optional"
>{{i18n "discourse_assign.assign_modal.optional_label"}}</span>
</label>
<Textarea
id="assign-modal-note"
@value={{@model.note}}
{{on "keydown" this.handleTextAreaKeydown}}
/>
</div>

View File

@ -1,63 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class AssignUserForm extends Component {
@service taskActions;
@service siteSettings;
@service capabilities;
@tracked assigneeError = false;
@tracked
assigneeName = this.args.model.username || this.args.model.group_name;
constructor() {
super(...arguments);
this.args.formApi.submit = this.assign;
}
get availableStatuses() {
return this.siteSettings.assign_statuses
.split("|")
.map((status) => ({ id: status, name: status }));
}
get status() {
return (
this.args.model.status || this.siteSettings.assign_statuses.split("|")[0]
);
}
@action
handleTextAreaKeydown(event) {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
this.assign();
}
}
@action
async assign() {
if (!this.assigneeName) {
this.assigneeError = true;
return;
}
await this.args.onSubmit();
}
@action
assignUsername([name]) {
this.assigneeName = name;
this.assigneeError = false;
if (this.taskActions.allowedGroupsForAssignment.includes(name)) {
this.args.model.username = null;
this.args.model.group_name = name;
} else {
this.args.model.username = name;
this.args.model.group_name = null;
}
}
}

View File

@ -0,0 +1,134 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { TextArea } from "@ember/legacy-built-in-components";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import ComboBox from "select-kit/components/combo-box";
import not from "truth-helpers/helpers/not";
import AssigneeChooser from "./assignee-chooser";
export default class Assignment extends Component {
@service siteSettings;
@service taskActions;
get assignee() {
return this.args.assignment.username || this.args.assignment.group_name;
}
get status() {
return this.args.assignment.status || this.assignStatuses[0];
}
get assignStatuses() {
return this.siteSettings.assign_statuses.split("|").filter(Boolean);
}
get assignStatusOptions() {
return this.assignStatuses.map((status) => ({ id: status, name: status }));
}
get assigneeIsEmpty() {
return !this.args.assignment.username && !this.args.assignment.group_name;
}
get showAssigneeIeEmptyError() {
return this.assigneeIsEmpty && this.args.showValidationErrors;
}
@action
handleTextAreaKeydown(event) {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
this.args.onSubmit();
}
}
@action
markAsEdited() {
this.args.assignment.isEdited = true;
}
@action
setAssignee([newAssignee]) {
if (this.taskActions.allowedGroupsForAssignment.includes(newAssignee)) {
this.args.assignment.username = null;
this.args.assignment.group_name = newAssignee;
} else {
this.args.assignment.username = newAssignee;
this.args.assignment.group_name = null;
}
this.markAsEdited();
}
@action
setStatus(status) {
this.args.assignment.status = status;
this.markAsEdited();
}
<template>
<div
class="control-group
{{if this.showAssigneeIeEmptyError 'assignee-error'}}"
>
<label>{{i18n "discourse_assign.assign_modal.assignee_label"}}</label>
<AssigneeChooser
autocomplete="off"
@id="assignee-chooser"
@value={{this.assignee}}
@onChange={{this.setAssignee}}
@showUserStatus={{true}}
@options={{hash
mobilePlacementStrategy="absolute"
includeGroups=true
customSearchOptions=(hash
assignableGroups=true
defaultSearchResults=this.taskActions.suggestions
)
groupMembersOf=this.taskActions.allowedGroups
maximum=1
tabindex=1
expandedOnInsert=(not this.assignee)
caretUpIcon="search"
caretDownIcon="search"
}}
/>
{{#if this.showAssigneeIeEmptyError}}
<span class="error-label">
{{icon "exclamation-triangle"}}
{{i18n "discourse_assign.assign_modal.choose_assignee"}}
</span>
{{/if}}
</div>
{{#if this.siteSettings.enable_assign_status}}
<div class="control-group assign-status">
<label>{{i18n "discourse_assign.assign_modal.status_label"}}</label>
<ComboBox
@id="assign-status"
@content={{this.assignStatusOptions}}
@value={{this.status}}
@onChange={{this.setStatus}}
/>
</div>
{{/if}}
<div class="control-group assign-status">
<label>
{{i18n "discourse_assign.assign_modal.note_label"}}&nbsp;<span
class="label-optional"
>{{i18n "discourse_assign.assign_modal.optional_label"}}</span>
</label>
<TextArea
id="assign-modal-note"
@value={{@assignment.note}}
{{on "keydown" this.handleTextAreaKeydown}}
{{on "input" this.markAsEdited}}
/>
</div>
</template>
}

View File

@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
import TopicAssignments from "../topic-assignments";
export default class EditTopicAssignments extends Component {
@service taskActions;
@tracked assignments = this.topic.assignments();
get title() {
if (this.topic.isAssigned() || this.topic.hasAssignedPosts()) {
return I18n.t("edit_assignments_modal.title");
} else {
return I18n.t("discourse_assign.assign_modal.title");
}
}
get topic() {
return this.args.model.topic;
}
@action
async submit() {
this.args.closeModal();
try {
await this.#assign();
} catch (error) {
popupAjaxError(error);
}
}
async #assign() {
for (const assignment of this.assignments) {
if (assignment.isEdited) {
await this.taskActions.putAssignment(assignment);
}
}
}
<template>
<DModal class="assign" @title={{this.title}} @closeModal={{@closeModal}}>
<:body>
<TopicAssignments @assignments={{this.assignments}} />
</:body>
<:footer>
<DButton
class="btn-primary"
@action={{this.submit}}
@label={{if
this.model.reassign
"discourse_assign.reassign.title"
"discourse_assign.assign_modal.assign"
}}
/>
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>
</template>
}

View File

@ -0,0 +1,48 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import i18n from "discourse-common/helpers/i18n";
import I18n from "I18n";
import ComboBox from "select-kit/components/combo-box";
import Assignment from "./assignment";
export default class TopicAssignments extends Component {
@tracked selectedAssignment = this.args.assignments.find((a) => a.id === 0);
get assignmentOptions() {
return this.args.assignments.map((a) => this.#toComboBoxOption(a));
}
@action
selectAssignment(id) {
this.selectedAssignment = this.args.assignments.find((a) => a.id === id);
}
#toComboBoxOption(assignment) {
const option = { id: assignment.id };
if (assignment.targetType === "Topic") {
option.name = I18n.t("edit_assignments_modal.topic");
} else {
option.name = `${I18n.t("edit_assignments_modal.post")} #${
assignment.postNumber
}`;
}
return option;
}
<template>
<div class="control-group target">
<label>{{i18n "discourse_assign.assign_modal.assignment_label"}}</label>
<ComboBox
@value={{this.selectedAssignment.id}}
@content={{this.assignmentOptions}}
@onChange={{this.selectAssignment}}
/>
</div>
<Assignment
@assignment={{this.selectedAssignment}}
@onSubmit={{this.submit}}
@showValidationErrors={{false}}
/>
</template>
}

View File

@ -3,6 +3,7 @@ import { htmlSafe } from "@ember/template";
import { renderAvatar } from "discourse/helpers/user-avatar";
import { iconHTML } from "discourse-common/lib/icon-library";
import I18n from "I18n";
import EditTopicAssignments from "../components/modal/edit-topic-assignments";
const DEPENDENT_KEYS = [
"topic.assigned_to_user",
@ -22,6 +23,7 @@ export default {
}
const taskActions = getOwner(this).lookup("service:task-actions");
const modal = getOwner(this).lookup("service:modal");
const firstPostId = this.topic.postStream.firstPostId;
switch (id) {
@ -42,9 +44,10 @@ export default {
break;
}
case "reassign": {
await taskActions.showAssignModal(this.topic, {
targetType: "Topic",
isAssigned: this.topic.isAssigned(),
await modal.show(EditTopicAssignments, {
model: {
topic: this.topic,
},
onSuccess: () =>
this.appEvents.trigger("post-stream:refresh", { id: firstPostId }),
});
@ -64,6 +67,7 @@ export default {
noneItem() {
return topicLevelUnassignButton(this.topic.uniqueAssignees());
},
content() {
const content = [];

View File

@ -16,7 +16,9 @@ import discourseComputed from "discourse-common/utils/decorators";
import I18n from "I18n";
import BulkAssign from "../components/bulk-actions/assign-user";
import BulkActionsAssignUser from "../components/bulk-actions/bulk-assign-user";
import EditTopicAssignments from "../components/modal/edit-topic-assignments";
import TopicLevelAssignMenu from "../components/topic-level-assign-menu";
import { extendTopicModel } from "../models/topic";
const PLUGIN_ID = "discourse-assign";
@ -38,50 +40,6 @@ function defaultTitle(topic) {
}
}
function extendTopicModel(api) {
api.modifyClass("model:topic", {
pluginId: PLUGIN_ID,
assignees() {
const result = [];
if (this.assigned_to_user) {
result.push(this.assigned_to_user);
}
const postAssignees = this.assignedPosts().map((p) => p.assigned_to);
result.push(...postAssignees);
return result;
},
uniqueAssignees() {
const map = new Map();
this.assignees().forEach((user) => map.set(user.username, user));
return [...map.values()];
},
assignedPosts() {
if (!this.indirectly_assigned_to) {
return [];
}
return Object.values(this.indirectly_assigned_to);
},
isAssigned() {
return this.assigned_to_user || this.assigned_to_group;
},
isAssignedTo(user) {
return this.assigned_to_user?.username === user.username;
},
hasAssignedPosts() {
return !!this.assignedPosts().length;
},
});
}
function registerTopicFooterButtons(api) {
registerTopicFooterDropdown(TopicLevelAssignMenu);
@ -110,6 +68,7 @@ function registerTopicFooterButtons(api) {
}
const taskActions = getOwner(this).lookup("service:task-actions");
const modal = getOwner(this).lookup("service:modal");
if (this.topic.isAssigned()) {
this.set("topic.assigned_to_user", null);
@ -121,7 +80,10 @@ function registerTopicFooterButtons(api) {
id: this.topic.postStream.firstPostId,
});
} else {
await taskActions.showAssignModal(this.topic, {
await modal.show(EditTopicAssignments, {
model: {
topic: this.topic,
},
onSuccess: () =>
this.appEvents.trigger("post-stream:refresh", {
id: this.topic.postStream.firstPostId,
@ -723,6 +685,9 @@ function initialize(api) {
}
}
this.appEvents.trigger("header:update-topic", topic);
this.appEvents.trigger("post-stream:refresh", {
id: topic.postStream.posts[0].id,
});
});
},
@ -833,7 +798,7 @@ export default {
}
withPluginApi("0.13.0", (api) => {
extendTopicModel(api);
extendTopicModel(api, PLUGIN_ID);
initialize(api);
registerTopicFooterButtons(api);

View File

@ -0,0 +1,40 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
export class Assignment extends EmberObject {
static fromTopic(topic) {
const assignment = new Assignment();
assignment.id = 0;
assignment.username = topic.assigned_to_user?.username;
assignment.groupName = topic.assigned_to_group?.name;
assignment.status = topic.assignment_status;
assignment.note = topic.assignment_note;
assignment.targetId = topic.id;
assignment.targetType = "Topic";
return assignment;
}
static fromPost(post) {
const assignment = new Assignment();
assignment.username = post.assigned_to.username;
assignment.groupName = post.assigned_to.name;
assignment.status = post.assignment_status;
assignment.note = post.assignment_note;
assignment.targetId = post.postId;
assignment.targetType = "Post";
assignment.postNumber = post.post_number;
assignment.id = post.post_number;
return assignment;
}
// to-do rename to groupName, some components use both this model
// and models from server, that's why we have to call it "group_name" now
@tracked group_name;
@tracked username;
@tracked status;
@tracked note;
targetId;
targetType;
postNumber;
isEdited = false;
}

View File

@ -0,0 +1,67 @@
import { Assignment } from "./assignment";
export function extendTopicModel(api, pluginId) {
api.modifyClass("model:topic", {
pluginId,
assignees() {
const result = [];
if (this.assigned_to_user) {
result.push(this.assigned_to_user);
}
const postAssignees = this.assignedPosts().map((p) => p.assigned_to);
result.push(...postAssignees);
return result;
},
uniqueAssignees() {
const map = new Map();
this.assignees().forEach((user) => map.set(user.username, user));
return [...map.values()];
},
assignedPosts() {
if (!this.indirectly_assigned_to) {
return [];
}
return Object.entries(this.indirectly_assigned_to).map(([key, value]) => {
value.postId = key;
return value;
});
},
assignments() {
return [this.topicAssignment(), ...this.postAssignments()].compact();
},
postAssignments() {
if (!this.indirectly_assigned_to) {
return [];
}
return Object.entries(this.indirectly_assigned_to).map(([key, value]) => {
value.postId = key;
return Assignment.fromPost(value);
});
},
topicAssignment() {
return Assignment.fromTopic(this);
},
isAssigned() {
return this.assigned_to_user || this.assigned_to_group;
},
isAssignedTo(user) {
return this.assigned_to_user?.username === user.username;
},
hasAssignedPosts() {
return !!this.postAssignments().length;
},
});
}

View File

@ -115,4 +115,18 @@ export default class TaskActions extends Service {
popupAjaxError(error);
}
}
async putAssignment(assignment) {
await ajax("/assign/assign", {
type: "PUT",
data: {
username: assignment.username,
group_name: assignment.group_name,
target_id: assignment.targetId,
target_type: assignment.targetType,
note: assignment.note,
status: assignment.status,
},
});
}
}

View File

@ -73,6 +73,12 @@
}
}
.target {
.combo-box {
width: 100%;
}
}
.email-group-user-chooser {
width: 100%;

View File

@ -69,7 +69,9 @@ en:
reassign_title: "Reassign Topic"
assign: "Assign"
assignee_label: Assignee
assignment_label: Assignment
choose_assignee: Choose a user to assign.
edit_assignments_title: "Edit Assignments"
note_label: Note
optional_label: "(optional)"
status_label: Status
@ -177,3 +179,7 @@ en:
assign_list_with_unread:
one: "Assign list - %{count} unread assignment"
other: "Assign list - %{count} unread assignments"
edit_assignments_modal:
title: "Edit assignments"
topic: "Topic"
post: "Post"

View File

@ -0,0 +1,169 @@
import { click, settled, visit } from "@ember/test-helpers";
import { test } from "qunit";
import {
acceptance,
publishToMessageBus,
} from "discourse/tests/helpers/qunit-helpers";
import topicWithAssignedPosts from "../fixtures/topic-with-assigned-posts";
const topic = topicWithAssignedPosts();
const firstReply = topic.post_stream.posts[1];
const secondReply = topic.post_stream.posts[2];
const new_assignee_1 = "user_1";
const new_assignee_2 = "user_2";
acceptance("Discourse Assign | Edit assignments modal", function (needs) {
needs.user({ can_assign: true });
needs.settings({
assign_enabled: true,
});
needs.pretender((server, helper) => {
server.get("/t/44.json", () => helper.response(topic));
server.put("/assign/assign", () => {
return helper.response({ success: true });
});
const suggestions = [
{ username: new_assignee_1 },
{ username: new_assignee_2 },
];
server.get("/assign/suggestions", () =>
helper.response({
assign_allowed_for_groups: [],
suggestions,
})
);
server.get("/u/search/users", () =>
helper.response({
users: suggestions,
})
);
});
test("reassigning topic", async function (assert) {
await visit("/t/assignment-topic/44");
await openModal();
await selectAssignee(new_assignee_1);
await submitModal();
await receiveAssignedMessage(topic, new_assignee_1);
assert
.dom(".post-stream article#post_1 .assigned-to .assigned-to--user a")
.hasText(new_assignee_1, "The topic is assigned to a new assignee");
});
test("reassigning posts", async function (assert) {
await visit("/t/assignment-topic/44");
await openModal();
await selectPost(1);
await expandAssigneeChooser();
await selectAssignee(new_assignee_1);
await selectPost(2);
await expandAssigneeChooser();
await selectAssignee(new_assignee_2);
await submitModal();
await receiveAssignedMessage(firstReply, new_assignee_1);
await receiveAssignedMessage(secondReply, new_assignee_2);
assert
.dom(".post-stream article#post_2 .assigned-to .assigned-to-username")
.hasText(new_assignee_1, "The first reply is assigned to a new assignee");
assert
.dom(".post-stream article#post_3 .assigned-to .assigned-to-username")
.hasText(
new_assignee_2,
"The second reply is assigned to a new assignee"
);
});
test("reassigning topic and posts in one go", async function (assert) {
await visit("/t/assignment-topic/44");
await openModal();
await selectAssignee(new_assignee_1);
await selectPost(1);
await expandAssigneeChooser();
await selectAssignee(new_assignee_2);
await selectPost(2);
await expandAssigneeChooser();
await selectAssignee(new_assignee_2);
await submitModal();
await receiveAssignedMessage(topic, new_assignee_1);
await receiveAssignedMessage(firstReply, new_assignee_2);
await receiveAssignedMessage(secondReply, new_assignee_2);
assert
.dom(".post-stream article#post_1 .assigned-to .assigned-to--user a")
.hasText(new_assignee_1, "The topic is assigned to a new assignee");
assert
.dom(".post-stream article#post_2 .assigned-to .assigned-to-username")
.hasText(new_assignee_2, "The first reply is assigned to a new assignee");
assert
.dom(".post-stream article#post_3 .assigned-to .assigned-to-username")
.hasText(
new_assignee_2,
"The second reply is assigned to a new assignee"
);
});
async function expandAssigneeChooser() {
await click(
".modal-container #assignee-chooser-header .select-kit-header-wrapper"
);
}
async function openModal() {
await click("#topic-footer-dropdown-reassign .btn");
await click(`li[data-value='reassign']`);
}
// todo remove this function and all calls to it after we start updating UI right away
// (there is no need to wait for a message bus message in the browser of a user
// who did reassignment, but we do that at the moment)
async function receiveAssignedMessage(target, newAssignee) {
const targetIsAPost = !!target.topic_id;
let topicId, postId;
if (targetIsAPost) {
topicId = target.topic_id;
postId = target.id;
} else {
topicId = target.id;
postId = false;
}
await publishToMessageBus("/staff/topic-assignment", {
type: "assigned",
topic_id: topicId,
post_id: postId,
assigned_type: "User",
assigned_to: {
username: newAssignee,
},
});
await settled();
}
async function selectPost(number) {
await click(".target .single-select .select-kit-header-wrapper");
await click(`li[title='Post #${number}']`);
}
async function selectAssignee(username) {
await click(`.email-group-user-chooser-row[data-value='${username}']`);
}
async function submitModal() {
await click(".d-modal__footer .btn-primary");
}
});

View File

@ -5,7 +5,7 @@ import {
publishToMessageBus,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
import topicWithAssignedPost from "../fixtures/topic-with-assigned-post";
import topicWithAssignedPosts from "../fixtures/topic-with-assigned-posts";
const new_assignee_username = "new_assignee";
@ -23,7 +23,7 @@ const selectors = {
},
};
const topic = topicWithAssignedPost();
const topic = topicWithAssignedPosts();
const post = topic.post_stream.posts[1];
acceptance("Discourse Assign | Post popup menu", function (needs) {

View File

@ -5,9 +5,9 @@ import {
publishToMessageBus,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
import topicWithAssignedPost from "../fixtures/topic-with-assigned-post";
import topicWithAssignedPosts from "../fixtures/topic-with-assigned-posts";
const topic = topicWithAssignedPost();
const topic = topicWithAssignedPosts();
const post = topic.post_stream.posts[1];
acceptance("Discourse Assign | Topic level assign menu", function (needs) {

View File

@ -1,20 +0,0 @@
import topicFixtures from "discourse/tests/fixtures/topic";
import { cloneJSON } from "discourse-common/lib/object";
export default function topicWithAssignedPost() {
const username = "eviltrout";
const topic = cloneJSON(topicFixtures["/t/28830/1.json"]);
const secondPost = topic.post_stream.posts[1];
topic["indirectly_assigned_to"] = {
[secondPost.id]: {
assigned_to: {
username,
},
post_number: 1,
},
};
secondPost["assigned_to_user"] = { username };
return topic;
}

View File

@ -0,0 +1,28 @@
import topicFixtures from "discourse/tests/fixtures/topic";
import { cloneJSON } from "discourse-common/lib/object";
export default function topicWithAssignedPosts() {
const username = "eviltrout";
const topic = cloneJSON(topicFixtures["/t/28830/1.json"]);
const firstReply = topic.post_stream.posts[1];
const secondReply = topic.post_stream.posts[2];
topic["indirectly_assigned_to"] = {
[firstReply.id]: {
assigned_to: {
username,
},
post_number: 1,
},
[secondReply.id]: {
assigned_to: {
username,
},
post_number: 2,
},
};
firstReply["assigned_to_user"] = { username };
secondReply["assigned_to_user"] = { username };
return topic;
}