113 lines
3.6 KiB
TypeScript
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>
|
|
)
|
|
}
|