This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user