From b25f8ff96e839253e9425bc9cf5df94bd6714a41 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Fri, 16 Jan 2026 22:46:38 +0000 Subject: [PATCH] MultiAssigneeSelect --- .../frontend/src/components/create-issue.tsx | 18 +++-- .../src/components/issue-detail-pane.tsx | 70 +++++++++++------ .../src/components/multi-assignee-select.tsx | 76 +++++++++++++++++++ 3 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 packages/frontend/src/components/multi-assignee-select.tsx diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/create-issue.tsx index b5d3a65..3ec1f5e 100644 --- a/packages/frontend/src/components/create-issue.tsx +++ b/packages/frontend/src/components/create-issue.tsx @@ -7,6 +7,7 @@ import { import { type FormEvent, useState } from "react"; import { toast } from "sonner"; +import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { useAuthenticatedSession } from "@/components/session-provider"; import { StatusSelect } from "@/components/status-select"; import StatusTag from "@/components/status-tag"; @@ -22,7 +23,6 @@ import { import { Field } from "@/components/ui/field"; import { Label } from "@/components/ui/label"; import { SelectTrigger } from "@/components/ui/select"; -import { UserSelect } from "@/components/user-select"; import { issue, parseError } from "@/lib/server"; import { cn } from "@/lib/utils"; import { SprintSelect } from "./sprint-select"; @@ -50,7 +50,7 @@ export function CreateIssue({ const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [sprintId, setSprintId] = useState("unassigned"); - const [assigneeId, setAssigneeId] = useState("unassigned"); + const [assigneeIds, setAssigneeIds] = useState(["unassigned"]); const [status, setStatus] = useState(Object.keys(statuses)[0] ?? ""); const [submitAttempted, setSubmitAttempted] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -60,7 +60,7 @@ export function CreateIssue({ setTitle(""); setDescription(""); setSprintId("unassigned"); - setAssigneeId("unassigned"); + setAssigneeIds(["unassigned"]); setStatus(statuses?.[0] ?? ""); setSubmitAttempted(false); setSubmitting(false); @@ -105,7 +105,7 @@ export function CreateIssue({ title, description, sprintId: sprintId === "unassigned" ? null : Number(sprintId), - assigneeId: assigneeId === "unassigned" ? null : Number(assigneeId), + assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)), status: status.trim() === "" ? undefined : status, onSuccess: async (data) => { setOpen(false); @@ -222,9 +222,13 @@ export function CreateIssue({ )} {members && members.length > 0 && ( -
- - +
+ +
)} diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index c75f420..ab87269 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -2,6 +2,7 @@ import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@ import { Check, Link, Trash, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { useSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; import { StatusSelect } from "@/components/status-select"; @@ -11,12 +12,20 @@ import { TimerModal } from "@/components/timer-modal"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { SelectTrigger } from "@/components/ui/select"; -import { UserSelect } from "@/components/user-select"; import { issue } from "@/lib/server"; import { issueID } from "@/lib/utils"; import SmallSprintDisplay from "./small-sprint-display"; import { SprintSelect } from "./sprint-select"; +function assigneesToStringArray(assignees: UserRecord[]): string[] { + if (assignees.length === 0) return ["unassigned"]; + return assignees.map((a) => a.id.toString()); +} + +function stringArrayToAssigneeIds(assigneeIds: string[]): number[] { + return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); +} + export function IssueDetailPane({ project, sprints, @@ -37,9 +46,7 @@ export function IssueDetailPane({ onIssueDelete?: (issueId: number) => void | Promise; }) { const { user } = useSession(); - const [assigneeId, setAssigneeId] = useState( - issueData.Issue.assigneeId?.toString() ?? "unassigned", - ); + const [assigneeIds, setAssigneeIds] = useState(assigneesToStringArray(issueData.Assignees)); const [sprintId, setSprintId] = useState(issueData.Issue.sprintId?.toString() ?? "unassigned"); const [status, setStatus] = useState(issueData.Issue.status); const [deleteOpen, setDeleteOpen] = useState(false); @@ -48,9 +55,9 @@ export function IssueDetailPane({ useEffect(() => { setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); - setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned"); + setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setStatus(issueData.Issue.status); - }, [issueData.Issue.sprintId, issueData.Issue.assigneeId, issueData.Issue.status]); + }, [issueData.Issue.sprintId, issueData.Assignees, issueData.Issue.status]); useEffect(() => { return () => { @@ -107,19 +114,36 @@ export function IssueDetailPane({ }); }; - const handleAssigneeChange = async (value: string) => { - setAssigneeId(value); - const newAssigneeId = value === "unassigned" ? null : Number(value); + const handleAssigneeChange = async (newAssigneeIds: string[]) => { + const previousAssigneeIds = assigneeIds; + setAssigneeIds(newAssigneeIds); + + const newAssigneeIdNumbers = stringArrayToAssigneeIds(newAssigneeIds); + const previousAssigneeIdNumbers = stringArrayToAssigneeIds(previousAssigneeIds); + + const hasChanged = + newAssigneeIdNumbers.length !== previousAssigneeIdNumbers.length || + !newAssigneeIdNumbers.every((id) => previousAssigneeIdNumbers.includes(id)); + + if (!hasChanged) { + return; + } await issue.update({ issueId: issueData.Issue.id, - assigneeId: newAssigneeId, + assigneeIds: newAssigneeIdNumbers, onSuccess: () => { - const user = members.find((member) => member.id === newAssigneeId); + const assignedUsers = members.filter((m) => newAssigneeIdNumbers.includes(m.id)); + const displayText = + assignedUsers.length === 0 + ? "Unassigned" + : assignedUsers.length === 1 + ? assignedUsers[0].name + : `${assignedUsers.length} assignees`; toast.success(
- Assigned {user ? : "unknown"}{" "} - to {issueID(project.Project.key, issueData.Issue.number)} + Updated assignees to {displayText} for{" "} + {issueID(project.Project.key, issueData.Issue.number)}
, { dismissible: false, @@ -128,10 +152,10 @@ export function IssueDetailPane({ onIssueUpdate?.(); }, onError: (error) => { - console.error("error updating assignee:", error); - setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned"); + console.error("error updating assignees:", error); + setAssigneeIds(previousAssigneeIds); - toast.error(`Error updating assignee: ${error}`, { + toast.error(`Error updating assignees: ${error}`, { dismissible: false, }); }, @@ -276,13 +300,13 @@ export function IssueDetailPane({
-
- Assignee: - + Assignees: +
@@ -292,7 +316,9 @@ export function IssueDetailPane({
- {user?.id === Number(assigneeId) && } + {assigneeIds.some((id) => user?.id === Number(id)) && ( + + )}
void; + fallbackUsers?: UserRecord[]; +}) { + const handleAssigneeChange = (index: number, value: string) => { + // if set to "unassigned" and there are other rows, remove this row + if (value === "unassigned" && assigneeIds.length > 1) { + const newAssigneeIds = assigneeIds.filter((_, i) => i !== index); + onChange(newAssigneeIds); + return; + } + + const newAssigneeIds = [...assigneeIds]; + newAssigneeIds[index] = value; + onChange(newAssigneeIds); + }; + + const handleAddAssignee = () => { + onChange([...assigneeIds, "unassigned"]); + }; + + const getAvailableUsers = (currentIndex: number) => { + const selectedIds = assigneeIds + .filter((_, i) => i !== currentIndex) + .filter((id) => id !== "unassigned") + .map((id) => Number(id)); + return users.filter((user) => !selectedIds.includes(user.id)); + }; + + const getFallbackUser = (assigneeId: string) => { + if (assigneeId === "unassigned") return null; + return fallbackUsers.find((u) => u.id.toString() === assigneeId) || null; + }; + + const selectedCount = assigneeIds.filter((id) => id !== "unassigned").length; + const lastRowHasSelection = assigneeIds[assigneeIds.length - 1] !== "unassigned"; + const canAddMore = selectedCount < users.length && lastRowHasSelection; + + return ( +
+ {assigneeIds.map((assigneeId, index) => ( +
+ handleAssigneeChange(index, value)} + fallbackUser={getFallbackUser(assigneeId)} + /> + {index === assigneeIds.length - 1 && canAddMore && ( + + )} +
+ ))} +
+ ); +}