FEATURE: Assign Status (#363)

Adds a new plugin setting that when enabled adds a status field for every assignment. This setting defaults to off.

The possible status for an assignment are customizable via yet another new setting, and the first one on this list will be the default status for new assignments.

The status is not yet show anywhere except the assign modal and the small action posts in topics at the moment. Adding status to the assignment list for users and groups will be handled in the near future.


Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
Rafael dos Santos Silva 2022-08-04 14:50:18 -03:00 committed by GitHub
parent a29a02abe7
commit 7a2fde72c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 475 additions and 32 deletions

View File

@ -56,6 +56,7 @@ module DiscourseAssign
username = params.permit(:username)["username"] username = params.permit(:username)["username"]
group_name = params.permit(:group_name)["group_name"] group_name = params.permit(:group_name)["group_name"]
note = params.permit(:note)["note"].presence note = params.permit(:note)["note"].presence
status = params.permit(:status)["status"].presence
assign_to = assign_to =
( (
@ -71,7 +72,7 @@ module DiscourseAssign
target = target_type.constantize.where(id: target_id).first target = target_type.constantize.where(id: target_id).first
raise Discourse::NotFound unless target raise Discourse::NotFound unless target
assign = Assigner.new(target, current_user).assign(assign_to, note: note) assign = Assigner.new(target, current_user).assign(assign_to, note: note, status: status)
if assign[:success] if assign[:success]
render json: success_json render json: success_json

View File

@ -15,10 +15,26 @@ class Assignment < ActiveRecord::Base
) )
} }
before_validation :default_status
validate :validate_status, if: -> { SiteSetting.enable_assign_status }
def self.valid_type?(type) def self.valid_type?(type)
VALID_TYPES.include?(type.downcase) VALID_TYPES.include?(type.downcase)
end end
def self.statuses
SiteSetting.assign_statuses.split("|")
end
def self.default_status
Assignment.statuses.first
end
def self.status_enabled?
SiteSetting.enable_assign_status
end
def assigned_to_user? def assigned_to_user?
assigned_to_type == "User" assigned_to_type == "User"
end end
@ -26,6 +42,18 @@ class Assignment < ActiveRecord::Base
def assigned_to_group? def assigned_to_group?
assigned_to_type == "Group" assigned_to_type == "Group"
end end
private
def default_status
self.status ||= Assignment.default_status if SiteSetting.enable_assign_status
end
def validate_status
if SiteSetting.enable_assign_status && !Assignment.statuses.include?(self.status)
errors.add(:status, :invalid)
end
end
end end
# == Schema Information # == Schema Information

View File

@ -1,6 +1,7 @@
import Controller, { inject as controller } from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import { action } from "@ember/object"; import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { not, or } from "@ember/object/computed"; import { not, or } from "@ember/object/computed";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
@ -42,6 +43,27 @@ export default Controller.extend(ModalFunctionality, {
}); });
}, },
@discourseComputed("siteSettings.enable_assign_status")
statusEnabled() {
return this.siteSettings.enable_assign_status;
},
@discourseComputed("siteSettings.assign_statuses")
availableStatuses() {
return this.siteSettings.assign_statuses.split("|").map((status) => {
return { id: status, name: status };
});
},
@discourseComputed("siteSettings.assign_statuses", "model.status")
status() {
return (
this.model.status ||
this.model.target.assignment_status ||
this.siteSettings.assign_statuses.split("|")[0]
);
},
@action @action
handleTextAreaKeydown(value, event) { handleTextAreaKeydown(value, event) {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
@ -82,6 +104,7 @@ export default Controller.extend(ModalFunctionality, {
target_id: this.get("model.target.id"), target_id: this.get("model.target.id"),
target_type: this.get("model.targetType"), target_type: this.get("model.targetType"),
note: this.get("model.note"), note: this.get("model.note"),
status: this.get("model.status"),
}, },
}) })
.then(() => { .then(() => {

View File

@ -577,7 +577,9 @@ function initialize(api) {
"assigned_group_to_post", "assigned_group_to_post",
"unassigned_from_post", "unassigned_from_post",
"unassigned_group_from_post", "unassigned_group_from_post",
"details_change",
"note_change", "note_change",
"status_change",
].includes(transformed.actionCode) ].includes(transformed.actionCode)
) { ) {
transformed.isSmallAction = true; transformed.isSmallAction = true;
@ -592,7 +594,8 @@ function initialize(api) {
topic.getProperties( topic.getProperties(
"assigned_to_user", "assigned_to_user",
"assigned_to_group", "assigned_to_group",
"assignment_note" "assignment_note",
"assignment_status"
) )
); );
@ -617,6 +620,7 @@ function initialize(api) {
topicAssignee, topicAssignee,
assignedToIndirectly.map((assigned) => ({ assignedToIndirectly.map((assigned) => ({
assignee: assigned.assigned_to, assignee: assigned.assigned_to,
status: assigned.assignment_status,
note: assigned.assignment_note, note: assigned.assignment_note,
})) }))
) )
@ -782,6 +786,7 @@ function initialize(api) {
const target = post || topic; const target = post || topic;
target.set("assignment_note", data.assignment_note); target.set("assignment_note", data.assignment_note);
target.set("assignment_status", data.assignment_status);
if (data.assigned_type === "User") { if (data.assigned_type === "User") {
target.set( target.set(
"assigned_to_user_id", "assigned_to_user_id",

View File

@ -38,6 +38,7 @@ export default Service.extend({
group_name: target.assigned_to_group?.name, group_name: target.assigned_to_group?.name,
target, target,
targetType: options.targetType, targetType: options.targetType,
status: target.assignment_status,
}, },
}); });
}, },
@ -49,6 +50,7 @@ export default Service.extend({
username: user.username, username: user.username,
target_id: target.id, target_id: target.id,
target_type: targetType, target_type: targetType,
status: target.assignment_status,
}, },
}); });
}, },

View File

@ -25,8 +25,21 @@
</a> </a>
{{/each}} {{/each}}
</div> </div>
<label>{{i18n "discourse_assign.assign_modal.note_label"}}</label> {{#if this.statusEnabled}}
{{textarea id="assign-modal-note" value=model.note key-down=(action "handleTextAreaKeydown")}} <div class="control-group assign-status">
<label>{{i18n "discourse_assign.assign_modal.status_label"}}</label>
{{combo-box
id="assign-status"
content=availableStatuses
value=status
onChange=(action (mut model.status))
}}
</div>
{{/if}}
<div class="control-group assign-status">
<label>{{i18n "discourse_assign.assign_modal.note_label"}}</label>
{{textarea id="assign-modal-note" value=model.note key-down=(action "handleTextAreaKeydown")}}
</div>
</div> </div>
{{/d-modal-body}} {{/d-modal-body}}

View File

@ -15,7 +15,9 @@ en:
unassigned_group_from_post: "unassigned %{who} from <a href='%{path}'>post</a> %{when}" unassigned_group_from_post: "unassigned %{who} from <a href='%{path}'>post</a> %{when}"
reassigned: "Reassigned %{who} %{when}" reassigned: "Reassigned %{who} %{when}"
reassigned_group: "Reassigned %{who} %{when}" reassigned_group: "Reassigned %{who} %{when}"
details_change: "changed assignment details for %{who} %{when}"
note_change: "changed assignment note for %{who} %{when}" note_change: "changed assignment note for %{who} %{when}"
status_change: "changed assignment status for %{who} %{when}"
discourse_assign: discourse_assign:
add_unassigned_filter: "Add 'unassigned' filter to category" add_unassigned_filter: "Add 'unassigned' filter to category"
cant_act: "You cannot act on flags that have been assigned to other users" cant_act: "You cannot act on flags that have been assigned to other users"
@ -41,11 +43,11 @@ en:
title: "Unassign from Post" title: "Unassign from Post"
help: "Unassign %{username} from Post" help: "Unassign %{username} from Post"
reassign: reassign:
title: "Reassign" title: "Edit"
title_w_ellipsis: "Edit assignment..." title_w_ellipsis: "Edit assignment..."
to_self: "Reassign to me" to_self: "Reassign to me"
to_self_help: "Reassign Topic to me" to_self_help: "Reassign Topic to me"
help: "Reassign Topic to a different user" help: "Edit assignment details"
reassign_modal: reassign_modal:
title: "Reassign Topic" title: "Reassign Topic"
description: "Enter the name of the user you'd like to Reassign this topic" description: "Enter the name of the user you'd like to Reassign this topic"
@ -55,6 +57,7 @@ en:
description: "Enter the name of the user you'd like to assign this topic" description: "Enter the name of the user you'd like to assign this topic"
assign: "Assign" assign: "Assign"
note_label: Note note_label: Note
status_label: Status
assign_post_modal: assign_post_modal:
title: "Assign Post" title: "Assign Post"
description: "Enter the name of the user you'd like to assign this post" description: "Enter the name of the user you'd like to assign this post"

View File

@ -15,6 +15,13 @@ en:
remind_assigns_frequency: "Frequency for reminding users about assigned topics." remind_assigns_frequency: "Frequency for reminding users about assigned topics."
max_assigned_topics: "Maximum number of topics that can be assigned to a user." max_assigned_topics: "Maximum number of topics that can be assigned to a user."
assign_allowed_on_groups: "Users in these groups are allowed to assign topics and can be assigned topics." assign_allowed_on_groups: "Users in these groups are allowed to assign topics and can be assigned topics."
enable_assign_status: "Add a customizable status field to every assignment."
assign_statuses: "List of statuses available to each assignment. The first status is the default status applied to every new assignment."
errors:
assign_statuses:
too_few: "There must be at least two different statuses available."
duplicate: "There are duplicate status values."
removed_in_use: "Can't remove a status from the list if there are existing assignments using this status."
discourse_assign: discourse_assign:
assigned_to: "Topic assigned to @%{username}" assigned_to: "Topic assigned to @%{username}"
unassigned: "Topic was unassigned" unassigned: "Topic was unassigned"
@ -77,3 +84,10 @@ en:
discourse_push_notifications: discourse_push_notifications:
popup: popup:
assigned: "@%{username} assigned you" assigned: "@%{username} assigned you"
activerecord:
errors:
models:
assignment:
attributes:
status:
invalid: "Selected status is invalid (it is not included in the assigned_status site setting)."

View File

@ -33,3 +33,12 @@ plugins:
default: "" default: ""
allow_any: false allow_any: false
refresh: true refresh: true
enable_assign_status:
default: false
client: true
assign_statuses:
client: true
type: list
default: "New|In Progress|Done"
allow_any: true
validator: AssignStatusesValidator

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddStatusToAssignments < ActiveRecord::Migration[6.1]
def change
add_column :assignments, :status, :text, null: true
end
end

View File

@ -195,7 +195,7 @@ class ::Assigner
topic.posts.where(post_number: 1).first topic.posts.where(post_number: 1).first
end end
def forbidden_reasons(assign_to:, type:, note:) def forbidden_reasons(assign_to:, type:, note:, status:)
case case
when assign_to.is_a?(User) && !can_assignee_see_target?(assign_to) when assign_to.is_a?(User) && !can_assignee_see_target?(assign_to)
if topic.private_message? if topic.private_message?
@ -211,9 +211,9 @@ class ::Assigner
end end
when !can_be_assigned?(assign_to) when !can_be_assigned?(assign_to)
assign_to.is_a?(User) ? :forbidden_assign_to : :forbidden_group_assign_to assign_to.is_a?(User) ? :forbidden_assign_to : :forbidden_group_assign_to
when topic_same_assignee_and_note(assign_to, type, note) when topic_same_assignee_and_details(assign_to, type, note, status)
assign_to.is_a?(User) ? :already_assigned : :group_already_assigned assign_to.is_a?(User) ? :already_assigned : :group_already_assigned
when post_same_assignee_and_note(assign_to, type, note) when post_same_assignee_and_details(assign_to, type, note, status)
assign_to.is_a?(User) ? :already_assigned : :group_already_assigned assign_to.is_a?(User) ? :already_assigned : :group_already_assigned
when Assignment.where(topic: topic).count >= ASSIGNMENTS_PER_TOPIC_LIMIT when Assignment.where(topic: topic).count >= ASSIGNMENTS_PER_TOPIC_LIMIT
:too_many_assigns_for_topic :too_many_assigns_for_topic
@ -222,32 +222,49 @@ class ::Assigner
end end
end end
def update_note(assign_to, note, skip_small_action_post: false) def update_details(assign_to, note, status, skip_small_action_post: false)
@target.assignment.update!(note: note) case
when @target.assignment.note != note && @target.assignment.status != status && status.present?
small_action_text = <<~TEXT
Status: #{@target.assignment.status} → #{status}
#{note}
TEXT
change_type = "details"
when @target.assignment.note != note
small_action_text = note
change_type = "note"
when @target.assignment.status != status
small_action_text = "#{@target.assignment.status}#{status}"
change_type = "status"
end
@target.assignment.update!(note: note, status: status)
queue_notification(assign_to, skip_small_action_post) queue_notification(assign_to, skip_small_action_post)
assignment = @target.assignment assignment = @target.assignment
publish_assignment(assignment, assign_to, note) publish_assignment(assignment, assign_to, note, status)
# email is skipped, for now # email is skipped, for now
unless skip_small_action_post unless skip_small_action_post
action_code = "note_change" action_code = "#{change_type}_change"
add_small_action_post(action_code, assign_to, note) add_small_action_post(action_code, assign_to, small_action_text)
end end
{ success: true } { success: true }
end end
def assign(assign_to, note: nil, skip_small_action_post: false) def assign(assign_to, note: nil, skip_small_action_post: false, status: nil)
assigned_to_type = assign_to.is_a?(User) ? "User" : "Group" assigned_to_type = assign_to.is_a?(User) ? "User" : "Group"
forbidden_reason = forbidden_reasons(assign_to: assign_to, type: assigned_to_type, note: note) forbidden_reason =
forbidden_reasons(assign_to: assign_to, type: assigned_to_type, note: note, status: status)
return { success: false, reason: forbidden_reason } if forbidden_reason return { success: false, reason: forbidden_reason } if forbidden_reason
if no_assignee_change?(assign_to) if no_assignee_change?(assign_to)
return update_note(assign_to, note, skip_small_action_post: skip_small_action_post) return update_details(assign_to, note, status, skip_small_action_post: skip_small_action_post)
end end
action_code = {} action_code = {}
@ -265,13 +282,14 @@ class ::Assigner
assigned_by_user_id: @assigned_by.id, assigned_by_user_id: @assigned_by.id,
topic_id: topic.id, topic_id: topic.id,
note: note, note: note,
status: status,
) )
first_post.publish_change_to_clients!(:revised, reload_topic: true) first_post.publish_change_to_clients!(:revised, reload_topic: true)
queue_notification(assign_to, skip_small_action_post) queue_notification(assign_to, skip_small_action_post)
publish_assignment(assignment, assign_to, note) publish_assignment(assignment, assign_to, note, status)
if assignment.assigned_to_user? if assignment.assigned_to_user?
if !TopicUser.exists?( if !TopicUser.exists?(
@ -413,6 +431,7 @@ class ::Assigner
post_number: post_target? && @target.post_number, post_number: post_target? && @target.post_number,
assigned_type: assignment.assigned_to.is_a?(User) ? "User" : "Group", assigned_type: assignment.assigned_to.is_a?(User) ? "User" : "Group",
assignment_note: nil, assignment_note: nil,
assignment_status: nil,
}, },
user_ids: allowed_user_ids, user_ids: allowed_user_ids,
) )
@ -433,7 +452,7 @@ class ::Assigner
) )
end end
def add_small_action_post(action_code, assign_to, note) def add_small_action_post(action_code, assign_to, text)
custom_fields = { custom_fields = {
"action_code_who" => assign_to.is_a?(User) ? assign_to.username : assign_to.name, "action_code_who" => assign_to.is_a?(User) ? assign_to.username : assign_to.name,
} }
@ -446,7 +465,7 @@ class ::Assigner
topic.add_moderator_post( topic.add_moderator_post(
@assigned_by, @assigned_by,
note, text,
bump: false, bump: false,
post_type: SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper], post_type: SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper],
action_code: action_code, action_code: action_code,
@ -454,7 +473,7 @@ class ::Assigner
) )
end end
def publish_assignment(assignment, assign_to, note) def publish_assignment(assignment, assign_to, note, status)
serializer = assignment.assigned_to_user? ? BasicUserSerializer : BasicGroupSerializer serializer = assignment.assigned_to_user? ? BasicUserSerializer : BasicGroupSerializer
MessageBus.publish( MessageBus.publish(
"/staff/topic-assignment", "/staff/topic-assignment",
@ -466,6 +485,7 @@ class ::Assigner
assigned_type: assignment.assigned_to_type, assigned_type: assignment.assigned_to_type,
assigned_to: serializer.new(assign_to, scope: Guardian.new, root: false).as_json, assigned_to: serializer.new(assign_to, scope: Guardian.new, root: false).as_json,
assignment_note: note, assignment_note: note,
assignment_status: status,
}, },
user_ids: allowed_user_ids, user_ids: allowed_user_ids,
) )
@ -491,19 +511,27 @@ class ::Assigner
return "unassigned_group#{suffix}" if assignment.assigned_to_group? return "unassigned_group#{suffix}" if assignment.assigned_to_group?
end end
def topic_same_assignee_and_note(assign_to, type, note) def topic_same_assignee_and_details(assign_to, type, note, status)
topic.assignment&.assigned_to_id == assign_to.id && topic.assignment&.assigned_to_id == assign_to.id &&
topic.assignment&.assigned_to_type == type && topic.assignment.active == true && topic.assignment&.assigned_to_type == type && topic.assignment.active == true &&
topic.assignment&.note == note topic.assignment&.note == note &&
(
topic.assignment&.status == status ||
topic.assignment&.status == Assignment.default_status && status.nil?
)
end end
def post_same_assignee_and_note(assign_to, type, note) def post_same_assignee_and_details(assign_to, type, note, status)
@target.is_a?(Topic) && @target.is_a?(Topic) &&
Assignment Assignment
.where(topic_id: topic.id, target_type: "Post", active: true) .where(topic_id: topic.id, target_type: "Post", active: true)
.any? do |assignment| .any? do |assignment|
assignment.assigned_to_id == assign_to.id && assignment.assigned_to_type == type && assignment.assigned_to_id == assign_to.id && assignment.assigned_to_type == type &&
assignment&.note == note assignment&.note == note &&
(
topic.assignment&.status == status ||
topic.assignment&.status == Assignment.default_status && status.nil?
)
end end
end end

View File

@ -32,6 +32,7 @@ module DiscourseAssign
.map do |post_id, assigned_map| .map do |post_id, assigned_map|
assigned_to = assigned_map[:assigned_to] assigned_to = assigned_map[:assigned_to]
note = assigned_map[:assignment_note] note = assigned_map[:assignment_note]
status = assigned_map[:assignment_status]
post_number = assigned_map[:post_number] post_number = assigned_map[:post_number]
if (assigned_to.is_a?(User)) if (assigned_to.is_a?(User))
@ -41,6 +42,7 @@ module DiscourseAssign
assigned_to: build_assigned_to_user(assigned_to, topic), assigned_to: build_assigned_to_user(assigned_to, topic),
post_number: post_number, post_number: post_number,
assignment_note: note, assignment_note: note,
assignment_status: status,
}, },
] ]
elsif assigned_to.is_a?(Group) elsif assigned_to.is_a?(Group)
@ -50,6 +52,7 @@ module DiscourseAssign
assigned_to: build_assigned_to_group(assigned_to, topic), assigned_to: build_assigned_to_group(assigned_to, topic),
post_number: post_number, post_number: post_number,
assignment_note: note, assignment_note: note,
assignment_status: status,
}, },
] ]
end end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class AssignStatusesValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(value)
statuses = value.split("|")
case
when statuses.size < 2
@reason = "too_few"
return false
when statuses.size != statuses.uniq.size
@reason = "duplicate"
return false
when Assignment.where.not(status: statuses).count > 0
@reason = "removed_in_use"
return false
end
true
end
def error_message
I18n.t("site_settings.errors.assign_statuses.#{@reason}")
end
end

View File

@ -19,6 +19,7 @@ register_svg_icon "user-times"
load File.expand_path("../lib/discourse_assign/engine.rb", __FILE__) load File.expand_path("../lib/discourse_assign/engine.rb", __FILE__)
load File.expand_path("../lib/discourse_assign/helpers.rb", __FILE__) load File.expand_path("../lib/discourse_assign/helpers.rb", __FILE__)
load File.expand_path("../lib/validators/assign_statuses_validator.rb", __FILE__)
Discourse::Application.routes.append do Discourse::Application.routes.append do
mount ::DiscourseAssign::Engine, at: "/assign" mount ::DiscourseAssign::Engine, at: "/assign"
@ -497,11 +498,16 @@ after_initialize do
.where(topic_id: id, target_type: "Post", active: true) .where(topic_id: id, target_type: "Post", active: true)
.includes(:target) .includes(:target)
.inject({}) do |acc, assignment| .inject({}) do |acc, assignment|
acc[assignment.target_id] = { if assignment.target
assigned_to: assignment.assigned_to, acc[assignment.target_id] = {
post_number: assignment.target.post_number, assigned_to: assignment.assigned_to,
assignment_note: assignment.note, post_number: assignment.target.post_number,
} if assignment.target assignment_note: assignment.note,
}
acc[assignment.target_id][
:assignment_status
] = assignment.status if SiteSetting.enable_assign_status
end
acc acc
end end
end end
@ -563,6 +569,13 @@ after_initialize do
(SiteSetting.assigns_public || scope.can_assign?) && object.topic.assignment.present? (SiteSetting.assigns_public || scope.can_assign?) && object.topic.assignment.present?
end end
add_to_serializer(:topic_view, :assignment_status, false) { object.topic.assignment.status }
add_to_serializer(:topic_view, :include_assignment_status?, false) do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.topic.assignment.present?
end
# SuggestedTopic serializer # SuggestedTopic serializer
add_to_serializer(:suggested_topic, :assigned_to_user, false) do add_to_serializer(:suggested_topic, :assigned_to_user, false) do
DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object) DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object)
@ -613,6 +626,13 @@ after_initialize do
(SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to&.is_a?(Group) (SiteSetting.assigns_public || scope.can_assign?) && object.assigned_to&.is_a?(Group)
end end
add_to_serializer(:topic_list_item, :assignment_status, false) { object.assignment.status }
add_to_serializer(:topic_list_item, :include_assignment_status?, false) do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment.present?
end
# SearchTopicListItem serializer # SearchTopicListItem serializer
add_to_serializer(:search_topic_list_item, :assigned_to_user, false) do add_to_serializer(:search_topic_list_item, :assigned_to_user, false) do
DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object) DiscourseAssign::Helpers.build_assigned_to_user(object.assigned_to, object)
@ -718,6 +738,13 @@ after_initialize do
(SiteSetting.assigns_public || scope.can_assign?) && object.assignment.present? (SiteSetting.assigns_public || scope.can_assign?) && object.assignment.present?
end end
add_to_serializer(:post, :assignment_status, false) { object.assignment.status }
add_to_serializer(:post, :include_assignment_status?, false) do
SiteSetting.enable_assign_status && (SiteSetting.assigns_public || scope.can_assign?) &&
object.assignment.present?
end
# CurrentUser serializer # CurrentUser serializer
add_to_serializer(:current_user, :can_assign) { object.can_assign? } add_to_serializer(:current_user, :can_assign) { object.can_assign? }

View File

@ -69,6 +69,7 @@ describe Search do
}, },
post_number: post5.post_number, post_number: post5.post_number,
assignment_note: nil, assignment_note: nil,
assignment_status: nil,
}, },
) )
end end

View File

@ -3,7 +3,10 @@
require "rails_helper" require "rails_helper"
RSpec.describe Assigner do RSpec.describe Assigner do
before { SiteSetting.assign_enabled = true } before do
SiteSetting.assign_enabled = true
SiteSetting.enable_assign_status = true
end
let(:assign_allowed_group) { Group.find_by(name: "staff") } let(:assign_allowed_group) { Group.find_by(name: "staff") }
let(:pm_post) { Fabricate(:private_message_post) } let(:pm_post) { Fabricate(:private_message_post) }
@ -53,11 +56,17 @@ RSpec.describe Assigner do
expect(topic.posts.last.raw).to eq "tomtom best mom" expect(topic.posts.last.raw).to eq "tomtom best mom"
end end
it "can assign with status" do
assigner.assign(moderator, status: "In Progress")
expect(topic.assignment.status).to eq "In Progress"
end
it "publishes topic assignment after assign and unassign" do it "publishes topic assignment after assign and unassign" do
messages = messages =
MessageBus.track_publish("/staff/topic-assignment") do MessageBus.track_publish("/staff/topic-assignment") do
assigner = described_class.new(topic, moderator_2) assigner = described_class.new(topic, moderator_2)
assigner.assign(moderator, note: "tomtom best mom") assigner.assign(moderator, note: "tomtom best mom", status: "In Progress")
assigner.unassign assigner.unassign
end end
@ -71,6 +80,7 @@ RSpec.describe Assigner do
assigned_type: "User", assigned_type: "User",
assigned_to: BasicUserSerializer.new(moderator, scope: Guardian.new, root: false).as_json, assigned_to: BasicUserSerializer.new(moderator, scope: Guardian.new, root: false).as_json,
assignment_note: "tomtom best mom", assignment_note: "tomtom best mom",
assignment_status: "In Progress",
}, },
) )
@ -83,6 +93,7 @@ RSpec.describe Assigner do
post_number: false, post_number: false,
assigned_type: "User", assigned_type: "User",
assignment_note: nil, assignment_note: nil,
assignment_status: nil,
}, },
) )
end end
@ -356,6 +367,76 @@ RSpec.describe Assigner do
expect(small_action_post.action_code).to eq "note_change" expect(small_action_post.action_code).to eq "note_change"
end end
end end
describe "updating status" do
it "does not recreate assignment if no assignee change" do
assigner.assign(moderator)
expect do assigner.assign(moderator, status: "Done") end.to_not change {
Assignment.last.id
}
end
it "updates status" do
assigner.assign(moderator)
assigner.assign(moderator, status: "Done")
expect(Assignment.last.status).to eq "Done"
end
it "queues notification" do
assigner.assign(moderator)
expect_enqueued_with(job: :assign_notification) do
assigner.assign(moderator, status: "Done")
end
end
it "publishes topic assignment with note" do
assigner.assign(moderator)
messages =
MessageBus.track_publish("/staff/topic-assignment") do
assigner = described_class.new(topic, moderator_2)
assigner.assign(moderator, status: "Done")
end
expect(messages[0].channel).to eq "/staff/topic-assignment"
expect(messages[0].data).to include(
{
type: "assigned",
topic_id: topic.id,
post_id: false,
post_number: false,
assigned_type: "User",
assigned_to:
BasicUserSerializer.new(moderator, scope: Guardian.new, root: false).as_json,
assignment_status: "Done",
},
)
end
it "adds a note_change small action post" do
assigner.assign(moderator)
assigner.assign(moderator, status: "Done")
small_action_post = topic.posts.last
expect(small_action_post.action_code).to eq "status_change"
end
end
describe "updating note and status at the same time" do
it "adds a note_change small action post" do
assigner.assign(moderator)
assigner.assign(moderator, note: "This is a note!", status: "Done")
small_action_post = topic.posts.last
expect(small_action_post.action_code).to eq "details_change"
end
end
end end
context "assign_self_regex" do context "assign_self_regex" do

View File

@ -115,6 +115,7 @@ RSpec.describe DiscourseAssign::AssignController do
before do before do
sign_in(user) sign_in(user)
add_to_assign_allowed_group(user2) add_to_assign_allowed_group(user2)
SiteSetting.enable_assign_status = true
end end
it "assigns topic to a user" do it "assigns topic to a user" do
@ -141,6 +142,29 @@ RSpec.describe DiscourseAssign::AssignController do
expect(post.topic.reload.assignment.note).to eq("do dis pls") expect(post.topic.reload.assignment.note).to eq("do dis pls")
end end
it "assigns topic with a set status to a user" do
put "/assign/assign.json",
params: {
target_id: post.topic_id,
target_type: "Topic",
username: user2.username,
status: "In Progress",
}
expect(post.topic.reload.assignment.status).to eq("In Progress")
end
it "assigns topic with default status to a user" do
put "/assign/assign.json",
params: {
target_id: post.topic_id,
target_type: "Topic",
username: user2.username,
}
expect(post.topic.reload.assignment.status).to eq("New")
end
it "assigns topic to a group" do it "assigns topic to a group" do
put "/assign/assign.json", put "/assign/assign.json",
params: { params: {

View File

@ -39,4 +39,24 @@ describe PostSerializer do
serializer = PostSerializer.new(post, scope: guardian) serializer = PostSerializer.new(post, scope: guardian)
expect(serializer.as_json[:post][:assignment_note]).to eq("tomtom best") expect(serializer.as_json[:post][:assignment_note]).to eq("tomtom best")
end end
context "when status is enabled" do
before { SiteSetting.enable_assign_status = true }
it "includes status in serializer" do
Assigner.new(post, user).assign(user, status: "Done")
serializer = PostSerializer.new(post, scope: guardian)
expect(serializer.as_json[:post][:assignment_status]).to eq("Done")
end
end
context "when status is disabled" do
before { SiteSetting.enable_assign_status = false }
it "doesn't include status in serializer" do
Assigner.new(post, user).assign(user, status: "Done")
serializer = PostSerializer.new(post, scope: guardian)
expect(serializer.as_json[:post][:assignment_status]).not_to eq("Done")
end
end
end end

View File

@ -45,4 +45,40 @@ RSpec.describe TopicViewSerializer do
serializer.as_json[:topic_view][:indirectly_assigned_to][post.id][:assignment_note], serializer.as_json[:topic_view][:indirectly_assigned_to][post.id][:assignment_note],
).to eq("note me down") ).to eq("note me down")
end end
context "when status is enabled" do
before { SiteSetting.enable_assign_status = true }
it "includes status in serializer" do
Assigner.new(topic, user).assign(user, status: "Done")
serializer = TopicViewSerializer.new(TopicView.new(topic), scope: guardian)
expect(serializer.as_json[:topic_view][:assignment_status]).to eq("Done")
end
it "includes indirectly_assigned_to status in serializer" do
Assigner.new(post, user).assign(user, status: "Done")
serializer = TopicViewSerializer.new(TopicView.new(topic), scope: guardian)
expect(
serializer.as_json[:topic_view][:indirectly_assigned_to][post.id][:assignment_status],
).to eq("Done")
end
end
context "when status is disabled" do
before { SiteSetting.enable_assign_status = false }
it "doesn't include status in serializer" do
Assigner.new(topic, user).assign(user, status: "Done")
serializer = TopicViewSerializer.new(TopicView.new(topic), scope: guardian)
expect(serializer.as_json[:topic_view][:assignment_status]).not_to eq("Done")
end
it "doesn't include indirectly_assigned_to status in serializer" do
Assigner.new(post, user).assign(user, status: "Done")
serializer = TopicViewSerializer.new(TopicView.new(topic), scope: guardian)
expect(
serializer.as_json[:topic_view][:indirectly_assigned_to][post.id][:assignment_status],
).not_to eq("Done")
end
end
end end

View File

@ -109,6 +109,92 @@ acceptance("Discourse Assign | Assign desktop", function (needs) {
}); });
}); });
acceptance("Discourse Assign | Assign Status enabled", function (needs) {
needs.user({
can_assign: true,
});
needs.settings({
assign_enabled: true,
enable_assign_status: true,
assign_statuses: "New|In Progress|Done",
});
needs.hooks.beforeEach(() => clearTopicFooterButtons());
needs.pretender((server, helper) => {
server.get("/assign/suggestions", () => {
return helper.response({
success: true,
assign_allowed_groups: false,
assign_allowed_for_groups: [],
suggestions: [
{
id: 19,
username: "eviltrout",
name: "Robin Ward",
avatar_template:
"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png",
},
],
});
});
server.put("/assign/assign", () => {
return helper.response({ success: true });
});
});
test("Modal contains status dropdown", async (assert) => {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-assign");
assert.ok(
exists(".assign.modal-body #assign-status"),
"assign status dropdown exists"
);
});
});
acceptance("Discourse Assign | Assign Status disabled", function (needs) {
needs.user({
can_assign: true,
});
needs.settings({ assign_enabled: true, enable_assign_status: false });
needs.hooks.beforeEach(() => clearTopicFooterButtons());
needs.pretender((server, helper) => {
server.get("/assign/suggestions", () => {
return helper.response({
success: true,
assign_allowed_groups: false,
assign_allowed_for_groups: [],
suggestions: [
{
id: 19,
username: "eviltrout",
name: "Robin Ward",
avatar_template:
"/user_avatar/meta.discourse.org/eviltrout/{size}/5275_2.png",
},
],
});
});
server.put("/assign/assign", () => {
return helper.response({ success: true });
});
});
test("Modal contains status dropdown", async (assert) => {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-assign");
assert.notOk(
exists(".assign.modal-body #assign-status"),
"assign status dropdown doesn't exists"
);
});
});
// See RemindAssignsFrequencySiteSettings // See RemindAssignsFrequencySiteSettings
const remindersFrequency = [ const remindersFrequency = [
{ {

View File

@ -23,6 +23,7 @@ function assignCurrentUserToTopic(needs) {
"/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png", "/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png",
}; };
topic["assignment_note"] = "Shark Doododooo"; topic["assignment_note"] = "Shark Doododooo";
topic["assignment_status"] = "New";
topic["indirectly_assigned_to"] = { topic["indirectly_assigned_to"] = {
2: { 2: {
assigned_to: { assigned_to: {
@ -89,6 +90,7 @@ acceptance("Discourse Assign | Assigned topic", function (needs) {
tagging_enabled: true, tagging_enabled: true,
assigns_user_url_path: "/", assigns_user_url_path: "/",
assigns_public: true, assigns_public: true,
enable_assign_status: true,
}); });
assignCurrentUserToTopic(needs); assignCurrentUserToTopic(needs);

View File

@ -28,6 +28,7 @@ discourseModule("Unit | Service | task-actions", function () {
group_name: "cats", group_name: "cats",
target, target,
targetType: "Topic", targetType: "Topic",
status: undefined,
}, },
}); });
}); });