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,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 { SprintSelect } from "./sprint-select";
export function CreateIssue({
export function IssueModal({
projectId,
sprints,
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 { 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 && <SelectSeparator />}
</SelectGroup>
<CreateOrganisation
<OrganisationModal
trigger={
<Button variant="ghost" className={"w-full"} size={"sm"}>
Create Organisation

View File

@@ -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<number>(0);
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<{
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({
</p>
)}
</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>
<OrganisationModal
mode="edit"
existingOrganisation={selectedOrganisation.Organisation}
open={editOrgOpen}
onOpenChange={setEditOrgOpen}
completeAction={async () => {
await refetchOrganisations({
selectOrganisationId: selectedOrganisation.Organisation.id,
});
}}
/>
</TabsContent>
<TabsContent value="users">
@@ -650,6 +722,63 @@ function OrganisationsDialog({
Creator: {selectedProject.User.name}
</p>
</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">
@@ -660,30 +789,118 @@ function OrganisationsDialog({
<div className="flex flex-col gap-2 min-w-0 flex-1">
{selectedProject ? (
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{sprints.map((sprint) => {
const dateRange = getSprintDateRange(sprint);
const isCurrent = isCurrentSprint(sprint);
{sprints.map((sprintItem) => {
const dateRange = getSprintDateRange(sprintItem);
const isCurrent = isCurrentSprint(sprintItem);
return (
<div
key={sprint.id}
key={sprintItem.id}
className={`flex items-center justify-between p-2 border ${
isCurrent
? "border-emerald-500/60 bg-emerald-500/10"
: ""
}`}
>
<SmallSprintDisplay sprint={sprint} />
{dateRange && (
<span className="text-xs text-muted-foreground">
{dateRange}
</span>
)}
<SmallSprintDisplay sprint={sprintItem} />
<div className="flex items-center gap-2">
{dateRange && (
<span className="text-xs text-muted-foreground">
{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>
);
})}
{isAdmin && (
<CreateSprint
<SprintModal
projectId={selectedProject?.Project.id}
completeAction={onCreateSprint}
trigger={
@@ -708,6 +925,33 @@ function OrganisationsDialog({
</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 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 { 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 && <SelectSeparator />}
</SelectGroup>
<CreateProject
<ProjectModal
organisationId={organisationId}
trigger={
<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,
"--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)

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 { 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";

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 { 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 { 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 { 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 && (
<CreateIssue
<IssueModal
projectId={selectedProject?.Project.id}
sprints={sprints}
members={members}