From cc6ae7404140c72285ccbcce7d9289ca66ea8031 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Mon, 12 Jan 2026 01:09:05 +0000 Subject: [PATCH] basic CreateSprint component --- .../frontend/src/components/create-sprint.tsx | 264 ++++++++++++++++++ packages/frontend/src/pages/App.tsx | 26 +- 2 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 packages/frontend/src/components/create-sprint.tsx diff --git a/packages/frontend/src/components/create-sprint.tsx b/packages/frontend/src/components/create-sprint.tsx new file mode 100644 index 0000000..927dcb8 --- /dev/null +++ b/packages/frontend/src/components/create-sprint.tsx @@ -0,0 +1,264 @@ +import { DEFAULT_SPRINT_COLOUR } from "@issue/shared"; +import { type FormEvent, useMemo, useState } from "react"; +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 { 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, + trigger, + completeAction, +}: { + projectId?: number; + trigger?: React.ReactNode; + completeAction?: () => 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 () => { + setOpen(false); + reset(); + try { + await completeAction?.(); + } catch (actionErr) { + console.error(actionErr); + } + }, + onError: (message) => { + setError(message); + setSubmitting(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 + /> + + +
+ +
+ + + + + + + { + if (!value) return; + setEndDate(getEndOfDay(value)); + }} + autoFocus + /> + + +
+
+ +
+ + +
+ +
+ {error || dateError ? ( + + ) : ( + + )} +
+ +
+ + + + +
+
+
+
+
+ ); +} diff --git a/packages/frontend/src/pages/App.tsx b/packages/frontend/src/pages/App.tsx index 8dfd22d..373dab4 100644 --- a/packages/frontend/src/pages/App.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -10,6 +10,7 @@ import type { import { useEffect, useMemo, useRef, useState } from "react"; import AccountDialog from "@/components/account-dialog"; import { CreateIssue } from "@/components/create-issue"; +import { CreateSprint } from "@/components/create-sprint"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssuesTable } from "@/components/issues-table"; import LogOutButton from "@/components/log-out-button"; @@ -48,6 +49,10 @@ export default function App() { const [members, setMembers] = useState([]); + const isAdmin = + selectedOrganisation?.OrganisationMember.role === "owner" || + selectedOrganisation?.OrganisationMember.role === "admin"; + const deepLinkParams = useMemo(() => { const params = new URLSearchParams(window.location.search); const orgSlug = params.get("o")?.trim().toLowerCase() ?? ""; @@ -383,15 +388,18 @@ export default function App() { /> )} {selectedOrganisation && selectedProject && ( - { - if (!selectedProject) return; - await refetchIssues(); - }} - /> + <> + { + if (!selectedProject) return; + await refetchIssues(); + }} + /> + {isAdmin && } + )}