edit + delete capabilities for org, project, sprint

This commit is contained in:
Oliver Bryan
2026-01-18 22:30:41 +00:00
parent e4bc1ea568
commit 303541e656
32 changed files with 1640 additions and 748 deletions

View File

@@ -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 { eq } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
@@ -26,6 +26,8 @@ export async function updateProject(
export async function deleteProject(projectId: number) { export async function deleteProject(projectId: number) {
// delete all of the project's issues first // delete all of the project's issues first
await db.delete(Issue).where(eq(Issue.projectId, projectId)); 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 // delete actual project
await db.delete(Project).where(eq(Project.id, projectId)); await db.delete(Project).where(eq(Project.id, projectId));
} }

View File

@@ -1,5 +1,5 @@
import { Sprint } from "@sprint/shared"; import { Issue, Sprint } from "@sprint/shared";
import { and, eq, gte, lte } from "drizzle-orm"; import { and, desc, eq, gte, lte, ne } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
export async function createSprint( export async function createSprint(
@@ -22,21 +22,55 @@ export async function createSprint(
return sprint; return sprint;
} }
export async function getSprintsByProject(projectId: number) { export async function getSprintById(sprintId: number) {
return await db.select().from(Sprint).where(eq(Sprint.projectId, projectId)); 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 const overlapping = await db
.select({ id: Sprint.id }) .select({ id: Sprint.id })
.from(Sprint) .from(Sprint)
.where( .where(and(...conditions))
and(
eq(Sprint.projectId, projectId),
lte(Sprint.startDate, endDate),
gte(Sprint.endDate, startDate),
),
)
.limit(1); .limit(1);
console.log(overlapping);
return overlapping.length > 0; 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));
}

View File

@@ -70,6 +70,8 @@ const main = async () => {
"/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)), "/projects/with-creators": withCors(withAuth(routes.projectsWithCreators)),
"/sprint/create": withCors(withAuth(withCSRF(routes.sprintCreate))), "/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)), "/sprints/by-project": withCors(withAuth(routes.sprintsByProject)),
"/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))), "/timer/toggle": withCors(withAuth(withCSRF(routes.timerToggle))),

View File

@@ -27,6 +27,8 @@ import projectUpdate from "./project/update";
import projectWithCreator from "./project/with-creator"; import projectWithCreator from "./project/with-creator";
import projectsWithCreators from "./project/with-creators"; import projectsWithCreators from "./project/with-creators";
import sprintCreate from "./sprint/create"; import sprintCreate from "./sprint/create";
import sprintDelete from "./sprint/delete";
import sprintUpdate from "./sprint/update";
import sprintsByProject from "./sprints/by-project"; import sprintsByProject from "./sprints/by-project";
import timerEnd from "./timer/end"; import timerEnd from "./timer/end";
import timerGet from "./timer/get"; import timerGet from "./timer/get";
@@ -78,6 +80,8 @@ export const routes = {
projectsWithCreators, projectsWithCreators,
sprintCreate, sprintCreate,
sprintUpdate,
sprintDelete,
sprintsByProject, sprintsByProject,
timerToggle, timerToggle,

View File

@@ -1,9 +1,9 @@
import { OrgDeleteRequestSchema } from "@sprint/shared"; import { OrgDeleteRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { deleteOrganisation, getOrganisationById } from "../../db/queries"; import { deleteOrganisation, getOrganisationById, getOrganisationMemberRole } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, OrgDeleteRequestSchema);
if ("error" in parsed) return parsed.error; 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); 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); await deleteOrganisation(id);
return Response.json({ success: true }); return Response.json({ success: true });

View File

@@ -1,9 +1,9 @@
import { OrgUpdateRequestSchema } from "@sprint/shared"; import { OrgUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, updateOrganisation } from "../../db/queries"; import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, OrgUpdateRequestSchema);
if ("error" in parsed) return parsed.error; 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); 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) { if (!name && !description && !slug && !statuses) {
return errorResponse( return errorResponse(
"at least one of name, description, slug, or statuses must be provided", "at least one of name, description, slug, or statuses must be provided",

View File

@@ -1,9 +1,9 @@
import { ProjectDeleteRequestSchema } from "@sprint/shared"; import { ProjectDeleteRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { deleteProject, getProjectByID } from "../../db/queries"; import { deleteProject, getOrganisationMemberRole, getProjectByID } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, ProjectDeleteRequestSchema);
if ("error" in parsed) return parsed.error; 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); 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); await deleteProject(id);
return Response.json({ success: true }); return Response.json({ success: true });

View File

@@ -1,9 +1,15 @@
import { ProjectUpdateRequestSchema } from "@sprint/shared"; import { ProjectUpdateRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { AuthedRequest } from "../../auth/middleware";
import { getProjectByID, getProjectByKey, getUserById, updateProject } from "../../db/queries"; import {
getOrganisationMemberRole,
getProjectByID,
getProjectByKey,
getUserById,
updateProject,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; 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); const parsed = await parseJsonBody(req, ProjectUpdateRequestSchema);
if ("error" in parsed) return parsed.error; 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); 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) { if (!key && !name && !creatorId && !organisationId) {
return errorResponse( return errorResponse(
"at least one of key, name, creatorId, or organisationId must be provided", "at least one of key, name, creatorId, or organisationId must be provided",

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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<void>;
errorAction?: (errorMessage: string) => void | Promise<void>;
}) {
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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || <Button variant="outline">Create Organisation</Button>}
</DialogTrigger>
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
<DialogHeader>
<DialogTitle>Create Organisation</DialogTitle>
{/* <DialogDescription>Enter the details for the new organisation.</DialogDescription> */}
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid mt-2">
<Field
label="Name"
value={name}
onChange={(e) => {
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}
/>
<Field
label="Slug"
value={slug}
onChange={(e) => {
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}
/>
<Field
label="Description (optional)"
value={description}
onChange={(e) => 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}
/>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error ? (
<Label className="text-destructive text-sm">{error}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
name.trim() === "" ||
name.trim().length > ORG_NAME_MAX_LENGTH ||
slug.trim() === "" ||
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
}
>
{submitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</form>
{/* <DialogFooter> */}
{/* </DialogFooter> */}
</DialogContent>
</Dialog>
);
}

View File

@@ -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<void>;
}) {
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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" disabled={!organisationId}>
Create Project
</Button>
)}
</DialogTrigger>
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid mt-2">
<Field
label="Name"
value={name}
onChange={(e) => {
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}
/>
<Field
label="Key"
value={key}
onChange={(e) => {
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"
/>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error ? (
<Label className="text-destructive text-sm">{error}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
(name.trim() === "" && submitAttempted) ||
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
((key.trim() === "" || key.length > 4) && submitAttempted)
}
>
{submitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<void>;
}) {
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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" disabled={!projectId}>
Create Sprint
</Button>
)}
</DialogTrigger>
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
<DialogHeader>
<DialogTitle>Create Sprint</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-2">
<Field
label="Name"
value={name}
onChange={(event) => 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}
/>
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Start Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="justify-start">
{startDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="single"
selected={startDate}
onSelect={(value) => {
if (!value) return;
setStartDate(getStartOfDay(value));
}}
autoFocus
sprints={sprints}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-2">
<Label>End Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="justify-start">
{endDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="single"
selected={endDate}
onSelect={(value) => {
if (!value) return;
setEndDate(getEndOfDay(value));
}}
autoFocus
sprints={sprints}
isEnd
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-2">
<Label>Colour</Label>
<ColourPicker colour={colour} onChange={setColour} />
</div>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error || dateError ? (
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) &&
submitAttempted) ||
(dateError !== "" && submitAttempted)
}
>
{submitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,7 +27,7 @@ import { issue, parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SprintSelect } from "./sprint-select"; import { SprintSelect } from "./sprint-select";
export function CreateIssue({ export function IssueModal({
projectId, projectId,
sprints, sprints,
members, members,

View File

@@ -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<void>;
errorAction?: (errorMessage: string) => void | Promise<void>;
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<string | null>(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 = (
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Organisation" : "Create Organisation"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid mt-2">
<Field
label="Name"
value={name}
onChange={(e) => {
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}
/>
<Field
label="Slug"
value={slug}
onChange={(e) => {
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}
/>
<Field
label="Description (optional)"
value={description}
onChange={(e) => 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}
/>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error ? (
<Label className="text-destructive text-sm">{error}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
name.trim() === "" ||
name.trim().length > ORG_NAME_MAX_LENGTH ||
slug.trim() === "" ||
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
}
>
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
</form>
</DialogContent>
);
if (isControlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || <Button variant="outline">Create Organisation</Button>}
</DialogTrigger>
{dialogContent}
</Dialog>
);
}

View File

@@ -1,7 +1,7 @@
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared"; import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CreateOrganisation } from "@/components/create-organisation"; import { OrganisationModal } from "@/components/organisation-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -74,7 +74,7 @@ export function OrganisationSelect({
{organisations.length > 0 && <SelectSeparator />} {organisations.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
<CreateOrganisation <OrganisationModal
trigger={ trigger={
<Button variant="ghost" className={"w-full"} size={"sm"}> <Button variant="ghost" className={"w-full"} size={"sm"}>
Create Organisation Create Organisation

View File

@@ -10,12 +10,14 @@ import {
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddMemberDialog } from "@/components/add-member-dialog"; 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 { OrganisationSelect } from "@/components/organisation-select";
import { ProjectModal } from "@/components/project-modal";
import { ProjectSelect } from "@/components/project-select"; import { ProjectSelect } from "@/components/project-select";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { SprintModal } from "@/components/sprint-modal";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ColourPicker from "@/components/ui/colour-picker"; 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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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"; import { capitalise } from "@/lib/utils";
function OrganisationsDialog({ function OrganisationsDialog({
@@ -75,6 +77,12 @@ function OrganisationsDialog({
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0); const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
const [reassignToStatus, setReassignToStatus] = useState<string>(""); const [reassignToStatus, setReassignToStatus] = useState<string>("");
// 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<SprintRecord | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{ const [confirmDialog, setConfirmDialog] = useState<{
open: boolean; open: boolean;
title: string; title: string;
@@ -97,6 +105,10 @@ function OrganisationsDialog({
selectedOrganisation?.OrganisationMember.role === "owner" || selectedOrganisation?.OrganisationMember.role === "owner" ||
selectedOrganisation?.OrganisationMember.role === "admin"; selectedOrganisation?.OrganisationMember.role === "admin";
const isOwner = selectedOrganisation?.OrganisationMember.role === "owner";
const canDeleteProject = isOwner || (selectedProject && selectedProject.Project.creatorId === user?.id);
const formatDate = (value: Date | string) => const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" }); new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const getSprintDateRange = (sprint: SprintRecord) => { const getSprintDateRange = (sprint: SprintRecord) => {
@@ -527,7 +539,67 @@ function OrganisationsDialog({
</p> </p>
)} )}
</div> </div>
{isAdmin && (
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setEditOrgOpen(true)}
>
<Icon icon="edit" className="size-4" />
Edit
</Button>
{isOwner && (
<Button
variant="destructive"
size="sm"
onClick={() => {
setConfirmDialog({
open: true,
title: "Delete Organisation",
message: `Are you sure you want to delete "${selectedOrganisation.Organisation.name}"? This action cannot be undone and will delete all projects, sprints, and issues.`,
confirmText: "Delete",
processingText: "Deleting...",
variant: "destructive",
onConfirm: async () => {
await organisation.remove({
organisationId:
selectedOrganisation.Organisation.id,
onSuccess: async () => {
closeConfirmDialog();
toast.success(
`Deleted organisation "${selectedOrganisation.Organisation.name}"`,
);
setSelectedOrganisation(null);
await refetchOrganisations();
},
onError: (error) => {
console.error(error);
},
});
},
});
}}
>
<Icon icon="trash" className="size-4" />
Delete
</Button>
)}
</div>
)}
</div> </div>
<OrganisationModal
mode="edit"
existingOrganisation={selectedOrganisation.Organisation}
open={editOrgOpen}
onOpenChange={setEditOrgOpen}
completeAction={async () => {
await refetchOrganisations({
selectOrganisationId: selectedOrganisation.Organisation.id,
});
}}
/>
</TabsContent> </TabsContent>
<TabsContent value="users"> <TabsContent value="users">
@@ -650,6 +722,63 @@ function OrganisationsDialog({
Creator: {selectedProject.User.name} Creator: {selectedProject.User.name}
</p> </p>
</div> </div>
{isAdmin && (
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setEditProjectOpen(true)}
>
<Icon icon="edit" className="size-4" />
Edit
</Button>
{canDeleteProject && (
<Button
variant="destructive"
size="sm"
onClick={() => {
setConfirmDialog({
open: true,
title: "Delete Project",
message: `Are you sure you want to delete "${selectedProject.Project.name}"? This will delete all sprints and issues in this project.`,
confirmText: "Delete",
processingText: "Deleting...",
variant: "destructive",
onConfirm: async () => {
await project.remove({
projectId:
selectedProject
.Project.id,
onSuccess:
async () => {
closeConfirmDialog();
toast.success(
`Deleted project "${selectedProject.Project.name}"`,
);
onSelectedProjectChange(
null,
);
await refetchOrganisations();
},
onError: (error) => {
console.error(
error,
);
},
});
},
});
}}
>
<Icon
icon="trash"
className="size-4"
/>
Delete
</Button>
)}
</div>
)}
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -660,30 +789,118 @@ function OrganisationsDialog({
<div className="flex flex-col gap-2 min-w-0 flex-1"> <div className="flex flex-col gap-2 min-w-0 flex-1">
{selectedProject ? ( {selectedProject ? (
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll"> <div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{sprints.map((sprint) => { {sprints.map((sprintItem) => {
const dateRange = getSprintDateRange(sprint); const dateRange = getSprintDateRange(sprintItem);
const isCurrent = isCurrentSprint(sprint); const isCurrent = isCurrentSprint(sprintItem);
return ( return (
<div <div
key={sprint.id} key={sprintItem.id}
className={`flex items-center justify-between p-2 border ${ className={`flex items-center justify-between p-2 border ${
isCurrent isCurrent
? "border-emerald-500/60 bg-emerald-500/10" ? "border-emerald-500/60 bg-emerald-500/10"
: "" : ""
}`} }`}
> >
<SmallSprintDisplay sprint={sprint} /> <SmallSprintDisplay sprint={sprintItem} />
{dateRange && ( <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> {dateRange && (
{dateRange} <span className="text-xs text-muted-foreground">
</span> {dateRange}
)} </span>
)}
{isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger
asChild
size={"sm"}
noStyle
className="hover:opacity-80 cursor-pointer"
>
<Icon
icon="ellipsisVertical"
className="size-4 text-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className="bg-background"
>
<DropdownMenuItem
onSelect={() => {
setEditingSprint(
sprintItem,
);
setEditSprintOpen(
true,
);
}}
className="hover:bg-primary-foreground"
>
<Icon
icon="edit"
className="size-4 text-muted-foreground"
/>
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
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"
>
<Icon
icon="trash"
className="size-4"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div> </div>
); );
})} })}
{isAdmin && ( {isAdmin && (
<CreateSprint <SprintModal
projectId={selectedProject?.Project.id} projectId={selectedProject?.Project.id}
completeAction={onCreateSprint} completeAction={onCreateSprint}
trigger={ trigger={
@@ -708,6 +925,33 @@ function OrganisationsDialog({
</div> </div>
</div> </div>
</div> </div>
{selectedProject && (
<>
<ProjectModal
mode="edit"
existingProject={selectedProject.Project}
open={editProjectOpen}
onOpenChange={setEditProjectOpen}
completeAction={async () => {
await refetchOrganisations();
}}
/>
<SprintModal
mode="edit"
existingSprint={editingSprint ?? undefined}
sprints={sprints}
open={editSprintOpen}
onOpenChange={(open) => {
setEditSprintOpen(open);
if (!open) setEditingSprint(null);
}}
completeAction={async () => {
await refetchOrganisations();
}}
/>
</>
)}
</TabsContent> </TabsContent>
<TabsContent value="issues"> <TabsContent value="issues">

View File

@@ -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<void>;
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<string | null>(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 = (
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Project" : "Create Project"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid mt-2">
<Field
label="Name"
value={name}
onChange={(e) => {
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}
/>
<Field
label="Key"
value={key}
onChange={(e) => {
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"
/>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error ? (
<Label className="text-destructive text-sm">{error}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
(name.trim() === "" && submitAttempted) ||
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
((key.trim() === "" || key.length > 4) && submitAttempted)
}
>
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
</form>
</DialogContent>
);
if (isControlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" disabled={!organisationId}>
Create Project
</Button>
)}
</DialogTrigger>
{dialogContent}
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
import type { ProjectRecord, ProjectResponse } from "@sprint/shared"; import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
import { useState } from "react"; import { useState } from "react";
import { CreateProject } from "@/components/create-project"; import { ProjectModal } from "@/components/project-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -68,7 +68,7 @@ export function ProjectSelect({
))} ))}
{projects.length > 0 && <SelectSeparator />} {projects.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
<CreateProject <ProjectModal
organisationId={organisationId} organisationId={organisationId}
trigger={ trigger={
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}> <Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}>

View File

@@ -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<void>;
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<string | null>(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 = (
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Sprint" : "Create Sprint"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-2">
<Field
label="Name"
value={name}
onChange={(event) => 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}
/>
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Start Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="justify-start">
{startDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="single"
selected={startDate}
onSelect={(value) => {
if (!value) return;
setStartDate(getStartOfDay(value));
}}
autoFocus
sprints={calendarSprints}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-2">
<Label>End Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="justify-start">
{endDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="single"
selected={endDate}
onSelect={(value) => {
if (!value) return;
setEndDate(getEndOfDay(value));
}}
autoFocus
sprints={calendarSprints}
isEnd
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-2">
<Label>Colour</Label>
<ColourPicker colour={colour} onChange={setColour} />
</div>
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error || dateError ? (
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
) : (
<Label className="opacity-0 text-sm">a</Label>
)}
</div>
<div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
submitting ||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) &&
submitAttempted) ||
(dateError !== "" && submitAttempted)
}
>
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
</form>
</DialogContent>
);
if (isControlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" disabled={!projectId}>
Create Sprint
</Button>
)}
</DialogTrigger>
{dialogContent}
</Dialog>
);
}

View File

@@ -195,13 +195,13 @@ function CalendarDayButton({
{ {
...style, ...style,
"--sprint-color": sprint?.color ? sprint.color : null, "--sprint-color": sprint?.color ? sprint.color : null,
"border-left": borderLeft:
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate() sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
? `1px solid ${sprint?.color}` ? `1px solid ${sprint?.color}`
: day.date.getDay() === 0 // sunday (left side) : day.date.getDay() === 0 // sunday (left side)
? `1px dashed ${sprint?.color}` ? `1px dashed ${sprint?.color}`
: `0px`, : `0px`,
"border-right": borderRight:
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate() sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
? `1px solid ${sprint?.color}` ? `1px solid ${sprint?.color}`
: day.date.getDay() === 6 // saturday (right side) : day.date.getDay() === 6 // saturday (right side)

View File

@@ -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<SuccessResponse>) {
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);
}
}

View File

@@ -1,6 +1,7 @@
export { addMember } from "@/lib/server/organisation/addMember"; export { addMember } from "@/lib/server/organisation/addMember";
export { byUser } from "@/lib/server/organisation/byUser"; export { byUser } from "@/lib/server/organisation/byUser";
export { create } from "@/lib/server/organisation/create"; export { create } from "@/lib/server/organisation/create";
export { remove } from "@/lib/server/organisation/delete";
export { members } from "@/lib/server/organisation/members"; export { members } from "@/lib/server/organisation/members";
export { removeMember } from "@/lib/server/organisation/removeMember"; export { removeMember } from "@/lib/server/organisation/removeMember";
export { update } from "@/lib/server/organisation/update"; export { update } from "@/lib/server/organisation/update";

View File

@@ -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<SuccessResponse>) {
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);
}
}

View File

@@ -1,2 +1,4 @@
export { byOrganisation } from "@/lib/server/project/byOrganisation"; export { byOrganisation } from "@/lib/server/project/byOrganisation";
export { create } from "@/lib/server/project/create"; export { create } from "@/lib/server/project/create";
export { remove } from "@/lib/server/project/delete";
export { update } from "@/lib/server/project/update";

View File

@@ -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<ProjectRecord>) {
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);
}
}

View File

@@ -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<SuccessResponse>) {
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);
}
}

View File

@@ -1,2 +1,4 @@
export { byProject } from "@/lib/server/sprint/byProject"; export { byProject } from "@/lib/server/sprint/byProject";
export { create } from "@/lib/server/sprint/create"; export { create } from "@/lib/server/sprint/create";
export { remove } from "@/lib/server/sprint/delete";
export { update } from "@/lib/server/sprint/update";

View File

@@ -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<SprintRecord>) {
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);
}
}

View File

@@ -11,8 +11,8 @@ import type {
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import AccountDialog from "@/components/account-dialog"; import AccountDialog from "@/components/account-dialog";
import { CreateIssue } from "@/components/create-issue";
import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssueModal } from "@/components/issue-modal";
import { IssuesTable } from "@/components/issues-table"; import { IssuesTable } from "@/components/issues-table";
import LogOutButton from "@/components/log-out-button"; import LogOutButton from "@/components/log-out-button";
import { OrganisationSelect } from "@/components/organisation-select"; import { OrganisationSelect } from "@/components/organisation-select";
@@ -459,7 +459,7 @@ export default function App() {
/> />
)} )}
{selectedOrganisation && selectedProject && ( {selectedOrganisation && selectedProject && (
<CreateIssue <IssueModal
projectId={selectedProject?.Project.id} projectId={selectedProject?.Project.id}
sprints={sprints} sprints={sprints}
members={members} members={members}

View File

@@ -194,8 +194,9 @@ export const ProjectCreateRequestSchema = z.object({
name: z.string().min(1, "Name is required").max(PROJECT_NAME_MAX_LENGTH), name: z.string().min(1, "Name is required").max(PROJECT_NAME_MAX_LENGTH),
key: z key: z
.string() .string()
.length(4, "Key must be exactly 4 characters") .min(1, "Key is required")
.regex(/^[A-Z]{4}$/, "Key must be 4 uppercase letters"), .max(4, "Key must be 4 characters or less")
.regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z"),
organisationId: z.number().int().positive("organisationId must be a positive integer"), organisationId: z.number().int().positive("organisationId must be a positive integer"),
}); });
@@ -206,8 +207,9 @@ export const ProjectUpdateRequestSchema = z.object({
name: z.string().min(1, "Name must be at least 1 character").max(PROJECT_NAME_MAX_LENGTH).optional(), name: z.string().min(1, "Name must be at least 1 character").max(PROJECT_NAME_MAX_LENGTH).optional(),
key: z key: z
.string() .string()
.length(4, "Key must be exactly 4 characters") .min(1, "Key is required")
.regex(/^[A-Z]{4}$/, "Key must be 4 uppercase letters") .max(4, "Key must be 4 characters or less")
.regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z")
.optional(), .optional(),
creatorId: z.number().int().positive().optional(), creatorId: z.number().int().positive().optional(),
organisationId: z.number().int().positive().optional(), organisationId: z.number().int().positive().optional(),
@@ -259,6 +261,38 @@ export const SprintCreateRequestSchema = z
export type SprintCreateRequest = z.infer<typeof SprintCreateRequestSchema>; export type SprintCreateRequest = z.infer<typeof SprintCreateRequestSchema>;
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<typeof SprintUpdateRequestSchema>;
export const SprintDeleteRequestSchema = z.object({
id: z.number().int().positive("id must be a positive integer"),
});
export type SprintDeleteRequest = z.infer<typeof SprintDeleteRequestSchema>;
export const SprintsByProjectQuerySchema = z.object({ export const SprintsByProjectQuerySchema = z.object({
projectId: z.coerce.number().int().positive("projectId must be a positive integer"), projectId: z.coerce.number().int().positive("projectId must be a positive integer"),
}); });

View File

@@ -28,8 +28,10 @@ export type {
RegisterRequest, RegisterRequest,
ReplaceStatusResponse, ReplaceStatusResponse,
SprintCreateRequest, SprintCreateRequest,
SprintDeleteRequest,
SprintResponseType, SprintResponseType,
SprintsByProjectQuery, SprintsByProjectQuery,
SprintUpdateRequest,
StatusCountResponse, StatusCountResponse,
SuccessResponse, SuccessResponse,
TimerEndRequest, TimerEndRequest,
@@ -75,8 +77,10 @@ export {
RegisterRequestSchema, RegisterRequestSchema,
ReplaceStatusResponseSchema, ReplaceStatusResponseSchema,
SprintCreateRequestSchema, SprintCreateRequestSchema,
SprintDeleteRequestSchema,
SprintRecordSchema, SprintRecordSchema,
SprintsByProjectQuerySchema, SprintsByProjectQuerySchema,
SprintUpdateRequestSchema,
StatusCountResponseSchema, StatusCountResponseSchema,
SuccessResponseSchema, SuccessResponseSchema,
TimerEndRequestSchema, TimerEndRequestSchema,