From 303541e6564916e6705995235f5bbd3dfbf69862 Mon Sep 17 00:00:00 2001
From: Oliver Bryan <04oliverbryan@gmail.com>
Date: Sun, 18 Jan 2026 22:30:41 +0000
Subject: [PATCH] edit + delete capabilities for org, project, sprint
---
packages/backend/src/db/queries/projects.ts | 4 +-
packages/backend/src/db/queries/sprints.ts | 58 ++-
packages/backend/src/index.ts | 2 +
packages/backend/src/routes/index.ts | 4 +
.../backend/src/routes/organisation/delete.ts | 14 +-
.../backend/src/routes/organisation/update.ts | 14 +-
packages/backend/src/routes/project/delete.ts | 22 +-
packages/backend/src/routes/project/update.ts | 24 +-
packages/backend/src/routes/sprint/delete.ts | 42 +++
packages/backend/src/routes/sprint/update.ts | 76 ++++
.../src/components/create-organisation.tsx | 219 -----------
.../src/components/create-project.tsx | 203 ----------
.../frontend/src/components/create-sprint.tsx | 275 --------------
.../{create-issue.tsx => issue-modal.tsx} | 2 +-
.../src/components/organisation-modal.tsx | 279 ++++++++++++++
.../src/components/organisation-select.tsx | 4 +-
.../src/components/organisations-dialog.tsx | 270 +++++++++++++-
.../frontend/src/components/project-modal.tsx | 268 ++++++++++++++
.../src/components/project-select.tsx | 4 +-
.../frontend/src/components/sprint-modal.tsx | 346 ++++++++++++++++++
.../frontend/src/components/ui/calendar.tsx | 4 +-
.../src/lib/server/organisation/delete.ts | 37 ++
.../src/lib/server/organisation/index.ts | 1 +
.../frontend/src/lib/server/project/delete.ts | 35 ++
.../frontend/src/lib/server/project/index.ts | 2 +
.../frontend/src/lib/server/project/update.ts | 43 +++
.../frontend/src/lib/server/sprint/delete.ts | 35 ++
.../frontend/src/lib/server/sprint/index.ts | 2 +
.../frontend/src/lib/server/sprint/update.ts | 49 +++
packages/frontend/src/pages/App.tsx | 4 +-
packages/shared/src/api-schemas.ts | 42 ++-
packages/shared/src/index.ts | 4 +
32 files changed, 1640 insertions(+), 748 deletions(-)
create mode 100644 packages/backend/src/routes/sprint/delete.ts
create mode 100644 packages/backend/src/routes/sprint/update.ts
delete mode 100644 packages/frontend/src/components/create-organisation.tsx
delete mode 100644 packages/frontend/src/components/create-project.tsx
delete mode 100644 packages/frontend/src/components/create-sprint.tsx
rename packages/frontend/src/components/{create-issue.tsx => issue-modal.tsx} (99%)
create mode 100644 packages/frontend/src/components/organisation-modal.tsx
create mode 100644 packages/frontend/src/components/project-modal.tsx
create mode 100644 packages/frontend/src/components/sprint-modal.tsx
create mode 100644 packages/frontend/src/lib/server/organisation/delete.ts
create mode 100644 packages/frontend/src/lib/server/project/delete.ts
create mode 100644 packages/frontend/src/lib/server/project/update.ts
create mode 100644 packages/frontend/src/lib/server/sprint/delete.ts
create mode 100644 packages/frontend/src/lib/server/sprint/update.ts
diff --git a/packages/backend/src/db/queries/projects.ts b/packages/backend/src/db/queries/projects.ts
index b3d2e26..8e53eab 100644
--- a/packages/backend/src/db/queries/projects.ts
+++ b/packages/backend/src/db/queries/projects.ts
@@ -1,4 +1,4 @@
-import { Issue, Organisation, Project, User } from "@sprint/shared";
+import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
import { eq } from "drizzle-orm";
import { db } from "../client";
@@ -26,6 +26,8 @@ export async function updateProject(
export async function deleteProject(projectId: number) {
// delete all of the project's issues first
await db.delete(Issue).where(eq(Issue.projectId, projectId));
+ // delete all of the project's sprints
+ await db.delete(Sprint).where(eq(Sprint.projectId, projectId));
// delete actual project
await db.delete(Project).where(eq(Project.id, projectId));
}
diff --git a/packages/backend/src/db/queries/sprints.ts b/packages/backend/src/db/queries/sprints.ts
index 3e2f496..9d8474a 100644
--- a/packages/backend/src/db/queries/sprints.ts
+++ b/packages/backend/src/db/queries/sprints.ts
@@ -1,5 +1,5 @@
-import { Sprint } from "@sprint/shared";
-import { and, eq, gte, lte } from "drizzle-orm";
+import { Issue, Sprint } from "@sprint/shared";
+import { and, desc, eq, gte, lte, ne } from "drizzle-orm";
import { db } from "../client";
export async function createSprint(
@@ -22,21 +22,55 @@ export async function createSprint(
return sprint;
}
-export async function getSprintsByProject(projectId: number) {
- return await db.select().from(Sprint).where(eq(Sprint.projectId, projectId));
+export async function getSprintById(sprintId: number) {
+ const [sprint] = await db.select().from(Sprint).where(eq(Sprint.id, sprintId));
+ return sprint;
}
-export async function hasOverlappingSprints(projectId: number, startDate: Date, endDate: Date) {
+export async function getSprintsByProject(projectId: number) {
+ return await db
+ .select()
+ .from(Sprint)
+ .where(eq(Sprint.projectId, projectId))
+ .orderBy(desc(Sprint.startDate));
+}
+
+export async function hasOverlappingSprints(
+ projectId: number,
+ startDate: Date,
+ endDate: Date,
+ excludeSprintId?: number,
+) {
+ const conditions = [
+ eq(Sprint.projectId, projectId),
+ lte(Sprint.startDate, endDate),
+ gte(Sprint.endDate, startDate),
+ ];
+
+ if (excludeSprintId !== undefined) {
+ console.log("Excluding sprint ID:", excludeSprintId);
+ conditions.push(ne(Sprint.id, excludeSprintId));
+ }
+
const overlapping = await db
.select({ id: Sprint.id })
.from(Sprint)
- .where(
- and(
- eq(Sprint.projectId, projectId),
- lte(Sprint.startDate, endDate),
- gte(Sprint.endDate, startDate),
- ),
- )
+ .where(and(...conditions))
.limit(1);
+
+ console.log(overlapping);
return overlapping.length > 0;
}
+
+export async function updateSprint(
+ sprintId: number,
+ updates: { name?: string; color?: string; startDate?: Date; endDate?: Date },
+) {
+ const [sprint] = await db.update(Sprint).set(updates).where(eq(Sprint.id, sprintId)).returning();
+ return sprint;
+}
+
+export async function deleteSprint(sprintId: number) {
+ await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
+ await db.delete(Sprint).where(eq(Sprint.id, sprintId));
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 4f7083b..1057fba 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -70,6 +70,8 @@ const main = async () => {
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
"/sprint/create": withCors(withAuth(withCSRF(routes.sprintCreate))),
+ "/sprint/update": withCors(withAuth(withCSRF(routes.sprintUpdate))),
+ "/sprint/delete": withCors(withAuth(withCSRF(routes.sprintDelete))),
"/sprints/by-project": withCors(withAuth(routes.sprintsByProject)),
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),
diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts
index 9d4e4ff..0efe957 100644
--- a/packages/backend/src/routes/index.ts
+++ b/packages/backend/src/routes/index.ts
@@ -27,6 +27,8 @@ import projectUpdate from "./project/update";
import projectWithCreator from "./project/with-creator";
import projectsWithCreators from "./project/with-creators";
import sprintCreate from "./sprint/create";
+import sprintDelete from "./sprint/delete";
+import sprintUpdate from "./sprint/update";
import sprintsByProject from "./sprints/by-project";
import timerEnd from "./timer/end";
import timerGet from "./timer/get";
@@ -78,6 +80,8 @@ export const routes = {
projectsWithCreators,
sprintCreate,
+ sprintUpdate,
+ sprintDelete,
sprintsByProject,
timerToggle,
diff --git a/packages/backend/src/routes/organisation/delete.ts b/packages/backend/src/routes/organisation/delete.ts
index 40c4eb1..cc71547 100644
--- a/packages/backend/src/routes/organisation/delete.ts
+++ b/packages/backend/src/routes/organisation/delete.ts
@@ -1,9 +1,9 @@
import { OrgDeleteRequestSchema } from "@sprint/shared";
-import type { BunRequest } from "bun";
-import { deleteOrganisation, getOrganisationById } from "../../db/queries";
+import type { AuthedRequest } from "../../auth/middleware";
+import { deleteOrganisation, getOrganisationById, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
-export default async function organisationDelete(req: BunRequest) {
+export default async function organisationDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,14 @@ export default async function organisationDelete(req: BunRequest) {
return errorResponse(`organisation with id ${id} not found`, "ORG_NOT_FOUND", 404);
}
+ const requesterMember = await getOrganisationMemberRole(id, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+ if (requesterMember.role !== "owner") {
+ return errorResponse("only owners can delete organisations", "PERMISSION_DENIED", 403);
+ }
+
await deleteOrganisation(id);
return Response.json({ success: true });
diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts
index 3e355dc..35b8639 100644
--- a/packages/backend/src/routes/organisation/update.ts
+++ b/packages/backend/src/routes/organisation/update.ts
@@ -1,9 +1,9 @@
import { OrgUpdateRequestSchema } from "@sprint/shared";
-import type { BunRequest } from "bun";
-import { getOrganisationById, updateOrganisation } from "../../db/queries";
+import type { AuthedRequest } from "../../auth/middleware";
+import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
-export default async function organisationUpdate(req: BunRequest) {
+export default async function organisationUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,14 @@ export default async function organisationUpdate(req: BunRequest) {
return errorResponse(`organisation with id ${id} does not exist`, "ORG_NOT_FOUND", 404);
}
+ const requesterMember = await getOrganisationMemberRole(id, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+ if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
+ return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
+ }
+
if (!name && !description && !slug && !statuses) {
return errorResponse(
"at least one of name, description, slug, or statuses must be provided",
diff --git a/packages/backend/src/routes/project/delete.ts b/packages/backend/src/routes/project/delete.ts
index c38c65d..f1f1f12 100644
--- a/packages/backend/src/routes/project/delete.ts
+++ b/packages/backend/src/routes/project/delete.ts
@@ -1,9 +1,9 @@
import { ProjectDeleteRequestSchema } from "@sprint/shared";
-import type { BunRequest } from "bun";
-import { deleteProject, getProjectByID } from "../../db/queries";
+import type { AuthedRequest } from "../../auth/middleware";
+import { deleteProject, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
-export default async function projectDelete(req: BunRequest) {
+export default async function projectDelete(req: AuthedRequest) {
const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +14,22 @@ export default async function projectDelete(req: BunRequest) {
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
}
+ const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+
+ const isOrgOwner = requesterMember.role === "owner";
+ const isProjectCreator = existingProject.creatorId === req.userId;
+
+ if (!isOrgOwner && !isProjectCreator) {
+ return errorResponse(
+ "only organisation owners or the project creator can delete projects",
+ "PERMISSION_DENIED",
+ 403,
+ );
+ }
+
await deleteProject(id);
return Response.json({ success: true });
diff --git a/packages/backend/src/routes/project/update.ts b/packages/backend/src/routes/project/update.ts
index 0cfa828..0495418 100644
--- a/packages/backend/src/routes/project/update.ts
+++ b/packages/backend/src/routes/project/update.ts
@@ -1,9 +1,15 @@
import { ProjectUpdateRequestSchema } from "@sprint/shared";
-import type { BunRequest } from "bun";
-import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries";
+import type { AuthedRequest } from "../../auth/middleware";
+import {
+ getOrganisationMemberRole,
+ getProjectByID,
+ getProjectByKey,
+ getUserById,
+ updateProject,
+} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
-export default async function projectUpdate(req: BunRequest) {
+export default async function projectUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
if ("error" in parsed) return parsed.error;
@@ -14,6 +20,18 @@ export default async function projectUpdate(req: BunRequest) {
return errorResponse(`project with id ${id} does not exist`, "PROJECT_NOT_FOUND", 404);
}
+ const requesterMember = await getOrganisationMemberRole(existingProject.organisationId, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+ if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
+ return errorResponse(
+ "only organisation owners and admins can edit projects",
+ "PERMISSION_DENIED",
+ 403,
+ );
+ }
+
if (!key && !name && !creatorId && !organisationId) {
return errorResponse(
"at least one of key, name, creatorId, or organisationId must be provided",
diff --git a/packages/backend/src/routes/sprint/delete.ts b/packages/backend/src/routes/sprint/delete.ts
new file mode 100644
index 0000000..16031ba
--- /dev/null
+++ b/packages/backend/src/routes/sprint/delete.ts
@@ -0,0 +1,42 @@
+import { SprintDeleteRequestSchema } from "@sprint/shared";
+import type { AuthedRequest } from "../../auth/middleware";
+import { deleteSprint, getOrganisationMemberRole, getProjectByID, getSprintById } from "../../db/queries";
+import { errorResponse, parseJsonBody } from "../../validation";
+
+export default async function sprintDelete(req: AuthedRequest) {
+ const parsed = await parseJsonBody(req, SprintDeleteRequestSchema);
+ if ("error" in parsed) return parsed.error;
+
+ const { id } = parsed.data;
+
+ const existingSprint = await getSprintById(id);
+ if (!existingSprint) {
+ return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
+ }
+
+ const project = await getProjectByID(existingSprint.projectId);
+ if (!project) {
+ return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
+ }
+
+ const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+
+ const isOrgOwner = requesterMember.role === "owner";
+ const isAdmin = requesterMember.role === "admin";
+ const isProjectCreator = project.creatorId === req.userId;
+
+ if (!isOrgOwner && !isAdmin && !isProjectCreator) {
+ return errorResponse(
+ "only organisation owners, admins, or project creators can delete sprints",
+ "PERMISSION_DENIED",
+ 403,
+ );
+ }
+
+ await deleteSprint(id);
+
+ return Response.json({ success: true });
+}
diff --git a/packages/backend/src/routes/sprint/update.ts b/packages/backend/src/routes/sprint/update.ts
new file mode 100644
index 0000000..43b1605
--- /dev/null
+++ b/packages/backend/src/routes/sprint/update.ts
@@ -0,0 +1,76 @@
+import { SprintUpdateRequestSchema } from "@sprint/shared";
+import type { AuthedRequest } from "../../auth/middleware";
+import {
+ getOrganisationMemberRole,
+ getProjectByID,
+ getSprintById,
+ hasOverlappingSprints,
+ updateSprint,
+} from "../../db/queries";
+import { errorResponse, parseJsonBody } from "../../validation";
+
+export default async function sprintUpdate(req: AuthedRequest) {
+ const parsed = await parseJsonBody(req, SprintUpdateRequestSchema);
+ if ("error" in parsed) return parsed.error;
+
+ const { id, name, color, startDate, endDate } = parsed.data;
+
+ const existingSprint = await getSprintById(id);
+ if (!existingSprint) {
+ return errorResponse(`sprint with id ${id} does not exist`, "SPRINT_NOT_FOUND", 404);
+ }
+
+ const project = await getProjectByID(existingSprint.projectId);
+ if (!project) {
+ return errorResponse("project not found", "PROJECT_NOT_FOUND", 404);
+ }
+
+ const requesterMember = await getOrganisationMemberRole(project.organisationId, req.userId);
+ if (!requesterMember) {
+ return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
+ }
+ if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
+ return errorResponse(
+ "only organisation owners and admins can edit sprints",
+ "PERMISSION_DENIED",
+ 403,
+ );
+ }
+
+ if (!name && !color && !startDate && !endDate) {
+ return errorResponse(
+ "at least one of name, color, startDate, or endDate must be provided",
+ "NO_UPDATES",
+ 400,
+ );
+ }
+
+ // validate dates if provided
+ const newStartDate = startDate ? new Date(startDate) : existingSprint.startDate;
+ const newEndDate = endDate ? new Date(endDate) : existingSprint.endDate;
+
+ if (newStartDate > newEndDate) {
+ return errorResponse("End date must be after start date", "INVALID_DATES", 400);
+ }
+
+ if (startDate || endDate) {
+ const hasOverlap = await hasOverlappingSprints(
+ project.id,
+ newStartDate,
+ newEndDate,
+ existingSprint.id,
+ );
+ if (hasOverlap) {
+ return errorResponse("Sprint dates overlap with an existing sprint", "SPRINT_OVERLAP", 400);
+ }
+ }
+
+ const sprint = await updateSprint(id, {
+ name,
+ color,
+ startDate: startDate ? newStartDate : undefined,
+ endDate: endDate ? newEndDate : undefined,
+ });
+
+ return Response.json(sprint);
+}
diff --git a/packages/frontend/src/components/create-organisation.tsx b/packages/frontend/src/components/create-organisation.tsx
deleted file mode 100644
index 1fd0a5b..0000000
--- a/packages/frontend/src/components/create-organisation.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import {
- ORG_DESCRIPTION_MAX_LENGTH,
- ORG_NAME_MAX_LENGTH,
- ORG_SLUG_MAX_LENGTH,
- type OrganisationRecord,
-} from "@sprint/shared";
-import { type FormEvent, useState } from "react";
-import { useAuthenticatedSession } from "@/components/session-provider";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Field } from "@/components/ui/field";
-import { Label } from "@/components/ui/label";
-import { organisation, parseError } from "@/lib/server";
-import { cn } from "@/lib/utils";
-
-const slugify = (value: string) =>
- value
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+/, "")
- .replace(/-{2,}/g, "-");
-
-export function CreateOrganisation({
- trigger,
- completeAction,
- errorAction,
-}: {
- trigger?: React.ReactNode;
- completeAction?: (org: OrganisationRecord) => void | Promise;
- errorAction?: (errorMessage: string) => void | Promise;
-}) {
- const { user } = useAuthenticatedSession();
-
- const [open, setOpen] = useState(false);
- const [name, setName] = useState("");
- const [slug, setSlug] = useState("");
- const [description, setDescription] = useState("");
- const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
- const [submitAttempted, setSubmitAttempted] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState(null);
-
- const reset = () => {
- setName("");
- setSlug("");
- setDescription("");
- setSlugManuallyEdited(false);
- setSubmitAttempted(false);
- setSubmitting(false);
- setError(null);
- };
-
- const onOpenChange = (nextOpen: boolean) => {
- setOpen(nextOpen);
- if (!nextOpen) {
- reset();
- }
- };
-
- const handleSubmit = async (e: FormEvent) => {
- e.preventDefault();
- setError(null);
- setSubmitAttempted(true);
-
- if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
- if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
- if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
-
- if (!user.id) {
- setError("you must be logged in to create an organisation");
- return;
- }
-
- setSubmitting(true);
- try {
- await organisation.create({
- name,
- slug,
- description,
- onSuccess: async (data) => {
- setOpen(false);
- reset();
- try {
- await completeAction?.(data);
- } catch (actionErr) {
- console.error(actionErr);
- }
- },
- onError: async (err) => {
- const message = parseError(err);
- setError(message || "failed to create organisation");
- setSubmitting(false);
- try {
- await errorAction?.(message || "failed to create organisation");
- } catch (actionErr) {
- console.error(actionErr);
- }
- },
- });
- } catch (err) {
- console.error(err);
- setError("failed to create organisation");
- setSubmitting(false);
- }
- };
-
- return (
-
- );
-}
diff --git a/packages/frontend/src/components/create-project.tsx b/packages/frontend/src/components/create-project.tsx
deleted file mode 100644
index 40bc81c..0000000
--- a/packages/frontend/src/components/create-project.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { PROJECT_NAME_MAX_LENGTH, type ProjectRecord } from "@sprint/shared";
-import { type FormEvent, useState } from "react";
-import { toast } from "sonner";
-import { useAuthenticatedSession } from "@/components/session-provider";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Field } from "@/components/ui/field";
-import { Label } from "@/components/ui/label";
-import { parseError, project } from "@/lib/server";
-import { cn } from "@/lib/utils";
-
-const keyify = (value: string) =>
- value
- .toUpperCase()
- .replace(/[^A-Z0-9]/g, "")
- .slice(0, 4);
-
-export function CreateProject({
- organisationId,
- trigger,
- completeAction,
-}: {
- organisationId?: number;
- trigger?: React.ReactNode;
- completeAction?: (project: ProjectRecord) => void | Promise;
-}) {
- const { user } = useAuthenticatedSession();
-
- const [open, setOpen] = useState(false);
- const [name, setName] = useState("");
- const [key, setKey] = useState("");
- const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
- const [submitAttempted, setSubmitAttempted] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState(null);
-
- const reset = () => {
- setName("");
- setKey("");
- setKeyManuallyEdited(false);
- setSubmitAttempted(false);
- setSubmitting(false);
- setError(null);
- };
-
- const onOpenChange = (nextOpen: boolean) => {
- setOpen(nextOpen);
- if (!nextOpen) {
- reset();
- }
- };
-
- const handleSubmit = async (e: FormEvent) => {
- e.preventDefault();
- setError(null);
- setSubmitAttempted(true);
-
- if (
- name.trim() === "" ||
- name.trim().length > PROJECT_NAME_MAX_LENGTH ||
- key.trim() === "" ||
- key.length > 4
- ) {
- return;
- }
-
- if (!user.id) {
- setError("you must be logged in to create a project");
- return;
- }
-
- if (!organisationId) {
- setError("select an organisation first");
- return;
- }
-
- setSubmitting(true);
- try {
- await project.create({
- key,
- name,
- organisationId,
- onSuccess: async (data) => {
- const proj = data as ProjectRecord;
-
- setOpen(false);
- reset();
- try {
- await completeAction?.(proj);
- } catch (actionErr) {
- console.error(actionErr);
- }
- },
- onError: (err) => {
- const message = parseError(err);
- setError(message);
- setSubmitting(false);
-
- toast.error(`Error creating project: ${message}`, {
- dismissible: false,
- });
- },
- });
- } catch (err) {
- console.error(err);
- setError("failed to create project");
- setSubmitting(false);
- }
- };
-
- return (
-
- );
-}
diff --git a/packages/frontend/src/components/create-sprint.tsx b/packages/frontend/src/components/create-sprint.tsx
deleted file mode 100644
index 885e9bd..0000000
--- a/packages/frontend/src/components/create-sprint.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
-import { type FormEvent, useMemo, useState } from "react";
-import { toast } from "sonner";
-import { useAuthenticatedSession } from "@/components/session-provider";
-import { Button } from "@/components/ui/button";
-import { Calendar } from "@/components/ui/calendar";
-import ColourPicker from "@/components/ui/colour-picker";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Field } from "@/components/ui/field";
-import { Label } from "@/components/ui/label";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { parseError, sprint } from "@/lib/server";
-import { cn } from "@/lib/utils";
-
-const SPRINT_NAME_MAX_LENGTH = 64;
-
-const getStartOfDay = (date: Date) => {
- const next = new Date(date);
- next.setHours(0, 0, 0, 0);
- return next;
-};
-
-const getEndOfDay = (date: Date) => {
- const next = new Date(date);
- next.setHours(23, 59, 0, 0);
- return next;
-};
-
-const addDays = (date: Date, days: number) => {
- const next = new Date(date);
- next.setDate(next.getDate() + days);
- return next;
-};
-
-const getDefaultDates = () => {
- const today = new Date();
- return {
- start: getStartOfDay(today),
- end: getEndOfDay(addDays(today, 14)),
- };
-};
-
-export function CreateSprint({
- projectId,
- sprints,
- trigger,
- completeAction,
-}: {
- projectId?: number;
- sprints: SprintRecord[];
- trigger?: React.ReactNode;
- completeAction?: (sprint: SprintRecord) => void | Promise;
-}) {
- const { user } = useAuthenticatedSession();
-
- const { start, end } = getDefaultDates();
- const [open, setOpen] = useState(false);
- const [name, setName] = useState("");
- const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
- const [startDate, setStartDate] = useState(start);
- const [endDate, setEndDate] = useState(end);
- const [submitAttempted, setSubmitAttempted] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState(null);
-
- const dateError = useMemo(() => {
- if (!submitAttempted) return "";
- if (startDate > endDate) {
- return "End date must be after start date";
- }
- return "";
- }, [endDate, startDate, submitAttempted]);
-
- const reset = () => {
- const defaults = getDefaultDates();
- setName("");
- setColour(DEFAULT_SPRINT_COLOUR);
- setStartDate(defaults.start);
- setEndDate(defaults.end);
- setSubmitAttempted(false);
- setSubmitting(false);
- setError(null);
- };
-
- const onOpenChange = (nextOpen: boolean) => {
- setOpen(nextOpen);
- if (!nextOpen) {
- reset();
- }
- };
-
- const handleSubmit = async (event: FormEvent) => {
- event.preventDefault();
- setError(null);
- setSubmitAttempted(true);
-
- if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
- return;
- }
-
- if (startDate > endDate) {
- return;
- }
-
- if (!user.id) {
- setError("you must be logged in to create a sprint");
- return;
- }
-
- if (!projectId) {
- setError("select a project first");
- return;
- }
-
- setSubmitting(true);
-
- try {
- await sprint.create({
- projectId,
- name,
- color: colour, // hm - always unsure which i should use
- startDate,
- endDate,
- onSuccess: async (data) => {
- setOpen(false);
- reset();
- try {
- await completeAction?.(data);
- } catch (actionErr) {
- console.error(actionErr);
- }
- },
- onError: (err) => {
- const message = parseError(err);
- setError(message);
- setSubmitting(false);
-
- toast.error(`Error creating sprint: ${message}`, {
- dismissible: false,
- });
- },
- });
- } catch (submitError) {
- console.error(submitError);
- setError("failed to create sprint");
- setSubmitting(false);
- }
- };
-
- return (
-
- );
-}
diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/issue-modal.tsx
similarity index 99%
rename from packages/frontend/src/components/create-issue.tsx
rename to packages/frontend/src/components/issue-modal.tsx
index 3ec1f5e..e9f348f 100644
--- a/packages/frontend/src/components/create-issue.tsx
+++ b/packages/frontend/src/components/issue-modal.tsx
@@ -27,7 +27,7 @@ import { issue, parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
import { SprintSelect } from "./sprint-select";
-export function CreateIssue({
+export function IssueModal({
projectId,
sprints,
members,
diff --git a/packages/frontend/src/components/organisation-modal.tsx b/packages/frontend/src/components/organisation-modal.tsx
new file mode 100644
index 0000000..3f80e29
--- /dev/null
+++ b/packages/frontend/src/components/organisation-modal.tsx
@@ -0,0 +1,279 @@
+import {
+ ORG_DESCRIPTION_MAX_LENGTH,
+ ORG_NAME_MAX_LENGTH,
+ ORG_SLUG_MAX_LENGTH,
+ type OrganisationRecord,
+} from "@sprint/shared";
+import { type FormEvent, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useAuthenticatedSession } from "@/components/session-provider";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Field } from "@/components/ui/field";
+import { Label } from "@/components/ui/label";
+import { organisation, parseError } from "@/lib/server";
+import { cn } from "@/lib/utils";
+
+const slugify = (value: string) =>
+ value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+/, "")
+ .replace(/-{2,}/g, "-");
+
+export function OrganisationModal({
+ trigger,
+ completeAction,
+ errorAction,
+ mode = "create",
+ existingOrganisation,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+}: {
+ trigger?: React.ReactNode;
+ completeAction?: (org: OrganisationRecord) => void | Promise;
+ errorAction?: (errorMessage: string) => void | Promise;
+ mode?: "create" | "edit";
+ existingOrganisation?: OrganisationRecord;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { user } = useAuthenticatedSession();
+
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
+
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [description, setDescription] = useState("");
+ const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isEdit = mode === "edit";
+
+ useEffect(() => {
+ if (isEdit && existingOrganisation && open) {
+ setName(existingOrganisation.name);
+ setSlug(existingOrganisation.slug);
+ setDescription(existingOrganisation.description ?? "");
+ setSlugManuallyEdited(true);
+ }
+ }, [isEdit, existingOrganisation, open]);
+
+ const reset = () => {
+ setName("");
+ setSlug("");
+ setDescription("");
+ setSlugManuallyEdited(false);
+ setSubmitAttempted(false);
+ setSubmitting(false);
+ setError(null);
+ };
+
+ const onOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ reset();
+ }
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setSubmitAttempted(true);
+
+ if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
+ if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
+ if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
+
+ if (!user.id) {
+ setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`);
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ if (isEdit && existingOrganisation) {
+ await organisation.update({
+ organisationId: existingOrganisation.id,
+ name,
+ slug,
+ description,
+ onSuccess: async (data) => {
+ setOpen(false);
+ reset();
+ toast.success("Organisation updated");
+ try {
+ await completeAction?.(data);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: async (err) => {
+ const message = parseError(err);
+ setError(message || "failed to update organisation");
+ setSubmitting(false);
+ try {
+ await errorAction?.(message || "failed to update organisation");
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ });
+ } else {
+ await organisation.create({
+ name,
+ slug,
+ description,
+ onSuccess: async (data) => {
+ setOpen(false);
+ reset();
+ try {
+ await completeAction?.(data);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: async (err) => {
+ const message = parseError(err);
+ setError(message || "failed to create organisation");
+ setSubmitting(false);
+ try {
+ await errorAction?.(message || "failed to create organisation");
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ });
+ }
+ } catch (err) {
+ console.error(err);
+ setError(`failed to ${isEdit ? "update" : "create"} organisation`);
+ setSubmitting(false);
+ }
+ };
+
+ const dialogContent = (
+
+
+ {isEdit ? "Edit Organisation" : "Create Organisation"}
+
+
+
+
+ );
+
+ if (isControlled) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx
index 3121c66..cf64139 100644
--- a/packages/frontend/src/components/organisation-select.tsx
+++ b/packages/frontend/src/components/organisation-select.tsx
@@ -1,7 +1,7 @@
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
import { useState } from "react";
import { toast } from "sonner";
-import { CreateOrganisation } from "@/components/create-organisation";
+import { OrganisationModal } from "@/components/organisation-modal";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -74,7 +74,7 @@ export function OrganisationSelect({
{organisations.length > 0 && }
-
Create Organisation
diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx
index a0d8ac3..dcfa912 100644
--- a/packages/frontend/src/components/organisations-dialog.tsx
+++ b/packages/frontend/src/components/organisations-dialog.tsx
@@ -10,12 +10,14 @@ import {
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { AddMemberDialog } from "@/components/add-member-dialog";
-import { CreateSprint } from "@/components/create-sprint";
+import { OrganisationModal } from "@/components/organisation-modal";
import { OrganisationSelect } from "@/components/organisation-select";
+import { ProjectModal } from "@/components/project-modal";
import { ProjectSelect } from "@/components/project-select";
import { useAuthenticatedSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-display";
+import { SprintModal } from "@/components/sprint-modal";
import StatusTag from "@/components/status-tag";
import { Button } from "@/components/ui/button";
import ColourPicker from "@/components/ui/colour-picker";
@@ -32,7 +34,7 @@ import { IconButton } from "@/components/ui/icon-button";
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";
+import { issue, organisation, project, sprint } from "@/lib/server";
import { capitalise } from "@/lib/utils";
function OrganisationsDialog({
@@ -75,6 +77,12 @@ function OrganisationsDialog({
const [issuesUsingStatus, setIssuesUsingStatus] = useState(0);
const [reassignToStatus, setReassignToStatus] = useState("");
+ // edit/delete state for organisations, projects, and sprints
+ const [editOrgOpen, setEditOrgOpen] = useState(false);
+ const [editProjectOpen, setEditProjectOpen] = useState(false);
+ const [editSprintOpen, setEditSprintOpen] = useState(false);
+ const [editingSprint, setEditingSprint] = useState(null);
+
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
title: string;
@@ -97,6 +105,10 @@ function OrganisationsDialog({
selectedOrganisation?.OrganisationMember.role === "owner" ||
selectedOrganisation?.OrganisationMember.role === "admin";
+ const isOwner = selectedOrganisation?.OrganisationMember.role === "owner";
+
+ const canDeleteProject = isOwner || (selectedProject && selectedProject.Project.creatorId === user?.id);
+
const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const getSprintDateRange = (sprint: SprintRecord) => {
@@ -527,7 +539,67 @@ function OrganisationsDialog({
)}
+ {isAdmin && (
+
+
+ {isOwner && (
+
+ )}
+
+ )}
+
+ {
+ await refetchOrganisations({
+ selectOrganisationId: selectedOrganisation.Organisation.id,
+ });
+ }}
+ />
@@ -650,6 +722,63 @@ function OrganisationsDialog({
Creator: {selectedProject.User.name}
+ {isAdmin && (
+
+
+ {canDeleteProject && (
+
+ )}
+
+ )}
>
) : (
@@ -660,30 +789,118 @@ function OrganisationsDialog({
{selectedProject ? (
- {sprints.map((sprint) => {
- const dateRange = getSprintDateRange(sprint);
- const isCurrent = isCurrentSprint(sprint);
+ {sprints.map((sprintItem) => {
+ const dateRange = getSprintDateRange(sprintItem);
+ const isCurrent = isCurrentSprint(sprintItem);
return (
-
- {dateRange && (
-
- {dateRange}
-
- )}
+
+
+ {dateRange && (
+
+ {dateRange}
+
+ )}
+ {isAdmin && (
+
+
+
+
+
+ {
+ setEditingSprint(
+ sprintItem,
+ );
+ setEditSprintOpen(
+ true,
+ );
+ }}
+ className="hover:bg-primary-foreground"
+ >
+
+ Edit
+
+ {
+ setConfirmDialog({
+ open: true,
+ title: "Delete Sprint",
+ message: `Are you sure you want to delete "${sprintItem.name}"? Issues assigned to this sprint will become unassigned.`,
+ confirmText:
+ "Delete",
+ processingText:
+ "Deleting...",
+ variant:
+ "destructive",
+ onConfirm:
+ async () => {
+ await sprint.remove(
+ {
+ sprintId:
+ sprintItem.id,
+ onSuccess:
+ async () => {
+ closeConfirmDialog();
+ toast.success(
+ `Deleted sprint "${sprintItem.name}"`,
+ );
+ await refetchOrganisations();
+ },
+ onError:
+ (
+ error,
+ ) => {
+ console.error(
+ error,
+ );
+ },
+ },
+ );
+ },
+ });
+ }}
+ className="hover:bg-destructive/10"
+ >
+
+ Delete
+
+
+
+ )}
+
);
})}
{isAdmin && (
-
+
+ {selectedProject && (
+ <>
+ {
+ await refetchOrganisations();
+ }}
+ />
+ {
+ setEditSprintOpen(open);
+ if (!open) setEditingSprint(null);
+ }}
+ completeAction={async () => {
+ await refetchOrganisations();
+ }}
+ />
+ >
+ )}
diff --git a/packages/frontend/src/components/project-modal.tsx b/packages/frontend/src/components/project-modal.tsx
new file mode 100644
index 0000000..c8de6e4
--- /dev/null
+++ b/packages/frontend/src/components/project-modal.tsx
@@ -0,0 +1,268 @@
+import { PROJECT_NAME_MAX_LENGTH, type ProjectRecord } from "@sprint/shared";
+import { type FormEvent, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useAuthenticatedSession } from "@/components/session-provider";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Field } from "@/components/ui/field";
+import { Label } from "@/components/ui/label";
+import { parseError, project } from "@/lib/server";
+import { cn } from "@/lib/utils";
+
+const keyify = (value: string) =>
+ value
+ .toUpperCase()
+ .replace(/[^A-Z0-9]/g, "")
+ .slice(0, 4);
+
+export function ProjectModal({
+ organisationId,
+ trigger,
+ completeAction,
+ mode = "create",
+ existingProject,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+}: {
+ organisationId?: number;
+ trigger?: React.ReactNode;
+ completeAction?: (project: ProjectRecord) => void | Promise;
+ mode?: "create" | "edit";
+ existingProject?: ProjectRecord;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { user } = useAuthenticatedSession();
+
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
+
+ const [name, setName] = useState("");
+ const [key, setKey] = useState("");
+ const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isEdit = mode === "edit";
+
+ useEffect(() => {
+ if (isEdit && existingProject && open) {
+ setName(existingProject.name);
+ setKey(existingProject.key);
+ setKeyManuallyEdited(true);
+ }
+ }, [isEdit, existingProject, open]);
+
+ const reset = () => {
+ setName("");
+ setKey("");
+ setKeyManuallyEdited(false);
+ setSubmitAttempted(false);
+ setSubmitting(false);
+ setError(null);
+ };
+
+ const onOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ reset();
+ }
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setSubmitAttempted(true);
+
+ if (
+ name.trim() === "" ||
+ name.trim().length > PROJECT_NAME_MAX_LENGTH ||
+ key.trim() === "" ||
+ key.length > 4
+ ) {
+ return;
+ }
+
+ if (!user.id) {
+ setError(`you must be logged in to ${isEdit ? "edit" : "create"} a project`);
+ return;
+ }
+
+ if (!isEdit && !organisationId) {
+ setError("select an organisation first");
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ if (isEdit && existingProject) {
+ await project.update({
+ projectId: existingProject.id,
+ key,
+ name,
+ onSuccess: async (data) => {
+ const proj = data as ProjectRecord;
+ setOpen(false);
+ reset();
+ toast.success("Project updated");
+ try {
+ await completeAction?.(proj);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: (err) => {
+ const message = parseError(err);
+ setError(message);
+ setSubmitting(false);
+
+ toast.error(`Error updating project: ${message}`, {
+ dismissible: false,
+ });
+ },
+ });
+ } else {
+ if (!organisationId) {
+ setError("select an organisation first");
+ return;
+ }
+ await project.create({
+ key,
+ name,
+ organisationId: organisationId,
+ onSuccess: async (data) => {
+ const proj = data as ProjectRecord;
+
+ setOpen(false);
+ reset();
+ try {
+ await completeAction?.(proj);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: (err) => {
+ const message = parseError(err);
+ setError(message);
+ setSubmitting(false);
+
+ toast.error(`Error creating project: ${message}`, {
+ dismissible: false,
+ });
+ },
+ });
+ }
+ } catch (err) {
+ console.error(err);
+ setError(`failed to ${isEdit ? "update" : "create"} project`);
+ setSubmitting(false);
+ }
+ };
+
+ const dialogContent = (
+
+
+ {isEdit ? "Edit Project" : "Create Project"}
+
+
+
+
+ );
+
+ if (isControlled) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/components/project-select.tsx b/packages/frontend/src/components/project-select.tsx
index 4cda514..cf9c1a5 100644
--- a/packages/frontend/src/components/project-select.tsx
+++ b/packages/frontend/src/components/project-select.tsx
@@ -1,6 +1,6 @@
import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
import { useState } from "react";
-import { CreateProject } from "@/components/create-project";
+import { ProjectModal } from "@/components/project-modal";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -68,7 +68,7 @@ export function ProjectSelect({
))}
{projects.length > 0 && }
-
diff --git a/packages/frontend/src/components/sprint-modal.tsx b/packages/frontend/src/components/sprint-modal.tsx
new file mode 100644
index 0000000..2c36300
--- /dev/null
+++ b/packages/frontend/src/components/sprint-modal.tsx
@@ -0,0 +1,346 @@
+import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
+import { type FormEvent, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { useAuthenticatedSession } from "@/components/session-provider";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import ColourPicker from "@/components/ui/colour-picker";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Field } from "@/components/ui/field";
+import { Label } from "@/components/ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { parseError, sprint } from "@/lib/server";
+import { cn } from "@/lib/utils";
+
+const SPRINT_NAME_MAX_LENGTH = 64;
+
+const getStartOfDay = (date: Date) => {
+ const next = new Date(date);
+ next.setHours(0, 0, 0, 0);
+ return next;
+};
+
+const getEndOfDay = (date: Date) => {
+ const next = new Date(date);
+ next.setHours(23, 59, 0, 0);
+ return next;
+};
+
+const addDays = (date: Date, days: number) => {
+ const next = new Date(date);
+ next.setDate(next.getDate() + days);
+ return next;
+};
+
+const getDefaultDates = () => {
+ const today = new Date();
+ return {
+ start: getStartOfDay(today),
+ end: getEndOfDay(addDays(today, 14)),
+ };
+};
+
+export function SprintModal({
+ projectId,
+ sprints,
+ trigger,
+ completeAction,
+ mode = "create",
+ existingSprint,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+}: {
+ projectId?: number;
+ sprints: SprintRecord[];
+ trigger?: React.ReactNode;
+ completeAction?: (sprint: SprintRecord) => void | Promise;
+ mode?: "create" | "edit";
+ existingSprint?: SprintRecord;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { user } = useAuthenticatedSession();
+
+ const isControlled = controlledOpen !== undefined;
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
+
+ const { start, end } = getDefaultDates();
+ const [name, setName] = useState("");
+ const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
+ const [startDate, setStartDate] = useState(start);
+ const [endDate, setEndDate] = useState(end);
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isEdit = mode === "edit";
+
+ useEffect(() => {
+ if (isEdit && existingSprint && open) {
+ setName(existingSprint.name);
+ setColour(existingSprint.color);
+ setStartDate(new Date(existingSprint.startDate));
+ setEndDate(new Date(existingSprint.endDate));
+ }
+ }, [isEdit, existingSprint, open]);
+
+ const dateError = useMemo(() => {
+ if (!submitAttempted) return "";
+ if (startDate > endDate) {
+ return "End date must be after start date";
+ }
+ return "";
+ }, [endDate, startDate, submitAttempted]);
+
+ const reset = () => {
+ const defaults = getDefaultDates();
+ setName("");
+ setColour(DEFAULT_SPRINT_COLOUR);
+ setStartDate(defaults.start);
+ setEndDate(defaults.end);
+ setSubmitAttempted(false);
+ setSubmitting(false);
+ setError(null);
+ };
+
+ const onOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ reset();
+ }
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setError(null);
+ setSubmitAttempted(true);
+
+ if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
+ return;
+ }
+
+ if (startDate > endDate) {
+ return;
+ }
+
+ if (!user.id) {
+ setError(`you must be logged in to ${isEdit ? "edit" : "create"} a sprint`);
+ return;
+ }
+
+ if (!isEdit && !projectId) {
+ setError("select a project first");
+ return;
+ }
+
+ setSubmitting(true);
+
+ try {
+ if (isEdit && existingSprint) {
+ await sprint.update({
+ sprintId: existingSprint.id,
+ name,
+ color: colour,
+ startDate,
+ endDate,
+ onSuccess: async (data) => {
+ setOpen(false);
+ reset();
+ toast.success("Sprint updated");
+ try {
+ await completeAction?.(data);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: (err) => {
+ const message = parseError(err);
+ setError(message);
+ setSubmitting(false);
+
+ toast.error(`Error updating sprint: ${message}`, {
+ dismissible: false,
+ });
+ },
+ });
+ } else {
+ if (!projectId) {
+ setError("select a project first");
+ return;
+ }
+ await sprint.create({
+ projectId: projectId,
+ name,
+ color: colour,
+ startDate,
+ endDate,
+ onSuccess: async (data) => {
+ setOpen(false);
+ reset();
+ try {
+ await completeAction?.(data);
+ } catch (actionErr) {
+ console.error(actionErr);
+ }
+ },
+ onError: (err) => {
+ const message = parseError(err);
+ setError(message);
+ setSubmitting(false);
+
+ toast.error(`Error creating sprint: ${message}`, {
+ dismissible: false,
+ });
+ },
+ });
+ }
+ } catch (submitError) {
+ console.error(submitError);
+ setError(`failed to ${isEdit ? "update" : "create"} sprint`);
+ setSubmitting(false);
+ }
+ };
+
+ // filter out current sprint from the calendar display when editing
+ const calendarSprints =
+ isEdit && existingSprint ? sprints.filter((s) => s.id !== existingSprint.id) : sprints;
+
+ const dialogContent = (
+
+
+ {isEdit ? "Edit Sprint" : "Create Sprint"}
+
+
+
+
+ );
+
+ if (isControlled) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/frontend/src/components/ui/calendar.tsx b/packages/frontend/src/components/ui/calendar.tsx
index e087be6..51e9fea 100644
--- a/packages/frontend/src/components/ui/calendar.tsx
+++ b/packages/frontend/src/components/ui/calendar.tsx
@@ -195,13 +195,13 @@ function CalendarDayButton({
{
...style,
"--sprint-color": sprint?.color ? sprint.color : null,
- "border-left":
+ borderLeft:
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
? `1px solid ${sprint?.color}`
: day.date.getDay() === 0 // sunday (left side)
? `1px dashed ${sprint?.color}`
: `0px`,
- "border-right":
+ borderRight:
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
? `1px solid ${sprint?.color}`
: day.date.getDay() === 6 // saturday (right side)
diff --git a/packages/frontend/src/lib/server/organisation/delete.ts b/packages/frontend/src/lib/server/organisation/delete.ts
new file mode 100644
index 0000000..26b8ebd
--- /dev/null
+++ b/packages/frontend/src/lib/server/organisation/delete.ts
@@ -0,0 +1,37 @@
+import type { SuccessResponse } from "@sprint/shared";
+import { toast } from "sonner";
+import { getCsrfToken, getServerURL } from "@/lib/utils";
+import type { ServerQueryInput } from "..";
+
+export async function remove({
+ organisationId,
+ onSuccess,
+ onError,
+}: {
+ organisationId: number;
+} & ServerQueryInput) {
+ const csrfToken = getCsrfToken();
+
+ const res = await fetch(`${getServerURL()}/organisation/delete`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
+ },
+ body: JSON.stringify({ id: organisationId }),
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => res.text());
+ const message =
+ typeof error === "string"
+ ? error
+ : error.error || `failed to delete organisation (${res.status})`;
+ toast.error(message);
+ onError?.(error);
+ } else {
+ const data = await res.json();
+ onSuccess?.(data, res);
+ }
+}
diff --git a/packages/frontend/src/lib/server/organisation/index.ts b/packages/frontend/src/lib/server/organisation/index.ts
index 423ef36..18c6db8 100644
--- a/packages/frontend/src/lib/server/organisation/index.ts
+++ b/packages/frontend/src/lib/server/organisation/index.ts
@@ -1,6 +1,7 @@
export { addMember } from "@/lib/server/organisation/addMember";
export { byUser } from "@/lib/server/organisation/byUser";
export { create } from "@/lib/server/organisation/create";
+export { remove } from "@/lib/server/organisation/delete";
export { members } from "@/lib/server/organisation/members";
export { removeMember } from "@/lib/server/organisation/removeMember";
export { update } from "@/lib/server/organisation/update";
diff --git a/packages/frontend/src/lib/server/project/delete.ts b/packages/frontend/src/lib/server/project/delete.ts
new file mode 100644
index 0000000..4dfe460
--- /dev/null
+++ b/packages/frontend/src/lib/server/project/delete.ts
@@ -0,0 +1,35 @@
+import type { SuccessResponse } from "@sprint/shared";
+import { toast } from "sonner";
+import { getCsrfToken, getServerURL } from "@/lib/utils";
+import type { ServerQueryInput } from "..";
+
+export async function remove({
+ projectId,
+ onSuccess,
+ onError,
+}: {
+ projectId: number;
+} & ServerQueryInput) {
+ const csrfToken = getCsrfToken();
+
+ const res = await fetch(`${getServerURL()}/project/delete`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
+ },
+ body: JSON.stringify({ id: projectId }),
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => res.text());
+ const message =
+ typeof error === "string" ? error : error.error || `failed to delete project (${res.status})`;
+ toast.error(message);
+ onError?.(error);
+ } else {
+ const data = await res.json();
+ onSuccess?.(data, res);
+ }
+}
diff --git a/packages/frontend/src/lib/server/project/index.ts b/packages/frontend/src/lib/server/project/index.ts
index a411753..985e43a 100644
--- a/packages/frontend/src/lib/server/project/index.ts
+++ b/packages/frontend/src/lib/server/project/index.ts
@@ -1,2 +1,4 @@
export { byOrganisation } from "@/lib/server/project/byOrganisation";
export { create } from "@/lib/server/project/create";
+export { remove } from "@/lib/server/project/delete";
+export { update } from "@/lib/server/project/update";
diff --git a/packages/frontend/src/lib/server/project/update.ts b/packages/frontend/src/lib/server/project/update.ts
new file mode 100644
index 0000000..461fe12
--- /dev/null
+++ b/packages/frontend/src/lib/server/project/update.ts
@@ -0,0 +1,43 @@
+import type { ProjectRecord } from "@sprint/shared";
+import { toast } from "sonner";
+import { getCsrfToken, getServerURL } from "@/lib/utils";
+import type { ServerQueryInput } from "..";
+
+export async function update({
+ projectId,
+ key,
+ name,
+ onSuccess,
+ onError,
+}: {
+ projectId: number;
+ key?: string;
+ name?: string;
+} & ServerQueryInput) {
+ const csrfToken = getCsrfToken();
+
+ const res = await fetch(`${getServerURL()}/project/update`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
+ },
+ body: JSON.stringify({
+ id: projectId,
+ key,
+ name,
+ }),
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => res.text());
+ const message =
+ typeof error === "string" ? error : error.error || `failed to update project (${res.status})`;
+ toast.error(message);
+ onError?.(error);
+ } else {
+ const data = await res.json();
+ onSuccess?.(data, res);
+ }
+}
diff --git a/packages/frontend/src/lib/server/sprint/delete.ts b/packages/frontend/src/lib/server/sprint/delete.ts
new file mode 100644
index 0000000..f8be95e
--- /dev/null
+++ b/packages/frontend/src/lib/server/sprint/delete.ts
@@ -0,0 +1,35 @@
+import type { SuccessResponse } from "@sprint/shared";
+import { toast } from "sonner";
+import { getCsrfToken, getServerURL } from "@/lib/utils";
+import type { ServerQueryInput } from "..";
+
+export async function remove({
+ sprintId,
+ onSuccess,
+ onError,
+}: {
+ sprintId: number;
+} & ServerQueryInput) {
+ const csrfToken = getCsrfToken();
+
+ const res = await fetch(`${getServerURL()}/sprint/delete`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
+ },
+ body: JSON.stringify({ id: sprintId }),
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => res.text());
+ const message =
+ typeof error === "string" ? error : error.error || `failed to delete sprint (${res.status})`;
+ toast.error(message);
+ onError?.(error);
+ } else {
+ const data = await res.json();
+ onSuccess?.(data, res);
+ }
+}
diff --git a/packages/frontend/src/lib/server/sprint/index.ts b/packages/frontend/src/lib/server/sprint/index.ts
index 6287fde..e89b9b7 100644
--- a/packages/frontend/src/lib/server/sprint/index.ts
+++ b/packages/frontend/src/lib/server/sprint/index.ts
@@ -1,2 +1,4 @@
export { byProject } from "@/lib/server/sprint/byProject";
export { create } from "@/lib/server/sprint/create";
+export { remove } from "@/lib/server/sprint/delete";
+export { update } from "@/lib/server/sprint/update";
diff --git a/packages/frontend/src/lib/server/sprint/update.ts b/packages/frontend/src/lib/server/sprint/update.ts
new file mode 100644
index 0000000..f7c74d9
--- /dev/null
+++ b/packages/frontend/src/lib/server/sprint/update.ts
@@ -0,0 +1,49 @@
+import type { SprintRecord } from "@sprint/shared";
+import { toast } from "sonner";
+import { getCsrfToken, getServerURL } from "@/lib/utils";
+import type { ServerQueryInput } from "..";
+
+export async function update({
+ sprintId,
+ name,
+ color,
+ startDate,
+ endDate,
+ onSuccess,
+ onError,
+}: {
+ sprintId: number;
+ name?: string;
+ color?: string;
+ startDate?: Date;
+ endDate?: Date;
+} & ServerQueryInput) {
+ const csrfToken = getCsrfToken();
+
+ const res = await fetch(`${getServerURL()}/sprint/update`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
+ },
+ body: JSON.stringify({
+ id: sprintId,
+ name: name?.trim(),
+ color,
+ startDate: startDate?.toISOString(),
+ endDate: endDate?.toISOString(),
+ }),
+ credentials: "include",
+ });
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => res.text());
+ const message =
+ typeof error === "string" ? error : error.error || `failed to update sprint (${res.status})`;
+ toast.error(message);
+ onError?.(error);
+ } 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 9933a5e..abf68ae 100644
--- a/packages/frontend/src/pages/App.tsx
+++ b/packages/frontend/src/pages/App.tsx
@@ -11,8 +11,8 @@ import type {
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import AccountDialog from "@/components/account-dialog";
-import { CreateIssue } from "@/components/create-issue";
import { IssueDetailPane } from "@/components/issue-detail-pane";
+import { IssueModal } from "@/components/issue-modal";
import { IssuesTable } from "@/components/issues-table";
import LogOutButton from "@/components/log-out-button";
import { OrganisationSelect } from "@/components/organisation-select";
@@ -459,7 +459,7 @@ export default function App() {
/>
)}
{selectedOrganisation && selectedProject && (
- ;
+export const SprintUpdateRequestSchema = z
+ .object({
+ id: z.number().int().positive("id must be a positive integer"),
+ name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters").optional(),
+ color: z
+ .string()
+ .regex(/^#[0-9a-fA-F]{6}$/, "Color must be a valid hex color")
+ .optional(),
+ startDate: z.string().datetime("Start date must be a valid date").optional(),
+ endDate: z.string().datetime("End date must be a valid date").optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.startDate && data.endDate) {
+ return new Date(data.startDate) <= new Date(data.endDate);
+ }
+ return true;
+ },
+ {
+ message: "End date must be after start date",
+ path: ["endDate"],
+ },
+ );
+
+export type SprintUpdateRequest = z.infer;
+
+export const SprintDeleteRequestSchema = z.object({
+ id: z.number().int().positive("id must be a positive integer"),
+});
+
+export type SprintDeleteRequest = z.infer;
+
export const SprintsByProjectQuerySchema = z.object({
projectId: z.coerce.number().int().positive("projectId must be a positive integer"),
});
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 5a5fc88..8631109 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -28,8 +28,10 @@ export type {
RegisterRequest,
ReplaceStatusResponse,
SprintCreateRequest,
+ SprintDeleteRequest,
SprintResponseType,
SprintsByProjectQuery,
+ SprintUpdateRequest,
StatusCountResponse,
SuccessResponse,
TimerEndRequest,
@@ -75,8 +77,10 @@ export {
RegisterRequestSchema,
ReplaceStatusResponseSchema,
SprintCreateRequestSchema,
+ SprintDeleteRequestSchema,
SprintRecordSchema,
SprintsByProjectQuerySchema,
+ SprintUpdateRequestSchema,
StatusCountResponseSchema,
SuccessResponseSchema,
TimerEndRequestSchema,