diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index 36f2806..ab5538e 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -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"); diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index 98aefec..5711b08 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -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) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f7c2a4a..de1969c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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))), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 217398f..9450df9 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -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, diff --git a/packages/backend/src/routes/issue/create.ts b/packages/backend/src/routes/issue/create.ts index 3c90182..5478fce 100644 --- a/packages/backend/src/routes/issue/create.ts +++ b/packages/backend/src/routes/issue/create.ts @@ -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); } diff --git a/packages/backend/src/routes/issue/update.ts b/packages/backend/src/routes/issue/update.ts index e4746da..52e81ce 100644 --- a/packages/backend/src/routes/issue/update.ts +++ b/packages/backend/src/routes/issue/update.ts @@ -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); diff --git a/packages/backend/src/routes/issues/replace-status.ts b/packages/backend/src/routes/issues/replace-status.ts new file mode 100644 index 0000000..f2ba9c3 --- /dev/null +++ b/packages/backend/src/routes/issues/replace-status.ts @@ -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); +} diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index e3365f4..b76435c 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -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); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c90b6d7..28ce959 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index 10ecbb1..d9ea027 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -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( issueData.Issue.assigneeId?.toString() ?? "unassigned", ); + const [status, setStatus] = useState(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 (
@@ -63,7 +84,12 @@ export function IssueDetailPane({
-

{issueData.Issue.title}

+
+ +
+ {issueData.Issue.title} +
+
{issueData.Issue.description !== "" && (

{issueData.Issue.description}

)} diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index c0d2471..8250e70 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -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({ )} {(columns.title == null || columns.title === true) && ( - {issueData.Issue.title} + + + {(columns.status == null || columns.status === true) && ( +
+ {issueData.Issue.status} +
+ )} + {issueData.Issue.title} +
+
)} {(columns.description == null || columns.description === true) && ( {issueData.Issue.description} diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index 1261557..b9f000d 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -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([]); + + const [statuses, setStatuses] = useState([]); + const [isCreatingStatus, setIsCreatingStatus] = useState(false); + const [newStatusName, setNewStatusName] = useState(""); + const [statusToRemove, setStatusToRemove] = useState(null); + const [reassignToStatus, setReassignToStatus] = useState(""); + 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({
-
- { - 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" - } - /> -
- {selectedOrganisation ? ( -
-
-

- {selectedOrganisation.Organisation.name} -

-
-

- Slug: {selectedOrganisation.Organisation.slug} -

-

- Role: {selectedOrganisation.OrganisationMember.role} -

- {selectedOrganisation.Organisation.description ? ( -

- {selectedOrganisation.Organisation.description} -

- ) : ( -

- No description -

- )} -
+ +
+ { + 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" + } + /> + + Info + Users + Issues +
-
-

- {members.length} Member{members.length !== 1 ? "s" : ""} -

-
-
- {members.map((member) => ( -
-
- - - {member.OrganisationMember.role} - + + +
+

+ {selectedOrganisation.Organisation.name} +

+
+

+ Slug: {selectedOrganisation.Organisation.slug} +

+

+ Role: {selectedOrganisation.OrganisationMember.role} +

+ {selectedOrganisation.Organisation.description ? ( +

+ {selectedOrganisation.Organisation.description} +

+ ) : ( +

+ No description +

+ )} +
+
+
+ + +
+

+ {members.length} Member{members.length !== 1 ? "s" : ""} +

+
+
+ {members.map((member) => ( +
+
+ + + {member.OrganisationMember.role} + +
+
+ {isAdmin && + member.OrganisationMember.role !== "owner" && + member.User.id !== user.id && ( + <> + + + + )} +
-
- {(selectedOrganisation.OrganisationMember.role === - "owner" || - selectedOrganisation.OrganisationMember.role === - "admin") && - member.OrganisationMember.role !== "owner" && - member.User.id !== user.id && ( - <> + ))} +
+ {isAdmin && ( + m.User.username)} + onSuccess={refetchMembers} + trigger={ + + } + /> + )} +
+
+ + + +
+

Issue Statuses

+
+
+ {statuses.map((status, index) => ( +
+ {status} + {isAdmin && ( +
+ + + {statuses.length > 1 && ( - - - )} + )} +
+ )}
-
- ))} -
- {(selectedOrganisation.OrganisationMember.role === "owner" || - selectedOrganisation.OrganisationMember.role === "admin") && ( - m.User.username)} - onSuccess={refetchMembers} - trigger={ -
+ {isAdmin && + (isCreatingStatus ? ( +
+ 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 + /> + +
+ ) : ( + - } - /> - )} + ))} +
-
-
+ + ) : ( -

No organisations yet.

+
+ { + 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" + } + /> +

No organisations yet.

+
)} + + {/* Status removal dialog with reassignment */} + { + if (!open) { + setStatusToRemove(null); + setReassignToStatus(""); + } + }} + > + + + Remove Status + +

+ Are you sure you want to remove the "{statusToRemove}" status? Which status + would you like issues with this status to be set to? +

+ +
+ + +
+
+
diff --git a/packages/frontend/src/components/status-select.tsx b/packages/frontend/src/components/status-select.tsx new file mode 100644 index 0000000..ccf0315 --- /dev/null +++ b/packages/frontend/src/components/status-select.tsx @@ -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 ( + + ); +} diff --git a/packages/frontend/src/components/ui/select.tsx b/packages/frontend/src/components/ui/select.tsx index 90154b1..459cd85 100644 --- a/packages/frontend/src/components/ui/select.tsx +++ b/packages/frontend/src/components/ui/select.tsx @@ -24,6 +24,7 @@ function SelectTrigger({ label, hasValue, labelPosition = "top", + chevronClassName, ...props }: React.ComponentProps & { isOpen?: boolean; @@ -31,6 +32,7 @@ function SelectTrigger({ label?: string; hasValue?: boolean; labelPosition?: "top" | "bottom"; + chevronClassName?: string; }) { return ( @@ -129,7 +131,14 @@ function SelectLabel({ className, ...props }: React.ComponentProps) { +function SelectItem({ + className, + textClassName, + children, + ...props +}: React.ComponentProps & { + textClassName?: string; +}) { return ( - {children} + {textClassName ? ( + {children} + ) : ( + {children} + )} ); } diff --git a/packages/frontend/src/components/ui/tabs.tsx b/packages/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..1079253 --- /dev/null +++ b/packages/frontend/src/components/ui/tabs.tsx @@ -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) { + return ( + + ); +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/frontend/src/lib/server/issue/index.ts b/packages/frontend/src/lib/server/issue/index.ts index 67080ff..831bde0 100644 --- a/packages/frontend/src/lib/server/issue/index.ts +++ b/packages/frontend/src/lib/server/issue/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/server/issue/replaceStatus.ts b/packages/frontend/src/lib/server/issue/replaceStatus.ts new file mode 100644 index 0000000..58f3e2e --- /dev/null +++ b/packages/frontend/src/lib/server/issue/replaceStatus.ts @@ -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); + } +} diff --git a/packages/frontend/src/lib/server/issue/update.ts b/packages/frontend/src/lib/server/issue/update.ts index a0b9060..73a5f34 100644 --- a/packages/frontend/src/lib/server/issue/update.ts +++ b/packages/frontend/src/lib/server/issue/update.ts @@ -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 = {}; diff --git a/packages/frontend/src/lib/server/organisation/index.ts b/packages/frontend/src/lib/server/organisation/index.ts index 3534e7f..423ef36 100644 --- a/packages/frontend/src/lib/server/organisation/index.ts +++ b/packages/frontend/src/lib/server/organisation/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/server/organisation/update.ts b/packages/frontend/src/lib/server/organisation/update.ts new file mode 100644 index 0000000..96c0b24 --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/update.ts @@ -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); + } +} diff --git a/packages/frontend/src/pages/App.tsx b/packages/frontend/src/pages/App.tsx index 601266c..b22167c 100644 --- a/packages/frontend/src/pages/App.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -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} /> diff --git a/todo.md b/todo.md index e336027..9e48c22 100644 --- a/todo.md +++ b/todo.md @@ -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)