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:
@@ -1,5 +1,5 @@
|
|||||||
import { Issue, User } from "@issue/shared";
|
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";
|
import { db } from "../client";
|
||||||
|
|
||||||
export async function createIssue(
|
export async function createIssue(
|
||||||
@@ -8,6 +8,7 @@ export async function createIssue(
|
|||||||
description: string,
|
description: string,
|
||||||
creatorId: number,
|
creatorId: number,
|
||||||
assigneeId?: number,
|
assigneeId?: number,
|
||||||
|
status?: string,
|
||||||
) {
|
) {
|
||||||
// prevents two issues with the same unique number
|
// prevents two issues with the same unique number
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
@@ -30,6 +31,7 @@ export async function createIssue(
|
|||||||
number: nextNumber,
|
number: nextNumber,
|
||||||
creatorId,
|
creatorId,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
|
...(status && { status }),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export async function deleteIssue(id: number) {
|
|||||||
|
|
||||||
export async function updateIssue(
|
export async function updateIssue(
|
||||||
id: number,
|
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();
|
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;
|
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) {
|
export async function getIssuesWithUsersByProject(projectId: number) {
|
||||||
const Creator = aliasedTable(User, "Creator");
|
const Creator = aliasedTable(User, "Creator");
|
||||||
const Assignee = aliasedTable(User, "Assignee");
|
const Assignee = aliasedTable(User, "Assignee");
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function getOrganisationsByUserId(userId: number) {
|
|||||||
|
|
||||||
export async function updateOrganisation(
|
export async function updateOrganisation(
|
||||||
organisationId: number,
|
organisationId: number,
|
||||||
updates: { name?: string; description?: string; slug?: string },
|
updates: { name?: string; description?: string; slug?: string; statuses?: string[] },
|
||||||
) {
|
) {
|
||||||
const [organisation] = await db
|
const [organisation] = await db
|
||||||
.update(Organisation)
|
.update(Organisation)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const main = async () => {
|
|||||||
"/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))),
|
"/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))),
|
||||||
|
|
||||||
"/issues/by-project": withCors(withAuth(routes.issuesByProject)),
|
"/issues/by-project": withCors(withAuth(routes.issuesByProject)),
|
||||||
|
"/issues/replace-status": withCors(withAuth(withCSRF(routes.issuesReplaceStatus))),
|
||||||
"/issues/all": withCors(withAuth(routes.issues)),
|
"/issues/all": withCors(withAuth(routes.issues)),
|
||||||
|
|
||||||
"/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))),
|
"/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import issueDelete from "./issue/delete";
|
|||||||
import issueUpdate from "./issue/update";
|
import issueUpdate from "./issue/update";
|
||||||
import issues from "./issues/all";
|
import issues from "./issues/all";
|
||||||
import issuesByProject from "./issues/by-project";
|
import issuesByProject from "./issues/by-project";
|
||||||
|
import issuesReplaceStatus from "./issues/replace-status";
|
||||||
import organisationAddMember from "./organisation/add-member";
|
import organisationAddMember from "./organisation/add-member";
|
||||||
import organisationById from "./organisation/by-id";
|
import organisationById from "./organisation/by-id";
|
||||||
import organisationsByUser from "./organisation/by-user";
|
import organisationsByUser from "./organisation/by-user";
|
||||||
@@ -48,6 +49,7 @@ export const routes = {
|
|||||||
|
|
||||||
issuesByProject,
|
issuesByProject,
|
||||||
issues,
|
issues,
|
||||||
|
issuesReplaceStatus,
|
||||||
|
|
||||||
organisationCreate,
|
organisationCreate,
|
||||||
organisationById,
|
organisationById,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AuthedRequest } from "../../auth/middleware";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { createIssue, getProjectByID, getProjectByKey } from "../../db/queries";
|
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
|
// 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) {
|
export default async function issueCreate(req: AuthedRequest) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const projectId = url.searchParams.get("projectId");
|
const projectId = url.searchParams.get("projectId");
|
||||||
@@ -25,8 +25,9 @@ export default async function issueCreate(req: AuthedRequest) {
|
|||||||
const description = url.searchParams.get("description") || "";
|
const description = url.searchParams.get("description") || "";
|
||||||
const assigneeIdParam = url.searchParams.get("assigneeId");
|
const assigneeIdParam = url.searchParams.get("assigneeId");
|
||||||
const assigneeId = assigneeIdParam ? Number(assigneeIdParam) : undefined;
|
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);
|
return Response.json(issue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { BunRequest } from "bun";
|
import type { BunRequest } from "bun";
|
||||||
import { updateIssue } from "../../db/queries";
|
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
|
// assigneeId can be "null" to unassign
|
||||||
export default async function issueUpdate(req: BunRequest) {
|
export default async function issueUpdate(req: BunRequest) {
|
||||||
const url = new URL(req.url);
|
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 title = url.searchParams.get("title") || undefined;
|
||||||
const description = url.searchParams.get("description") || undefined;
|
const description = url.searchParams.get("description") || undefined;
|
||||||
const assigneeIdParam = url.searchParams.get("assigneeId");
|
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
|
// Parse assigneeId: "null" means unassign, number means assign, undefined means no change
|
||||||
let assigneeId: number | null | undefined;
|
let assigneeId: number | null | undefined;
|
||||||
@@ -22,7 +23,7 @@ export default async function issueUpdate(req: BunRequest) {
|
|||||||
assigneeId = Number(assigneeIdParam);
|
assigneeId = Number(assigneeIdParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!title && !description && assigneeId === undefined) {
|
if (!title && !description && assigneeId === undefined && !status) {
|
||||||
return new Response("no updates provided", { status: 400 });
|
return new Response("no updates provided", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default async function issueUpdate(req: BunRequest) {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(issue);
|
return Response.json(issue);
|
||||||
|
|||||||
41
packages/backend/src/routes/issues/replace-status.ts
Normal file
41
packages/backend/src/routes/issues/replace-status.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
import type { BunRequest } from "bun";
|
import type { BunRequest } from "bun";
|
||||||
import { getOrganisationById, updateOrganisation } from "../../db/queries";
|
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) {
|
export default async function organisationUpdate(req: BunRequest) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const id = url.searchParams.get("id");
|
const id = url.searchParams.get("id");
|
||||||
const name = url.searchParams.get("name") || undefined;
|
const name = url.searchParams.get("name") || undefined;
|
||||||
const description = url.searchParams.get("description") || undefined;
|
const description = url.searchParams.get("description") || undefined;
|
||||||
const slug = url.searchParams.get("slug") || 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) {
|
if (!id) {
|
||||||
return new Response("organisation id is required", { status: 400 });
|
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 });
|
return new Response(`organisation with id ${id} does not exist`, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name && !description && !slug) {
|
if (!name && !description && !slug && !statuses) {
|
||||||
return new Response("at least one of name, description, or slug must be provided", {
|
return new Response("at least one of name, description, slug, or statuses must be provided", {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,6 +49,7 @@ export default async function organisationUpdate(req: BunRequest) {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
slug,
|
slug,
|
||||||
|
statuses,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(organisation);
|
return Response.json(organisation);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
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 { TimerModal } from "@/components/timer-modal";
|
import { TimerModal } from "@/components/timer-modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UserSelect } from "@/components/user-select";
|
import { UserSelect } from "@/components/user-select";
|
||||||
@@ -13,12 +14,14 @@ export function IssueDetailPane({
|
|||||||
project,
|
project,
|
||||||
issueData,
|
issueData,
|
||||||
members,
|
members,
|
||||||
|
statuses,
|
||||||
close,
|
close,
|
||||||
onIssueUpdate,
|
onIssueUpdate,
|
||||||
}: {
|
}: {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
issueData: IssueResponse;
|
issueData: IssueResponse;
|
||||||
members: UserRecord[];
|
members: UserRecord[];
|
||||||
|
statuses: string[];
|
||||||
close: () => void;
|
close: () => void;
|
||||||
onIssueUpdate?: () => void;
|
onIssueUpdate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -26,10 +29,12 @@ export function IssueDetailPane({
|
|||||||
const [assigneeId, setAssigneeId] = useState<string>(
|
const [assigneeId, setAssigneeId] = useState<string>(
|
||||||
issueData.Issue.assigneeId?.toString() ?? "unassigned",
|
issueData.Issue.assigneeId?.toString() ?? "unassigned",
|
||||||
);
|
);
|
||||||
|
const [status, setStatus] = useState<string>(issueData.Issue.status);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned");
|
setAssigneeId(issueData.Issue.assigneeId?.toString() ?? "unassigned");
|
||||||
}, [issueData.Issue.assigneeId]);
|
setStatus(issueData.Issue.status);
|
||||||
|
}, [issueData.Issue.assigneeId, issueData.Issue.status]);
|
||||||
|
|
||||||
const handleAssigneeChange = async (value: string) => {
|
const handleAssigneeChange = async (value: string) => {
|
||||||
setAssigneeId(value);
|
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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-row items-center justify-end border-b h-[25px]">
|
<div className="flex flex-row items-center justify-end border-b h-[25px]">
|
||||||
@@ -63,7 +84,12 @@ export function IssueDetailPane({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col w-full p-2 py-2 gap-2">
|
<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 !== "" && (
|
{issueData.Issue.description !== "" && (
|
||||||
<p className="text-sm">{issueData.Issue.description}</p>
|
<p className="text-sm">{issueData.Issue.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function IssuesTable({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
issuesData: IssueResponse[];
|
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;
|
issueSelectAction?: (issue: IssueResponse) => void;
|
||||||
className: string;
|
className: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -44,7 +44,16 @@ export function IssuesTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{(columns.title == null || columns.title === true) && (
|
{(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) && (
|
{(columns.description == null || columns.description === true) && (
|
||||||
<TableCell className="overflow-hide">{issueData.Issue.description}</TableCell>
|
<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 { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/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({
|
function OrganisationsDialog({
|
||||||
trigger,
|
trigger,
|
||||||
@@ -27,7 +30,15 @@ function OrganisationsDialog({
|
|||||||
const { user } = useAuthenticatedSession();
|
const { user } = useAuthenticatedSession();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("info");
|
||||||
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
|
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<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -46,6 +57,10 @@ function OrganisationsDialog({
|
|||||||
onConfirm: async () => {},
|
onConfirm: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
selectedOrganisation?.OrganisationMember.role === "owner" ||
|
||||||
|
selectedOrganisation?.OrganisationMember.role === "admin";
|
||||||
|
|
||||||
const refetchMembers = useCallback(async () => {
|
const refetchMembers = useCallback(async () => {
|
||||||
if (!selectedOrganisation) return;
|
if (!selectedOrganisation) return;
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void refetchMembers();
|
void refetchMembers();
|
||||||
@@ -159,126 +260,240 @@ function OrganisationsDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<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 ? (
|
{selectedOrganisation ? (
|
||||||
<div className="flex flex-col gap-2 min-w-0">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<div className="w-full border p-2 min-w-0 overflow-hidden">
|
<div className="flex gap-2 items-center">
|
||||||
<h2 className="text-xl font-600 mb-2 break-all">
|
<OrganisationSelect
|
||||||
{selectedOrganisation.Organisation.name}
|
organisations={organisations}
|
||||||
</h2>
|
selectedOrganisation={selectedOrganisation}
|
||||||
<div className="flex flex-col gap-1">
|
onSelectedOrganisationChange={(org) => {
|
||||||
<p className="text-sm text-muted-foreground break-all">
|
setSelectedOrganisation(org);
|
||||||
Slug: {selectedOrganisation.Organisation.slug}
|
localStorage.setItem(
|
||||||
</p>
|
"selectedOrganisationId",
|
||||||
<p className="text-sm text-muted-foreground break-all">
|
`${org?.Organisation.id}`,
|
||||||
Role: {selectedOrganisation.OrganisationMember.role}
|
);
|
||||||
</p>
|
}}
|
||||||
{selectedOrganisation.Organisation.description ? (
|
onCreateOrganisation={async (organisationId) => {
|
||||||
<p className="text-sm break-words">
|
await refetchOrganisations({ selectOrganisationId: organisationId });
|
||||||
{selectedOrganisation.Organisation.description}
|
}}
|
||||||
</p>
|
contentClass={
|
||||||
) : (
|
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
|
||||||
<p className="text-sm text-muted-foreground break-words">
|
}
|
||||||
No description
|
/>
|
||||||
</p>
|
<TabsList>
|
||||||
)}
|
<TabsTrigger value="info">Info</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
<TabsTrigger value="issues">Issues</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div className="border p-2 min-w-0 overflow-hidden">
|
|
||||||
<h2 className="text-xl font-600 mb-2">
|
<TabsContent value="info">
|
||||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
<div className="border p-2 min-w-0 overflow-hidden">
|
||||||
</h2>
|
<h2 className="text-xl font-600 mb-2 break-all">
|
||||||
<div className="flex flex-col gap-2 w-full">
|
{selectedOrganisation.Organisation.name}
|
||||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
</h2>
|
||||||
{members.map((member) => (
|
<div className="flex flex-col gap-1">
|
||||||
<div
|
<p className="text-sm text-muted-foreground break-all">
|
||||||
key={member.OrganisationMember.id}
|
Slug: {selectedOrganisation.Organisation.slug}
|
||||||
className="flex items-center justify-between p-2 border"
|
</p>
|
||||||
>
|
<p className="text-sm text-muted-foreground break-all">
|
||||||
<div className="flex items-center gap-2">
|
Role: {selectedOrganisation.OrganisationMember.role}
|
||||||
<SmallUserDisplay user={member.User} />
|
</p>
|
||||||
<span className="text-sm text-muted-foreground">
|
{selectedOrganisation.Organisation.description ? (
|
||||||
{member.OrganisationMember.role}
|
<p className="text-sm break-words">
|
||||||
</span>
|
{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>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
{(selectedOrganisation.OrganisationMember.role ===
|
</div>
|
||||||
"owner" ||
|
{isAdmin && (
|
||||||
selectedOrganisation.OrganisationMember.role ===
|
<AddMemberDialog
|
||||||
"admin") &&
|
organisationId={selectedOrganisation.Organisation.id}
|
||||||
member.OrganisationMember.role !== "owner" &&
|
existingMembers={members.map((m) => m.User.username)}
|
||||||
member.User.id !== user.id && (
|
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
|
<Button
|
||||||
variant="dummy"
|
variant="dummy"
|
||||||
size="none"
|
size="none"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleRoleChange(
|
handleRemoveStatusClick(status)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
aria-label="Remove status"
|
||||||
>
|
>
|
||||||
<X className="size-5 text-destructive" />
|
<X className="size-5 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
{isAdmin &&
|
||||||
{(selectedOrganisation.OrganisationMember.role === "owner" ||
|
(isCreatingStatus ? (
|
||||||
selectedOrganisation.OrganisationMember.role === "admin") && (
|
<div className="flex gap-2">
|
||||||
<AddMemberDialog
|
<Input
|
||||||
organisationId={selectedOrganisation.Organisation.id}
|
value={newStatusName}
|
||||||
existingMembers={members.map((m) => m.User.username)}
|
onChange={(e) => setNewStatusName(e.target.value)}
|
||||||
onSuccess={refetchMembers}
|
placeholder="Status name"
|
||||||
trigger={
|
className="flex-1"
|
||||||
<Button variant="outline">
|
onKeyDown={(e) => {
|
||||||
Add user <Plus className="size-4" />
|
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>
|
</Button>
|
||||||
}
|
))}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
</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
|
<ConfirmDialog
|
||||||
@@ -291,6 +506,59 @@ function OrganisationsDialog({
|
|||||||
confirmText={confirmDialog.confirmText}
|
confirmText={confirmDialog.confirmText}
|
||||||
variant={confirmDialog.variant}
|
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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,
|
label,
|
||||||
hasValue,
|
hasValue,
|
||||||
labelPosition = "top",
|
labelPosition = "top",
|
||||||
|
chevronClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -31,6 +32,7 @@ function SelectTrigger({
|
|||||||
label?: string;
|
label?: string;
|
||||||
hasValue?: boolean;
|
hasValue?: boolean;
|
||||||
labelPosition?: "top" | "bottom";
|
labelPosition?: "top" | "bottom";
|
||||||
|
chevronClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -65,7 +67,7 @@ function SelectTrigger({
|
|||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className="size-4 opacity-50"
|
className={cn("size-4 opacity-50", chevronClassName)}
|
||||||
style={{ rotate: isOpen ? "180deg" : "0deg" }}
|
style={{ rotate: isOpen ? "180deg" : "0deg" }}
|
||||||
/>
|
/>
|
||||||
</SelectPrimitive.Icon>
|
</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 (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
@@ -153,7 +162,11 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
|||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
{textClassName ? (
|
||||||
|
<span className={cn(textClassName)}>{children}</span>
|
||||||
|
) : (
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
)}
|
||||||
</SelectPrimitive.Item>
|
</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 { byProject } from "@/lib/server/issue/byProject";
|
||||||
export { create } from "@/lib/server/issue/create";
|
export { create } from "@/lib/server/issue/create";
|
||||||
|
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
|
||||||
export { update } from "@/lib/server/issue/update";
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
|
status,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
@@ -13,6 +14,7 @@ export async function update({
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
assigneeId?: number | null;
|
assigneeId?: number | null;
|
||||||
|
status?: string;
|
||||||
} & ServerQueryInput) {
|
} & ServerQueryInput) {
|
||||||
const url = new URL(`${getServerURL()}/issue/update`);
|
const url = new URL(`${getServerURL()}/issue/update`);
|
||||||
url.searchParams.set("id", `${issueId}`);
|
url.searchParams.set("id", `${issueId}`);
|
||||||
@@ -21,6 +23,7 @@ export async function update({
|
|||||||
if (assigneeId !== undefined) {
|
if (assigneeId !== undefined) {
|
||||||
url.searchParams.set("assigneeId", assigneeId === null ? "null" : `${assigneeId}`);
|
url.searchParams.set("assigneeId", assigneeId === null ? "null" : `${assigneeId}`);
|
||||||
}
|
}
|
||||||
|
if (status !== undefined) url.searchParams.set("status", status);
|
||||||
|
|
||||||
const csrfToken = getCsrfToken();
|
const csrfToken = getCsrfToken();
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { byUser } from "@/lib/server/organisation/byUser";
|
|||||||
export { create } from "@/lib/server/organisation/create";
|
export { create } from "@/lib/server/organisation/create";
|
||||||
export { members } from "@/lib/server/organisation/members";
|
export { members } from "@/lib/server/organisation/members";
|
||||||
export { removeMember } from "@/lib/server/organisation/removeMember";
|
export { removeMember } from "@/lib/server/organisation/removeMember";
|
||||||
|
export { update } from "@/lib/server/organisation/update";
|
||||||
export { updateMemberRole } from "@/lib/server/organisation/updateMemberRole";
|
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}
|
project={selectedProject}
|
||||||
issueData={selectedIssue}
|
issueData={selectedIssue}
|
||||||
members={members}
|
members={members}
|
||||||
|
statuses={
|
||||||
|
selectedOrganisation.Organisation.statuses as unknown as string[]
|
||||||
|
}
|
||||||
close={() => setSelectedIssue(null)}
|
close={() => setSelectedIssue(null)}
|
||||||
onIssueUpdate={refetchIssues}
|
onIssueUpdate={refetchIssues}
|
||||||
/>
|
/>
|
||||||
|
|||||||
3
todo.md
3
todo.md
@@ -6,13 +6,12 @@
|
|||||||
- dedicated /register route (currently login/register are combined on /login)
|
- dedicated /register route (currently login/register are combined on /login)
|
||||||
- real logo
|
- real logo
|
||||||
- org settings
|
- org settings
|
||||||
|
- customise status COLOURS (green for done, orange for in progress, white for todo, red for rejected, purple for review)
|
||||||
- sprints
|
- sprints
|
||||||
- issues
|
- issues
|
||||||
- deadline
|
- deadline
|
||||||
- comments
|
- 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)
|
- 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
|
- sprint
|
||||||
- more than one assignee
|
- more than one assignee
|
||||||
- time tracking (linked to issues or standalone)
|
- time tracking (linked to issues or standalone)
|
||||||
|
|||||||
Reference in New Issue
Block a user