Files
template-app/modules/notes/ui/note-form.tsx
T

113 lines
3.6 KiB
TypeScript

"use client"
// Creates a note, or saves a change to one. The same form for both: a note being edited carries the
// version it was read at, so a second editor's save is a 409 shown here and never a silent overwrite.
import { emitAppEvent, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
import { type FormEvent, type ReactElement, useId, useState } from "react"
export interface NoteFormLabels {
readonly heading: string
readonly title: string
readonly body: string
readonly submit: string
readonly busy: string
readonly saved: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
export type NoteFormTarget =
| { readonly mode: "create"; readonly listHref: string }
| {
readonly mode: "edit"
readonly noteId: string
readonly version: number
readonly title: string
readonly body: string
}
export function NoteForm({
slugs,
target,
labels,
}: {
slugs: ProjectSlugs
target: NoteFormTarget
labels: NoteFormLabels
}): ReactElement {
const { call, describe } = useTransport()
const titleId = useId()
const bodyId = useId()
const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({
kind: "idle",
})
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const title = String(form.get("title") ?? "").trim()
const body = String(form.get("body") ?? "")
if (title.length === 0) return
setState({ kind: "busy" })
try {
if (target.mode === "create") {
const created = (await call("notes.note.create", { ...slugs, title, body })) as { noteId: string }
// For whatever listens in the browser (an in-app guide's checklist task); it waits for a
// listener that asked to store the event first, because the next line leaves the page.
await emitAppEvent("note_created")
window.location.assign(`${target.listHref}/${created.noteId}`)
return
}
await call("notes.note.update", {
...slugs,
noteId: target.noteId,
title,
body,
expectedVersion: target.version,
})
setState({ kind: "saved" })
// The page is rendered on the server: a reload shows the new version, which the next save needs.
window.location.reload()
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
return (
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<div className="kit-field" data-guide="notes.note-title">
<label htmlFor={titleId}>{labels.title}</label>
<input
id={titleId}
name="title"
type="text"
required
maxLength={200}
defaultValue={target.mode === "edit" ? target.title : ""}
/>
</div>
<div className="kit-field">
<label htmlFor={bodyId}>{labels.body}</label>
<textarea
id={bodyId}
name="body"
rows={6}
maxLength={20000}
defaultValue={target.mode === "edit" ? target.body : ""}
/>
</div>
<button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</button>
<p role="status">{state.kind === "saved" ? labels.saved : ""}</p>
{state.kind === "failed" ? (
<p className="app-error" role="alert">
{labels.failed.replace("{detail}", state.detail)}
</p>
) : null}
</form>
)
}