full status implementation

This commit is contained in:
Oliver Bryan
2026-01-10 16:26:57 +00:00
parent fb96486da8
commit 364e4e0f64
22 changed files with 711 additions and 126 deletions

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View 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 };

View File

@@ -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";

View 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);
}
}

View File

@@ -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 = {};

View File

@@ -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";

View 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);
}
}

View File

@@ -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}
/>