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:
parent
0e10473e6f
commit
811b23cd6b
6 changed files with 517 additions and 377 deletions
12
frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css
Normal file
12
frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css
Normal 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;
|
||||
}
|
||||
26
frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx
Normal file
26
frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/packages/swarm-ui/src/ui/submit/FormResult.css
Normal file
3
frontend/packages/swarm-ui/src/ui/submit/FormResult.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.ui-form-result {
|
||||
margin-block: 1em;
|
||||
}
|
||||
32
frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx
Normal file
32
frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx
Normal 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;
|
||||
}
|
||||
42
frontend/packages/swarm-ui/src/ui/submit/submit.ts
Normal file
42
frontend/packages/swarm-ui/src/ui/submit/submit.ts
Normal 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) } };
|
||||
}
|
||||
}
|
||||
40
frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts
Normal file
40
frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts
Normal 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" }),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue