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

@@ -1,5 +1,5 @@
import { Issue, User } from "@issue/shared";
import { aliasedTable, and, eq, sql } from "drizzle-orm";
import { aliasedTable, and, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
export async function createIssue(
@@ -8,6 +8,7 @@ export async function createIssue(
description: string,
creatorId: number,
assigneeId?: number,
status?: string,
) {
// prevents two issues with the same unique number
return await db.transaction(async (tx) => {
@@ -30,6 +31,7 @@ export async function createIssue(
number: nextNumber,
creatorId,
assigneeId,
...(status && { status }),
})
.returning();
@@ -43,7 +45,7 @@ export async function deleteIssue(id: number) {
export async function updateIssue(
id: number,
updates: { title?: string; description?: string; assigneeId?: number | null },
updates: { title?: string; description?: string; assigneeId?: number | null; status?: string },
) {
return await db.update(Issue).set(updates).where(eq(Issue.id, id)).returning();
}
@@ -69,6 +71,27 @@ export async function getIssueByNumber(projectId: number, number: number) {
return issue;
}
export async function replaceIssueStatus(organisationId: number, oldStatus: string, newStatus: string) {
const { Project } = await import("@issue/shared");
// get all project IDs for this organisation
const projects = await db
.select({ id: Project.id })
.from(Project)
.where(eq(Project.organisationId, organisationId));
const projectIds = projects.map((p) => p.id);
if (projectIds.length === 0) return { updated: 0 };
// update all issues with oldStatus to newStatus for projects in this organisation
const result = await db
.update(Issue)
.set({ status: newStatus })
.where(and(eq(Issue.status, oldStatus), inArray(Issue.projectId, projectIds)));
return { updated: result.rowCount ?? 0 };
}
export async function getIssuesWithUsersByProject(projectId: number) {
const Creator = aliasedTable(User, "Creator");
const Assignee = aliasedTable(User, "Assignee");

View File

@@ -81,7 +81,7 @@ export async function getOrganisationsByUserId(userId: number) {
export async function updateOrganisation(
organisationId: number,
updates: { name?: string; description?: string; slug?: string },
updates: { name?: string; description?: string; slug?: string; statuses?: string[] },
) {
const [organisation] = await db
.update(Organisation)

View File

@@ -42,6 +42,7 @@ const main = async () => {
"/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))),
"/issues/by-project": withCors(withAuth(routes.issuesByProject)),
"/issues/replace-status": withCors(withAuth(withCSRF(routes.issuesReplaceStatus))),
"/issues/all": withCors(withAuth(routes.issues)),
"/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))),

View File

@@ -7,6 +7,7 @@ import issueDelete from "./issue/delete";
import issueUpdate from "./issue/update";
import issues from "./issues/all";
import issuesByProject from "./issues/by-project";
import issuesReplaceStatus from "./issues/replace-status";
import organisationAddMember from "./organisation/add-member";
import organisationById from "./organisation/by-id";
import organisationsByUser from "./organisation/by-user";
@@ -48,6 +49,7 @@ export const routes = {
issuesByProject,
issues,
issuesReplaceStatus,
organisationCreate,
organisationById,

View File

@@ -1,9 +1,9 @@
import type { AuthedRequest } from "../../auth/middleware";
import { createIssue, getProjectByID, getProjectByKey } from "../../db/queries";
// /issue/create?projectId=1&title=Testing&description=Description
// /issue/create?projectId=1&title=Testing&description=Description&status=TO%20DO
// OR
// /issue/create?projectKey=projectKey&title=Testing&description=Description
// /issue/create?projectKey=projectKey&title=Testing&description=Description&status=TO%20DO
export default async function issueCreate(req: AuthedRequest) {
const url = new URL(req.url);
const projectId = url.searchParams.get("projectId");
@@ -25,8 +25,9 @@ export default async function issueCreate(req: AuthedRequest) {
const description = url.searchParams.get("description") || "";
const assigneeIdParam = url.searchParams.get("assigneeId");
const assigneeId = assigneeIdParam ? Number(assigneeIdParam) : undefined;
const status = url.searchParams.get("status") || undefined;
const issue = await createIssue(project.id, title, description, req.userId, assigneeId);
const issue = await createIssue(project.id, title, description, req.userId, assigneeId, status);
return Response.json(issue);
}

View File

@@ -1,7 +1,7 @@
import type { BunRequest } from "bun";
import { updateIssue } from "../../db/queries";
// /issue/update?id=1&title=Testing&description=Description&assigneeId=2
// /issue/update?id=1&title=Testing&description=Description&assigneeId=2&status=IN%20PROGRESS
// assigneeId can be "null" to unassign
export default async function issueUpdate(req: BunRequest) {
const url = new URL(req.url);
@@ -13,6 +13,7 @@ export default async function issueUpdate(req: BunRequest) {
const title = url.searchParams.get("title") || undefined;
const description = url.searchParams.get("description") || undefined;
const assigneeIdParam = url.searchParams.get("assigneeId");
const status = url.searchParams.get("status") || undefined;
// Parse assigneeId: "null" means unassign, number means assign, undefined means no change
let assigneeId: number | null | undefined;
@@ -22,7 +23,7 @@ export default async function issueUpdate(req: BunRequest) {
assigneeId = Number(assigneeIdParam);
}
if (!title && !description && assigneeId === undefined) {
if (!title && !description && assigneeId === undefined && !status) {
return new Response("no updates provided", { status: 400 });
}
@@ -30,6 +31,7 @@ export default async function issueUpdate(req: BunRequest) {
title,
description,
assigneeId,
status,
});
return Response.json(issue);

View File

@@ -0,0 +1,41 @@
import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationMemberRole, replaceIssueStatus } from "../../db/queries";
// /issues/replace-status?organisationId=1&oldStatus=TO%20DO&newStatus=IN%20PROGRESS
export default async function issuesReplaceStatus(req: AuthedRequest) {
const url = new URL(req.url);
const organisationIdParam = url.searchParams.get("organisationId");
const oldStatus = url.searchParams.get("oldStatus");
const newStatus = url.searchParams.get("newStatus");
if (!organisationIdParam) {
return new Response("missing organisationId", { status: 400 });
}
if (!oldStatus) {
return new Response("missing oldStatus", { status: 400 });
}
if (!newStatus) {
return new Response("missing newStatus", { status: 400 });
}
const organisationId = Number(organisationIdParam);
if (!Number.isInteger(organisationId)) {
return new Response("organisationId must be an integer", { status: 400 });
}
// check if user is admin or owner of the organisation
const membership = await getOrganisationMemberRole(organisationId, req.userId);
if (!membership) {
return new Response("not a member of this organisation", { status: 403 });
}
if (membership.role !== "owner" && membership.role !== "admin") {
return new Response("only admins and owners can replace statuses", { status: 403 });
}
const result = await replaceIssueStatus(organisationId, oldStatus, newStatus);
return Response.json(result);
}

View File

@@ -1,13 +1,29 @@
import type { BunRequest } from "bun";
import { getOrganisationById, updateOrganisation } from "../../db/queries";
// /organisation/update?id=1&name=New%20Name&description=New%20Description&slug=new-slug
// /organisation/update?id=1&name=New%20Name&description=New%20Description&slug=new-slug&statuses=["TO DO","IN PROGRESS"]
export default async function organisationUpdate(req: BunRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const name = url.searchParams.get("name") || undefined;
const description = url.searchParams.get("description") || undefined;
const slug = url.searchParams.get("slug") || undefined;
const statusesParam = url.searchParams.get("statuses");
let statuses: string[] | undefined;
if (statusesParam) {
try {
statuses = JSON.parse(statusesParam);
if (!Array.isArray(statuses) || !statuses.every((s) => typeof s === "string")) {
return new Response("statuses must be an array of strings", { status: 400 });
}
if (statuses.length === 0) {
return new Response("statuses must have at least one status", { status: 400 });
}
} catch {
return new Response("invalid statuses format (must be JSON array)", { status: 400 });
}
}
if (!id) {
return new Response("organisation id is required", { status: 400 });
@@ -23,8 +39,8 @@ export default async function organisationUpdate(req: BunRequest) {
return new Response(`organisation with id ${id} does not exist`, { status: 404 });
}
if (!name && !description && !slug) {
return new Response("at least one of name, description, or slug must be provided", {
if (!name && !description && !slug && !statuses) {
return new Response("at least one of name, description, slug, or statuses must be provided", {
status: 400,
});
}
@@ -33,6 +49,7 @@ export default async function organisationUpdate(req: BunRequest) {
name,
description,
slug,
statuses,
});
return Response.json(organisation);

View File

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

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

View File

@@ -6,13 +6,12 @@
- dedicated /register route (currently login/register are combined on /login)
- real logo
- org settings
- customise status COLOURS (green for done, orange for in progress, white for todo, red for rejected, purple for review)
- sprints
- issues
- deadline
- comments
- admins are capable of deleting comments from members who are at their permission level or below (not sure if this should apply, or if ANYONE should have control over others' comments - people in an org tend to be trusted to not be trolls)
- status
- predefined statuses are added to organisation by default. list of statuses can be edited by owner/admin (maybe this should be on projects rather than organisations?)
- sprint
- more than one assignee
- time tracking (linked to issues or standalone)