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 ( - - - {trigger || } - - - - - Create Organisation - {/* Enter the details for the new organisation. */} - - -
-
- { - const nextName = e.target.value; - setName(nextName); - if (!slugManuallyEdited) { - setSlug(slugify(nextName)); - } - }} - validate={(v) => { - if (v.trim() === "") return "Cannot be empty"; - if (v.trim().length > ORG_NAME_MAX_LENGTH) { - return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`; - } - return undefined; - }} - submitAttempted={submitAttempted} - placeholder="Demo Organisation" - maxLength={ORG_NAME_MAX_LENGTH} - /> - { - setSlug(slugify(e.target.value)); - setSlugManuallyEdited(true); - }} - validate={(v) => { - if (v.trim() === "") return "Cannot be empty"; - if (v.trim().length > ORG_SLUG_MAX_LENGTH) { - return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`; - } - return undefined; - }} - submitAttempted={submitAttempted} - placeholder="demo-organisation" - maxLength={ORG_SLUG_MAX_LENGTH} - /> - setDescription(e.target.value)} - validate={(v) => { - if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) { - return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`; - } - return undefined; - }} - submitAttempted={submitAttempted} - placeholder="What is this organisation for?" - maxLength={ORG_DESCRIPTION_MAX_LENGTH} - /> - -
- {error ? ( - - ) : ( - - )} -
- -
- - - - -
-
-
- - {/* */} - {/* */} -
-
- ); -} 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 ( - - - {trigger || ( - - )} - - - - - Create Project - - -
-
- { - const nextName = e.target.value; - setName(nextName); - if (!keyManuallyEdited) { - setKey(keyify(nextName)); - } - }} - validate={(v) => { - if (v.trim() === "") return "Cannot be empty"; - if (v.trim().length > PROJECT_NAME_MAX_LENGTH) { - return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`; - } - return undefined; - }} - submitAttempted={submitAttempted} - placeholder="Demo Project" - maxLength={PROJECT_NAME_MAX_LENGTH} - /> - { - setKey(keyify(e.target.value)); - setKeyManuallyEdited(true); - }} - validate={(v) => { - if (v.trim() === "") return "Cannot be empty"; - if (v.length > 4) return "Must be 4 or less characters"; - return undefined; - }} - submitAttempted={submitAttempted} - placeholder="DEMO" - /> - -
- {error ? ( - - ) : ( - - )} -
- -
- - - - -
-
-
-
-
- ); -} 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 ( - - - {trigger || ( - - )} - - - - - Create Sprint - - -
-
- setName(event.target.value)} - validate={(value) => - value.trim() === "" - ? "Cannot be empty" - : value.trim().length > SPRINT_NAME_MAX_LENGTH - ? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)` - : undefined - } - submitAttempted={submitAttempted} - placeholder="Sprint 1" - maxLength={SPRINT_NAME_MAX_LENGTH} - /> - -
-
- - - - - - - { - if (!value) return; - setStartDate(getStartOfDay(value)); - }} - autoFocus - sprints={sprints} - /> - - -
- -
- - - - - - - { - if (!value) return; - setEndDate(getEndOfDay(value)); - }} - autoFocus - sprints={sprints} - isEnd - /> - - -
-
- -
- - -
- -
- {error || dateError ? ( - - ) : ( - - )} -
- -
- - - - -
-
-
-
-
- ); -} 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"} + + +
+
+ { + const nextName = e.target.value; + setName(nextName); + if (!slugManuallyEdited) { + setSlug(slugify(nextName)); + } + }} + validate={(v) => { + if (v.trim() === "") return "Cannot be empty"; + if (v.trim().length > ORG_NAME_MAX_LENGTH) { + return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`; + } + return undefined; + }} + submitAttempted={submitAttempted} + placeholder="Demo Organisation" + maxLength={ORG_NAME_MAX_LENGTH} + /> + { + setSlug(slugify(e.target.value)); + setSlugManuallyEdited(true); + }} + validate={(v) => { + if (v.trim() === "") return "Cannot be empty"; + if (v.trim().length > ORG_SLUG_MAX_LENGTH) { + return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`; + } + return undefined; + }} + submitAttempted={submitAttempted} + placeholder="demo-organisation" + maxLength={ORG_SLUG_MAX_LENGTH} + /> + setDescription(e.target.value)} + validate={(v) => { + if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) { + return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`; + } + return undefined; + }} + submitAttempted={submitAttempted} + placeholder="What is this organisation for?" + maxLength={ORG_DESCRIPTION_MAX_LENGTH} + /> + +
+ {error ? ( + + ) : ( + + )} +
+ +
+ + + + +
+
+
+
+ ); + + if (isControlled) { + return ( + + {dialogContent} + + ); + } + + return ( + + + {trigger || } + + {dialogContent} + + ); +} 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"} + + +
+
+ { + const nextName = e.target.value; + setName(nextName); + if (!keyManuallyEdited) { + setKey(keyify(nextName)); + } + }} + validate={(v) => { + if (v.trim() === "") return "Cannot be empty"; + if (v.trim().length > PROJECT_NAME_MAX_LENGTH) { + return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`; + } + return undefined; + }} + submitAttempted={submitAttempted} + placeholder="Demo Project" + maxLength={PROJECT_NAME_MAX_LENGTH} + /> + { + setKey(keyify(e.target.value)); + setKeyManuallyEdited(true); + }} + validate={(v) => { + if (v.trim() === "") return "Cannot be empty"; + if (v.length > 4) return "Must be 4 or less characters"; + return undefined; + }} + submitAttempted={submitAttempted} + placeholder="DEMO" + /> + +
+ {error ? ( + + ) : ( + + )} +
+ +
+ + + + +
+
+
+
+ ); + + if (isControlled) { + return ( + + {dialogContent} + + ); + } + + return ( + + + {trigger || ( + + )} + + {dialogContent} + + ); +} 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"} + + +
+
+ setName(event.target.value)} + validate={(value) => + value.trim() === "" + ? "Cannot be empty" + : value.trim().length > SPRINT_NAME_MAX_LENGTH + ? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)` + : undefined + } + submitAttempted={submitAttempted} + placeholder="Sprint 1" + maxLength={SPRINT_NAME_MAX_LENGTH} + /> + +
+
+ + + + + + + { + if (!value) return; + setStartDate(getStartOfDay(value)); + }} + autoFocus + sprints={calendarSprints} + /> + + +
+ +
+ + + + + + + { + if (!value) return; + setEndDate(getEndOfDay(value)); + }} + autoFocus + sprints={calendarSprints} + isEnd + /> + + +
+
+ +
+ + +
+ +
+ {error || dateError ? ( + + ) : ( + + )} +
+ +
+ + + + +
+
+
+
+ ); + + if (isControlled) { + return ( + + {dialogContent} + + ); + } + + return ( + + + {trigger || ( + + )} + + {dialogContent} + + ); +} 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,