MultiAssigneeSelect

This commit is contained in:
Oliver Bryan
2026-01-16 22:46:38 +00:00
parent 9334b1a4dd
commit b25f8ff96e
3 changed files with 135 additions and 29 deletions

View File

@@ -7,6 +7,7 @@ import {
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { StatusSelect } from "@/components/status-select"; import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
@@ -22,7 +23,6 @@ import {
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { UserSelect } from "@/components/user-select";
import { issue, parseError } from "@/lib/server"; import { issue, parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SprintSelect } from "./sprint-select"; import { SprintSelect } from "./sprint-select";
@@ -50,7 +50,7 @@ export function CreateIssue({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [assigneeId, setAssigneeId] = useState<string>("unassigned"); const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
const [status, setStatus] = useState<string>(Object.keys(statuses)[0] ?? ""); const [status, setStatus] = useState<string>(Object.keys(statuses)[0] ?? "");
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -60,7 +60,7 @@ export function CreateIssue({
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setSprintId("unassigned"); setSprintId("unassigned");
setAssigneeId("unassigned"); setAssigneeIds(["unassigned"]);
setStatus(statuses?.[0] ?? ""); setStatus(statuses?.[0] ?? "");
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
@@ -105,7 +105,7 @@ export function CreateIssue({
title, title,
description, description,
sprintId: sprintId === "unassigned" ? null : Number(sprintId), 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, status: status.trim() === "" ? undefined : status,
onSuccess: async (data) => { onSuccess: async (data) => {
setOpen(false); setOpen(false);
@@ -222,9 +222,13 @@ export function CreateIssue({
)} )}
{members && members.length > 0 && ( {members && members.length > 0 && (
<div className="flex items-center gap-2 mt-4"> <div className="flex items-start gap-2 mt-4">
<Label className="text-sm">Assignee</Label> <Label className="text-sm pt-2">Assignees</Label>
<UserSelect users={members} value={assigneeId} onChange={setAssigneeId} /> <MultiAssigneeSelect
users={members}
assigneeIds={assigneeIds}
onChange={setAssigneeIds}
/>
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@
import { Check, Link, Trash, X } from "lucide-react"; import { Check, Link, Trash, X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { StatusSelect } from "@/components/status-select"; import { StatusSelect } from "@/components/status-select";
@@ -11,12 +12,20 @@ import { TimerModal } from "@/components/timer-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { UserSelect } from "@/components/user-select";
import { issue } from "@/lib/server"; import { issue } from "@/lib/server";
import { issueID } from "@/lib/utils"; import { issueID } from "@/lib/utils";
import SmallSprintDisplay from "./small-sprint-display"; import SmallSprintDisplay from "./small-sprint-display";
import { SprintSelect } from "./sprint-select"; 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({ export function IssueDetailPane({
project, project,
sprints, sprints,
@@ -37,9 +46,7 @@ export function IssueDetailPane({
onIssueDelete?: (issueId: number) => void | Promise<void>; onIssueDelete?: (issueId: number) => void | Promise<void>;
}) { }) {
const { user } = useSession(); const { user } = useSession();
const [assigneeId, setAssigneeId] = useState<string>( const [assigneeIds, setAssigneeIds] = useState<string[]>(assigneesToStringArray(issueData.Assignees));
issueData.Issue.assigneeId?.toString() ?? "unassigned",
);
const [sprintId, setSprintId] = useState<string>(issueData.Issue.sprintId?.toString() ?? "unassigned"); const [sprintId, setSprintId] = useState<string>(issueData.Issue.sprintId?.toString() ?? "unassigned");
const [status, setStatus] = useState<string>(issueData.Issue.status); const [status, setStatus] = useState<string>(issueData.Issue.status);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@@ -48,9 +55,9 @@ export function IssueDetailPane({
useEffect(() => { useEffect(() => {
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned"); setAssigneeIds(assigneesToStringArray(issueData.Assignees));
setStatus(issueData.Issue.status); setStatus(issueData.Issue.status);
}, [issueData.Issue.sprintId, issueData.Issue.assigneeId, issueData.Issue.status]); }, [issueData.Issue.sprintId, issueData.Assignees, issueData.Issue.status]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -107,19 +114,36 @@ export function IssueDetailPane({
}); });
}; };
const handleAssigneeChange = async (value: string) => { const handleAssigneeChange = async (newAssigneeIds: string[]) => {
setAssigneeId(value); const previousAssigneeIds = assigneeIds;
const newAssigneeId = value === "unassigned" ? null : Number(value); 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({ await issue.update({
issueId: issueData.Issue.id, issueId: issueData.Issue.id,
assigneeId: newAssigneeId, assigneeIds: newAssigneeIdNumbers,
onSuccess: () => { 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( toast.success(
<div className={"flex items-center gap-2"}> <div className={"flex items-center gap-2"}>
Assigned {user ? <SmallUserDisplay user={user} className={"text-sm"} /> : "unknown"}{" "} Updated assignees to {displayText} for{" "}
to {issueID(project.Project.key, issueData.Issue.number)} {issueID(project.Project.key, issueData.Issue.number)}
</div>, </div>,
{ {
dismissible: false, dismissible: false,
@@ -128,10 +152,10 @@ export function IssueDetailPane({
onIssueUpdate?.(); onIssueUpdate?.();
}, },
onError: (error) => { onError: (error) => {
console.error("error updating assignee:", error); console.error("error updating assignees:", error);
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned"); setAssigneeIds(previousAssigneeIds);
toast.error(`Error updating assignee: ${error}`, { toast.error(`Error updating assignees: ${error}`, {
dismissible: false, dismissible: false,
}); });
}, },
@@ -276,13 +300,13 @@ export function IssueDetailPane({
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} /> <SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-start gap-2">
<span className="text-sm">Assignee:</span> <span className="text-sm pt-2">Assignees:</span>
<UserSelect <MultiAssigneeSelect
users={members} users={members}
value={assigneeId} assigneeIds={assigneeIds}
onChange={handleAssigneeChange} onChange={handleAssigneeChange}
fallbackUser={issueData.Assignee} fallbackUsers={issueData.Assignees}
/> />
</div> </div>
@@ -292,7 +316,9 @@ export function IssueDetailPane({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.id === Number(assigneeId) && <TimerModal issueId={issueData.Issue.id} />} {assigneeIds.some((id) => user?.id === Number(id)) && (
<TimerModal issueId={issueData.Issue.id} />
)}
<TimerDisplay issueId={issueData.Issue.id} /> <TimerDisplay issueId={issueData.Issue.id} />
</div> </div>
<ConfirmDialog <ConfirmDialog

View File

@@ -0,0 +1,76 @@
import type { UserRecord } from "@sprint/shared";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UserSelect } from "@/components/user-select";
export function MultiAssigneeSelect({
users,
assigneeIds,
onChange,
fallbackUsers = [],
}: {
users: UserRecord[];
assigneeIds: string[];
onChange: (assigneeIds: string[]) => 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 (
<div className="flex flex-col gap-1">
{assigneeIds.map((assigneeId, index) => (
<div key={`assignee-${index}-${assigneeId}`} className="flex items-center gap-1">
<UserSelect
users={getAvailableUsers(index)}
value={assigneeId}
onChange={(value) => handleAssigneeChange(index, value)}
fallbackUser={getFallbackUser(assigneeId)}
/>
{index === assigneeIds.length - 1 && canAddMore && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleAddAssignee}
title="Add assignee"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
);
}