mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
edit + delete capabilities for org, project, sprint
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
279
packages/frontend/src/components/organisation-modal.tsx
Normal file
279
packages/frontend/src/components/organisation-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
268
packages/frontend/src/components/project-modal.tsx
Normal file
268
packages/frontend/src/components/project-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
346
packages/frontend/src/components/sprint-modal.tsx
Normal file
346
packages/frontend/src/components/sprint-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
37
packages/frontend/src/lib/server/organisation/delete.ts
Normal file
37
packages/frontend/src/lib/server/organisation/delete.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
35
packages/frontend/src/lib/server/project/delete.ts
Normal file
35
packages/frontend/src/lib/server/project/delete.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
43
packages/frontend/src/lib/server/project/update.ts
Normal file
43
packages/frontend/src/lib/server/project/update.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
35
packages/frontend/src/lib/server/sprint/delete.ts
Normal file
35
packages/frontend/src/lib/server/sprint/delete.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
49
packages/frontend/src/lib/server/sprint/update.ts
Normal file
49
packages/frontend/src/lib/server/sprint/update.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user