mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
MultiAssigneeSelect
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
76
packages/frontend/src/components/multi-assignee-select.tsx
Normal file
76
packages/frontend/src/components/multi-assignee-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user