Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: forms share one submit flow, result line and field column

useSubmit holds the idle, sending, done and error state and the request
flow that six forms each wrote out; FormResult shows the success line and
the problem panel; FormStack is the column the link, claude-login,
inference and limits forms each styled by hand. The create-agent, link
account, backend-env and agent-input forms use the submit flow; the link,
claude-login, inference and limits forms use FormStack.
This commit is contained in:
iris 2026-11-06 18:47:34 +01:00 • committed by mara
commit 811b23cd6b
6 changed files with 517 additions and 377 deletions

View file

@ -0,0 +1,12 @@
.ui-form-stack {
display: flex;
flex-direction: column;
gap: 0.75em;
margin-top: 1em;
}
.ui-form-stack-start {
align-items: flex-start;
}
.ui-form-stack-stretch {
align-items: stretch;
}

View file

@ -0,0 +1,26 @@
// <FormStack> — a `<form>` whose fields and buttons stack in one column, with
// the gap the forms in dialogs and panels share. The fields keep their own
// width (`align="start"`) or fill the form (`align="stretch"`).
import type { ComponentChildren } from "preact";
import "./FormStack.css";
export function FormStack({
onSubmit,
align = "start",
class: extraClass,
children,
}: {
onSubmit: (e: Event) => void;
align?: "start" | "stretch";
class?: string;
children: ComponentChildren;
}) {
const classes = ["ui-form-stack", `ui-form-stack-${align}`, extraClass]
.filter(Boolean)
.join(" ");
return (
<form class={classes} onSubmit={onSubmit}>
{children}
</form>
);
}

View file

@ -0,0 +1,3 @@
.ui-form-result {
margin-block: 1em;
}

View file

@ -0,0 +1,32 @@
// <FormResult> — what a form says after it sent its request: a success line
// when it is done, the problem panel when it failed, nothing otherwise. The
// success line is a `Hint` in the `ok` tone.
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Hint } from "../hint/Hint.js";
import type { SubmitState } from "./submit.js";
import "./FormResult.css";
export function FormResult<T = void>({
state,
done,
failed,
}: {
state: SubmitState<T>;
/** The success line, given what the reply parsed to. */
done: (value: T) => ComponentChildren;
/** What failed, as the panel's heading says it: "failed to link the account". */
failed: string;
}) {
if (state.status === "done") {
return (
<Hint tone="ok" class="ui-form-result">
{done(state.value)}
</Hint>
);
}
if (state.status === "error") {
return <ApiErrorPanel context={failed} problem={state.problem} />;
}
return null;
}

View file

@ -0,0 +1,42 @@
// The state of a form that sends one request and shows how it went: idle,
// sending, done (with whatever the success parsed out of the reply) or an
// error the form shows as a problem panel. `submitOutcome` is the request flow
// itself, with no UI in it, so a test can run it; `useSubmit` wraps it in
// component state.
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
export type SubmitState<T = void> =
| { status: "idle" }
| { status: "submitting" }
| { status: "done"; value: T }
| { status: "error"; problem: ProblemDetails };
export interface SubmitOptions<T> {
/** Read the success reply; the result is the `done` state's `value`. */
parse?: (reply: Response) => T | Promise<T>;
/** Reword a refusal, for a status that deserves a plainer message than the
* server's own. */
refine?: (reply: Response, problem: ProblemDetails) => ProblemDetails;
}
export async function submitOutcome<T = void>(
request: () => Promise<Response>,
{ parse, refine }: SubmitOptions<T> = {},
): Promise<
{ status: "done"; value: T } | { status: "error"; problem: ProblemDetails }
> {
try {
const reply = await request();
if (!reply.ok) {
const problem = await readApiError(reply);
return {
status: "error",
problem: refine ? refine(reply, problem) : problem,
};
}
const value = (parse ? await parse(reply) : undefined) as T;
return { status: "done", value };
} catch (err) {
return { status: "error", problem: { detail: String(err) } };
}
}

View file

@ -0,0 +1,40 @@
// `useSubmit` — the submit state of one form (see `submit.ts`). `run` sends
// the request and sets the state; `onDone` and `onError` are for what the form
// does with the outcome besides showing it (clear a field, go back a step).
// `fail` shows a problem the form found itself, without sending anything;
// `reset` returns to idle, for a form that starts over.
import { useState } from "preact/hooks";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import {
submitOutcome,
type SubmitOptions,
type SubmitState,
} from "./submit.js";
export interface RunOptions<T> extends SubmitOptions<T> {
onDone?: (value: T) => void;
onError?: (problem: ProblemDetails) => void;
}
export function useSubmit<T = void>() {
const [state, setState] = useState<SubmitState<T>>({ status: "idle" });
async function run(
request: () => Promise<Response>,
{ onDone, onError, ...options }: RunOptions<T> = {},
) {
setState({ status: "submitting" });
const outcome = await submitOutcome(request, options);
setState(outcome);
if (outcome.status === "done") onDone?.(outcome.value);
else onError?.(outcome.problem);
}
return {
state,
submitting: state.status === "submitting",
run,
fail: (problem: ProblemDetails) => setState({ status: "error", problem }),
reset: () => setState({ status: "idle" }),
};
}