From 86a11e6cb5c6a21685ba7455242f562ac74d0c74 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Tue, 30 Dec 2025 05:13:50 +0000 Subject: [PATCH 1/3] automatically focus first input --- packages/frontend/src/components/login-form.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index 67c3ca2..4c286f0 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -1,4 +1,5 @@ -import { type ChangeEvent, useMemo, useState } from "react"; +/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ +import { type ChangeEvent, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -140,6 +141,17 @@ export default function LogInForm() { }); }; + const focusFirstInput = () => { + const firstInput = document.querySelector("input"); + if (firstInput) { + (firstInput as HTMLInputElement).focus(); + } + }; + + useEffect(() => { + focusFirstInput(); + }, []); + const resetForm = () => { setError(""); setSubmitAttempted(false); @@ -147,6 +159,8 @@ export default function LogInForm() { setNameTouched(false); setUsernameTouched(false); setPasswordTouched(false); + + requestAnimationFrame(() => focusFirstInput()); }; const handleSubmit = (e: React.FormEvent) => { From cad3fec8ba15ca46ee04d7113bba1e8a414f54ca Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Tue, 30 Dec 2025 05:14:02 +0000 Subject: [PATCH 2/3] refetchIssues function --- packages/frontend/src/Index.tsx | 34 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/frontend/src/Index.tsx b/packages/frontend/src/Index.tsx index a937587..c0b5a04 100644 --- a/packages/frontend/src/Index.tsx +++ b/packages/frontend/src/Index.tsx @@ -80,6 +80,10 @@ function Index() { void refetchOrganisations(); }, [user.id]); + useEffect(() => { + setSelectedOrganisation((prev) => prev || organisations[0] || null); + }, [organisations]); + const refetchProjects = async (organisationId: number, options?: { selectProjectId?: number }) => { try { const res = await fetch( @@ -126,24 +130,30 @@ function Index() { void refetchProjects(selectedOrganisation.Organisation.id); }, [selectedOrganisation]); + useEffect(() => { + setSelectedProject((prev) => prev || projects[0] || null); + }, [projects]); + + const refetchIssues = async (projectKey: string) => { + try { + const res = await fetch(`${serverURL}/issues/${projectKey}`, { + headers: getAuthHeaders(), + }); + const data = (await res.json()) as IssueResponse[]; + setIssues(data); + } catch (err) { + console.error("error fetching issues:", err); + setIssues([]); + } + }; + // fetch issues when project is selected useEffect(() => { if (!selectedProject) return; - fetch(`${serverURL}/issues/${selectedProject.Project.key}`, { headers: getAuthHeaders() }) - .then((res) => res.json()) - .then((data: IssueResponse[]) => { - setIssues(data); - }) - .catch((err) => { - console.error("error fetching issues:", err); - }); + void refetchIssues(selectedProject.Project.key); }, [selectedProject]); - useEffect(() => { - setSelectedProject((prev) => prev || projects[0] || null); - }, [projects]); - return (
{/* header area */} From daee3fb52652f9192bf55a5939904ce14c155c18 Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Tue, 30 Dec 2025 05:32:52 +0000 Subject: [PATCH 3/3] functional issue creation --- packages/frontend/src/Index.tsx | 10 + .../frontend/src/components/create-issue.tsx | 218 ++++++++++++++++++ todo.md | 7 +- 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/components/create-issue.tsx 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)