diff --git a/packages/frontend/src/Index.tsx b/packages/frontend/src/Index.tsx index c0b5a04..db9a564 100644 --- a/packages/frontend/src/Index.tsx +++ b/packages/frontend/src/Index.tsx @@ -2,6 +2,7 @@ import type { IssueResponse, OrganisationResponse, ProjectResponse, UserRecord } from "@issue/shared"; import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +import { CreateIssue } from "@/components/create-issue"; import { CreateOrganisation } from "@/components/create-organisation"; import { CreateProject } from "@/components/create-project"; import { IssueDetailPane } from "@/components/issue-detail-pane"; @@ -273,6 +274,15 @@ function Index() { )} + {selectedOrganisation && selectedProject && ( + { + if (!selectedProject) return; + await refetchIssues(selectedProject.Project.key); + }} + /> + )}
diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/create-issue.tsx new file mode 100644 index 0000000..ec4773d --- /dev/null +++ b/packages/frontend/src/components/create-issue.tsx @@ -0,0 +1,218 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn, getAuthHeaders } from "@/lib/utils"; + +export function CreateIssue({ + projectId, + trigger, + completeAction, +}: { + projectId?: number; + trigger?: React.ReactNode; + completeAction?: (issueId: number) => void | Promise; +}) { + const serverURL = import.meta.env.VITE_SERVER_URL?.trim() || "http://localhost:3000"; + + const userId = JSON.parse(localStorage.getItem("user") || "{}").id as number | undefined; + + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + const [titleTouched, setTitleTouched] = useState(false); + const [descriptionTouched, setDescriptionTouched] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const titleInvalid = useMemo( + () => ((titleTouched || submitAttempted) && title.trim() === "" ? "Cannot be empty" : ""), + [titleTouched, submitAttempted, title], + ); + + const descriptionInvalid = useMemo( + () => + (descriptionTouched || submitAttempted) && description.trim().length > 2048 + ? "Too long (2048 character limit)" + : "", + [descriptionTouched, submitAttempted, description], + ); + + const reset = () => { + setTitle(""); + setDescription(""); + + setTitleTouched(false); + setDescriptionTouched(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 (title.trim() === "" || descriptionInvalid !== "") { + return; + } + + if (!userId) { + setError("you must be logged in to create an issue"); + return; + } + + if (!projectId) { + setError("select a project first"); + return; + } + + setSubmitting(true); + + try { + const url = new URL(`${serverURL}/issue/create`); + url.searchParams.set("projectId", `${projectId}`); + url.searchParams.set("title", title.trim()); + url.searchParams.set("description", description.trim()); + + const res = await fetch(url.toString(), { + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const message = await res.text(); + setError(message || `failed to create issue (${res.status})`); + setSubmitting(false); + return; + } + + const issue = (await res.json()) as { id?: number }; + if (!issue.id) { + setError("failed to create issue"); + setSubmitting(false); + return; + } + + setOpen(false); + reset(); + try { + await completeAction?.(issue.id); + } catch (actionErr) { + console.error(actionErr); + } + } catch (err) { + console.error(err); + setError("failed to create issue"); + setSubmitting(false); + } + }; + + return ( + + + {trigger || ( + + )} + + + + + Create Issue + + +
+
+
+ + { + setTitle(e.target.value); + }} + onBlur={() => setTitleTouched(true)} + aria-invalid={titleInvalid !== ""} + placeholder="Demo Issue" + required + /> +
+ {titleInvalid !== "" ? ( + + ) : ( + + )} +
+
+ +
+ + { + setDescription(e.target.value); + }} + onBlur={() => setDescriptionTouched(true)} + aria-invalid={descriptionInvalid !== ""} + placeholder="Optional details" + /> +
+ {descriptionInvalid !== "" ? ( + + ) : ( + + )} +
+
+ +
+ {error ? ( + + ) : ( + + )} +
+ +
+ + + + +
+
+
+
+
+ ); +} diff --git a/todo.md b/todo.md index 2e6f777..467bb95 100644 --- a/todo.md +++ b/todo.md @@ -1,3 +1,8 @@ - user settings/profile page -- create issue +- org settings - add/invite user(s) to org +- issues + - issue creator + - issue assignee + - deadline +- time tracking (linked to issues or standalone)