mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
full status implementation
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { StatusSelect } from "@/components/status-select";
|
||||
import { TimerModal } from "@/components/timer-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserSelect } from "@/components/user-select";
|
||||
@@ -13,12 +14,14 @@ export function IssueDetailPane({
|
||||
project,
|
||||
issueData,
|
||||
members,
|
||||
statuses,
|
||||
close,
|
||||
onIssueUpdate,
|
||||
}: {
|
||||
project: ProjectResponse;
|
||||
issueData: IssueResponse;
|
||||
members: UserRecord[];
|
||||
statuses: string[];
|
||||
close: () => void;
|
||||
onIssueUpdate?: () => void;
|
||||
}) {
|
||||
@@ -26,10 +29,12 @@ export function IssueDetailPane({
|
||||
const [assigneeId, setAssigneeId] = useState<string>(
|
||||
issueData.Issue.assigneeId?.toString() ?? "unassigned",
|
||||
);
|
||||
const [status, setStatus] = useState<string>(issueData.Issue.status);
|
||||
|
||||
useEffect(() => {
|
||||
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned");
|
||||
}, [issueData.Issue.assigneeId]);
|
||||
setStatus(issueData.Issue.status);
|
||||
}, [issueData.Issue.assigneeId, issueData.Issue.status]);
|
||||
|
||||
const handleAssigneeChange = async (value: string) => {
|
||||
setAssigneeId(value);
|
||||
@@ -48,6 +53,22 @@ export function IssueDetailPane({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusChange = async (value: string) => {
|
||||
setStatus(value);
|
||||
|
||||
await issue.update({
|
||||
issueId: issueData.Issue.id,
|
||||
status: value,
|
||||
onSuccess: () => {
|
||||
onIssueUpdate?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error updating status:", error);
|
||||
setStatus(issueData.Issue.status);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center justify-end border-b h-[25px]">
|
||||
@@ -63,7 +84,12 @@ export function IssueDetailPane({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full p-2 py-2 gap-2">
|
||||
<h1 className="text-md">{issueData.Issue.title}</h1>
|
||||
<div className="flex gap-2 -mt-1 -ml-1">
|
||||
<StatusSelect statuses={statuses} value={status} onChange={handleStatusChange} />
|
||||
<div className="flex w-full h-8 border-b items-center min-w-0">
|
||||
<span className="block w-full truncate">{issueData.Issue.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
{issueData.Issue.description !== "" && (
|
||||
<p className="text-sm">{issueData.Issue.description}</p>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function IssuesTable({
|
||||
className,
|
||||
}: {
|
||||
issuesData: IssueResponse[];
|
||||
columns?: { id?: boolean; title?: boolean; description?: boolean; assignee?: boolean };
|
||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||
issueSelectAction?: (issue: IssueResponse) => void;
|
||||
className: string;
|
||||
}) {
|
||||
@@ -44,7 +44,16 @@ export function IssuesTable({
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.title == null || columns.title === true) && (
|
||||
<TableCell>{issueData.Issue.title}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2 max-w-full truncate">
|
||||
{(columns.status == null || columns.status === true) && (
|
||||
<div className="text-xs px-1 bg-foreground/85 rounded text-background">
|
||||
{issueData.Issue.status}
|
||||
</div>
|
||||
)}
|
||||
{issueData.Issue.title}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.description == null || columns.description === true) && (
|
||||
<TableCell className="overflow-hide">{issueData.Issue.description}</TableCell>
|
||||
|
||||
@@ -9,7 +9,10 @@ import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { organisation } from "@/lib/server";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { issue, organisation } from "@/lib/server";
|
||||
|
||||
function OrganisationsDialog({
|
||||
trigger,
|
||||
@@ -27,7 +30,15 @@ function OrganisationsDialog({
|
||||
const { user } = useAuthenticatedSession();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
|
||||
|
||||
const [statuses, setStatuses] = useState<string[]>([]);
|
||||
const [isCreatingStatus, setIsCreatingStatus] = useState(false);
|
||||
const [newStatusName, setNewStatusName] = useState("");
|
||||
const [statusToRemove, setStatusToRemove] = useState<string | null>(null);
|
||||
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
||||
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
@@ -46,6 +57,10 @@ function OrganisationsDialog({
|
||||
onConfirm: async () => {},
|
||||
});
|
||||
|
||||
const isAdmin =
|
||||
selectedOrganisation?.OrganisationMember.role === "owner" ||
|
||||
selectedOrganisation?.OrganisationMember.role === "admin";
|
||||
|
||||
const refetchMembers = useCallback(async () => {
|
||||
if (!selectedOrganisation) return;
|
||||
try {
|
||||
@@ -138,6 +153,92 @@ function OrganisationsDialog({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOrganisation) {
|
||||
const orgStatuses = (selectedOrganisation.Organisation as unknown as { statuses: string[] })
|
||||
.statuses;
|
||||
setStatuses(
|
||||
Array.isArray(orgStatuses) ? orgStatuses : ["TO DO", "IN PROGRESS", "REVIEW", "DONE"],
|
||||
);
|
||||
}
|
||||
}, [selectedOrganisation]);
|
||||
|
||||
const updateStatuses = async (newStatuses: string[]) => {
|
||||
if (!selectedOrganisation) return;
|
||||
try {
|
||||
await organisation.update({
|
||||
organisationId: selectedOrganisation.Organisation.id,
|
||||
statuses: newStatuses,
|
||||
onSuccess: () => {
|
||||
setStatuses(newStatuses);
|
||||
void refetchOrganisations();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error updating statuses:", error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error updating statuses:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateStatus = async () => {
|
||||
const trimmed = newStatusName.trim().toUpperCase();
|
||||
if (!trimmed) return;
|
||||
if (statuses.includes(trimmed)) {
|
||||
setNewStatusName("");
|
||||
setIsCreatingStatus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newStatuses = [...statuses, trimmed];
|
||||
await updateStatuses(newStatuses);
|
||||
setNewStatusName("");
|
||||
setIsCreatingStatus(false);
|
||||
};
|
||||
|
||||
const handleRemoveStatusClick = (status: string) => {
|
||||
if (statuses.length <= 1) return;
|
||||
setStatusToRemove(status);
|
||||
const remaining = statuses.filter((s) => s !== status);
|
||||
setReassignToStatus(remaining[0] || "");
|
||||
};
|
||||
|
||||
const moveStatus = async (status: string, direction: "up" | "down") => {
|
||||
const currentIndex = statuses.indexOf(status);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (nextIndex < 0 || nextIndex >= statuses.length) return;
|
||||
|
||||
const nextStatuses = [...statuses];
|
||||
[nextStatuses[currentIndex], nextStatuses[nextIndex]] = [
|
||||
nextStatuses[nextIndex],
|
||||
nextStatuses[currentIndex],
|
||||
];
|
||||
|
||||
await updateStatuses(nextStatuses);
|
||||
};
|
||||
|
||||
const confirmRemoveStatus = async () => {
|
||||
if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return;
|
||||
|
||||
await issue.replaceStatus({
|
||||
organisationId: selectedOrganisation.Organisation.id,
|
||||
oldStatus: statusToRemove,
|
||||
newStatus: reassignToStatus,
|
||||
onSuccess: async () => {
|
||||
const newStatuses = statuses.filter((s) => s !== statusToRemove);
|
||||
await updateStatuses(newStatuses);
|
||||
setStatusToRemove(null);
|
||||
setReassignToStatus("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("error replacing status:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void refetchMembers();
|
||||
@@ -159,126 +260,240 @@ function OrganisationsDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<OrganisationSelect
|
||||
organisations={organisations}
|
||||
selectedOrganisation={selectedOrganisation}
|
||||
onSelectedOrganisationChange={(org) => {
|
||||
setSelectedOrganisation(org);
|
||||
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
|
||||
}}
|
||||
onCreateOrganisation={async (organisationId) => {
|
||||
await refetchOrganisations({ selectOrganisationId: organisationId });
|
||||
}}
|
||||
contentClass={
|
||||
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedOrganisation ? (
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
<div className="w-full border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2 break-all">
|
||||
{selectedOrganisation.Organisation.name}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground break-all">
|
||||
Slug: {selectedOrganisation.Organisation.slug}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground break-all">
|
||||
Role: {selectedOrganisation.OrganisationMember.role}
|
||||
</p>
|
||||
{selectedOrganisation.Organisation.description ? (
|
||||
<p className="text-sm break-words">
|
||||
{selectedOrganisation.Organisation.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<div className="flex gap-2 items-center">
|
||||
<OrganisationSelect
|
||||
organisations={organisations}
|
||||
selectedOrganisation={selectedOrganisation}
|
||||
onSelectedOrganisationChange={(org) => {
|
||||
setSelectedOrganisation(org);
|
||||
localStorage.setItem(
|
||||
"selectedOrganisationId",
|
||||
`${org?.Organisation.id}`,
|
||||
);
|
||||
}}
|
||||
onCreateOrganisation={async (organisationId) => {
|
||||
await refetchOrganisations({ selectOrganisationId: organisationId });
|
||||
}}
|
||||
contentClass={
|
||||
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
|
||||
}
|
||||
/>
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="issues">Issues</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.OrganisationMember.id}
|
||||
className="flex items-center justify-between p-2 border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallUserDisplay user={member.User} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{member.OrganisationMember.role}
|
||||
</span>
|
||||
|
||||
<TabsContent value="info">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2 break-all">
|
||||
{selectedOrganisation.Organisation.name}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground break-all">
|
||||
Slug: {selectedOrganisation.Organisation.slug}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground break-all">
|
||||
Role: {selectedOrganisation.OrganisationMember.role}
|
||||
</p>
|
||||
{selectedOrganisation.Organisation.description ? (
|
||||
<p className="text-sm break-words">
|
||||
{selectedOrganisation.Organisation.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
No description
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">
|
||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.OrganisationMember.id}
|
||||
className="flex items-center justify-between p-2 border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallUserDisplay user={member.User} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{member.OrganisationMember.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin &&
|
||||
member.OrganisationMember.role !== "owner" &&
|
||||
member.User.id !== user.id && (
|
||||
<>
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
onClick={() =>
|
||||
handleRoleChange(
|
||||
member.User.id,
|
||||
member.User.name,
|
||||
member.OrganisationMember
|
||||
.role,
|
||||
)
|
||||
}
|
||||
>
|
||||
{member.OrganisationMember.role ===
|
||||
"admin" ? (
|
||||
<ChevronDown className="size-5 text-yellow-500" />
|
||||
) : (
|
||||
<ChevronUp className="size-5 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
onClick={() =>
|
||||
handleRemoveMember(
|
||||
member.User.id,
|
||||
member.User.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-5 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(selectedOrganisation.OrganisationMember.role ===
|
||||
"owner" ||
|
||||
selectedOrganisation.OrganisationMember.role ===
|
||||
"admin") &&
|
||||
member.OrganisationMember.role !== "owner" &&
|
||||
member.User.id !== user.id && (
|
||||
<>
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<AddMemberDialog
|
||||
organisationId={selectedOrganisation.Organisation.id}
|
||||
existingMembers={members.map((m) => m.User.username)}
|
||||
onSuccess={refetchMembers}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
Add user <Plus className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="issues">
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">Issue Statuses</h2>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||
{statuses.map((status, index) => (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center justify-between p-2 border"
|
||||
>
|
||||
<span className="text-sm">{status}</span>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
disabled={index === 0}
|
||||
onClick={() => void moveStatus(status, "up")}
|
||||
aria-label="Move status up"
|
||||
>
|
||||
<ChevronUp className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
disabled={index === statuses.length - 1}
|
||||
onClick={() =>
|
||||
void moveStatus(status, "down")
|
||||
}
|
||||
aria-label="Move status down"
|
||||
>
|
||||
<ChevronDown className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
{statuses.length > 1 && (
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
onClick={() =>
|
||||
handleRoleChange(
|
||||
member.User.id,
|
||||
member.User.name,
|
||||
member.OrganisationMember.role,
|
||||
)
|
||||
}
|
||||
>
|
||||
{member.OrganisationMember.role ===
|
||||
"admin" ? (
|
||||
<ChevronDown className="size-5 text-yellow-500" />
|
||||
) : (
|
||||
<ChevronUp className="size-5 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="dummy"
|
||||
size="none"
|
||||
onClick={() =>
|
||||
handleRemoveMember(
|
||||
member.User.id,
|
||||
member.User.name,
|
||||
)
|
||||
handleRemoveStatusClick(status)
|
||||
}
|
||||
aria-label="Remove status"
|
||||
>
|
||||
<X className="size-5 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(selectedOrganisation.OrganisationMember.role === "owner" ||
|
||||
selectedOrganisation.OrganisationMember.role === "admin") && (
|
||||
<AddMemberDialog
|
||||
organisationId={selectedOrganisation.Organisation.id}
|
||||
existingMembers={members.map((m) => m.User.username)}
|
||||
onSuccess={refetchMembers}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
Add user <Plus className="size-4" />
|
||||
))}
|
||||
</div>
|
||||
{isAdmin &&
|
||||
(isCreatingStatus ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newStatusName}
|
||||
onChange={(e) => setNewStatusName(e.target.value)}
|
||||
placeholder="Status name"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleCreateStatus();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsCreatingStatus(false);
|
||||
setNewStatusName("");
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleCreateStatus()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreatingStatus(true)}
|
||||
>
|
||||
Create status <Plus className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No organisations yet.</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<OrganisationSelect
|
||||
organisations={organisations}
|
||||
selectedOrganisation={selectedOrganisation}
|
||||
onSelectedOrganisationChange={(org) => {
|
||||
setSelectedOrganisation(org);
|
||||
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
|
||||
}}
|
||||
onCreateOrganisation={async (organisationId) => {
|
||||
await refetchOrganisations({ selectOrganisationId: organisationId });
|
||||
}}
|
||||
contentClass={
|
||||
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">No organisations yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -291,6 +506,59 @@ function OrganisationsDialog({
|
||||
confirmText={confirmDialog.confirmText}
|
||||
variant={confirmDialog.variant}
|
||||
/>
|
||||
|
||||
{/* Status removal dialog with reassignment */}
|
||||
<Dialog
|
||||
open={statusToRemove !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setStatusToRemove(null);
|
||||
setReassignToStatus("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Status</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to remove the "{statusToRemove}" status? Which status
|
||||
would you like issues with this status to be set to?
|
||||
</p>
|
||||
<Select value={reassignToStatus} onValueChange={setReassignToStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses
|
||||
.filter((s) => s !== statusToRemove)
|
||||
.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStatusToRemove(null);
|
||||
setReassignToStatus("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmRemoveStatus()}
|
||||
disabled={!reassignToStatus}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
40
packages/frontend/src/components/status-select.tsx
Normal file
40
packages/frontend/src/components/status-select.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function StatusSelect({
|
||||
statuses,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select status",
|
||||
}: {
|
||||
statuses: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
<SelectTrigger
|
||||
className="w-fit px-2 text-xs gap-1"
|
||||
size="sm"
|
||||
chevronClassName={"size-3 -mr-1"}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SelectValue placeholder={placeholder}>{value}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
align="start"
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status} textClassName="text-xs">
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function SelectTrigger({
|
||||
label,
|
||||
hasValue,
|
||||
labelPosition = "top",
|
||||
chevronClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
isOpen?: boolean;
|
||||
@@ -31,6 +32,7 @@ function SelectTrigger({
|
||||
label?: string;
|
||||
hasValue?: boolean;
|
||||
labelPosition?: "top" | "bottom";
|
||||
chevronClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -65,7 +67,7 @@ function SelectTrigger({
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon
|
||||
className="size-4 opacity-50"
|
||||
className={cn("size-4 opacity-50", chevronClassName)}
|
||||
style={{ rotate: isOpen ? "180deg" : "0deg" }}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
@@ -129,7 +131,14 @@ function SelectLabel({ className, ...props }: React.ComponentProps<typeof Select
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
function SelectItem({
|
||||
className,
|
||||
textClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
textClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
@@ -153,7 +162,11 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{textClassName ? (
|
||||
<span className={cn(textClassName)}>{children}</span>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
56
packages/frontend/src/components/ui/tabs.tsx
Normal file
56
packages/frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"border text-muted-foreground inline-flex h-9 w-fit items-center justify-center p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring",
|
||||
"dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
|
||||
"text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)]",
|
||||
"flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1",
|
||||
"text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||
"focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none",
|
||||
"disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,3 +1,4 @@
|
||||
export { byProject } from "@/lib/server/issue/byProject";
|
||||
export { create } from "@/lib/server/issue/create";
|
||||
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
|
||||
export { update } from "@/lib/server/issue/update";
|
||||
|
||||
36
packages/frontend/src/lib/server/issue/replaceStatus.ts
Normal file
36
packages/frontend/src/lib/server/issue/replaceStatus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import type { ServerQueryInput } from "..";
|
||||
|
||||
export async function replaceStatus({
|
||||
organisationId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
organisationId: number;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
} & ServerQueryInput) {
|
||||
const url = new URL(`${getServerURL()}/issues/replace-status`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
url.searchParams.set("oldStatus", oldStatus);
|
||||
url.searchParams.set("newStatus", newStatus);
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
onError?.(error || `failed to replace status (${res.status})`);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
onSuccess?.(data, res);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export async function update({
|
||||
title,
|
||||
description,
|
||||
assigneeId,
|
||||
status,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
@@ -13,6 +14,7 @@ export async function update({
|
||||
title?: string;
|
||||
description?: string;
|
||||
assigneeId?: number | null;
|
||||
status?: string;
|
||||
} & ServerQueryInput) {
|
||||
const url = new URL(`${getServerURL()}/issue/update`);
|
||||
url.searchParams.set("id", `${issueId}`);
|
||||
@@ -21,6 +23,7 @@ export async function update({
|
||||
if (assigneeId !== undefined) {
|
||||
url.searchParams.set("assigneeId", assigneeId === null ? "null" : `${assigneeId}`);
|
||||
}
|
||||
if (status !== undefined) url.searchParams.set("status", status);
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
@@ -3,4 +3,5 @@ export { byUser } from "@/lib/server/organisation/byUser";
|
||||
export { create } from "@/lib/server/organisation/create";
|
||||
export { members } from "@/lib/server/organisation/members";
|
||||
export { removeMember } from "@/lib/server/organisation/removeMember";
|
||||
export { update } from "@/lib/server/organisation/update";
|
||||
export { updateMemberRole } from "@/lib/server/organisation/updateMemberRole";
|
||||
|
||||
42
packages/frontend/src/lib/server/organisation/update.ts
Normal file
42
packages/frontend/src/lib/server/organisation/update.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import type { ServerQueryInput } from "..";
|
||||
|
||||
export async function update({
|
||||
organisationId,
|
||||
name,
|
||||
description,
|
||||
slug,
|
||||
statuses,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
organisationId: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
statuses?: string[];
|
||||
} & ServerQueryInput) {
|
||||
const url = new URL(`${getServerURL()}/organisation/update`);
|
||||
url.searchParams.set("id", `${organisationId}`);
|
||||
if (name !== undefined) url.searchParams.set("name", name);
|
||||
if (description !== undefined) url.searchParams.set("description", description);
|
||||
if (slug !== undefined) url.searchParams.set("slug", slug);
|
||||
if (statuses !== undefined) url.searchParams.set("statuses", JSON.stringify(statuses));
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
onError?.(error || `failed to update organisation (${res.status})`);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
onSuccess?.(data, res);
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,9 @@ export default function App() {
|
||||
project={selectedProject}
|
||||
issueData={selectedIssue}
|
||||
members={members}
|
||||
statuses={
|
||||
selectedOrganisation.Organisation.statuses as unknown as string[]
|
||||
}
|
||||
close={() => setSelectedIssue(null)}
|
||||
onIssueUpdate={refetchIssues}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user