Composition
Write the domain surface yourself over the flow hook, place its pieces in your own layout, and add a domain through the zone picker your accounts already reach.
Domain.useFlow returns everything a surface needs and renders nothing, so the markup is yours from
the first element. The registry block is one such surface, on
shadcn; this page is the shape of writing another.
What the flow hands you
| Field | Carries |
|---|---|
state |
What DomainKit has to say about the domain, as plain fields |
connection |
The connect controller: connect, reuse, detach, disconnect |
provisioning, cleanup |
The plan, approve, apply machine for adding and for removing |
verification |
The observer, with the readiness it last read |
plan |
The plan still awaiting its apply, which is what the rows report |
readiness |
The last observation, which is what the rows report after an apply |
capabilities |
The groups the transport declares, so nothing offers a step it lacks |
requirements, domain |
What the host asked for, and for which domain |
One row’s standing
Records.standingOf answers both facts a row carries about one record, and leaves the surface to
choose which of them its column is about: planned is the operation a plan still awaiting its apply
holds for that record, and observed is the requirement the last observation stored for it. Either
is null when nothing covers the record.
/**
* Compose the surface yourself from the same hooks `Domain.useFlow` runs. Each takes one options
* object and returns a controller whose `state` is a tagged union.
*/
export function DomainRow({ domain }: { readonly domain: string }) {
const connection = Connect.useController({ domain });
const provisioning = Provision.useController({ domain, requirements });
const cleanup = Cleanup.useController({ domain });
const verification = Verify.useController({ domain, polling: true });
if (!Connect.holdsConnection(connection)) {
return <ConnectButton controller={connection} />;
}
return (
<section>
<RecordRows readiness={verification.readiness} records={requirements} />
<ReviewActions controller={provisioning} />
<RemoveButton controller={cleanup} />
</section>
);
}The one action
provisioning.approve() authorizes the plan’s digest and applies it in the same call. Name the
operation ids to approve part of a plan, which is how a blocked record is left out while the rest
goes in; approving a whole plan that holds a conflict is refused.
/**
* `approve` authorizes the digest and applies it in one action. `reject` records the refusal and
* is terminal. `retry` re-plans when the reason says the old plan is gone and re-runs the failed
* step otherwise.
*/
export function ReviewActions({ controller }: { readonly controller: Provision.Controller }) {
const state = controller.state;
if (state._tag !== "Planned") return null;
return (
<div>
<button onClick={() => controller.approve()} type="button">
Approve {state.plan.operations.length} change(s)
</button>
<button onClick={() => controller.reject("Not now")} type="button">
Decline
</button>
</div>
);
}Adding a domain
Connect.useZones lists every zone the workspace’s accounts reach, and Connect.useDomainField
turns one text input into a combobox over them: it returns the props for the input, the listbox, and
each option, moves the highlight with the arrow keys, and completes on Tab or Enter while keeping
whatever subdomain was typed in front of the zone.
/**
* One input over those zones. Arrow keys move the highlight, Tab or Enter finishes the name while
* keeping whatever subdomain was typed in front of the zone, and `found` says which account the
* records would go to.
*/
export function AddDomainField() {
const [value, setValue] = useState("");
const { zones } = Connect.useZones();
const field = Connect.useDomainField({ onChange: setValue, value, zones });
return (
<div>
<input {...field.inputProps} placeholder="app.example.com" />
<ul {...field.listboxProps}>
{field.suggestions.map((zone) => (
<li key={`${zone.connectionId}:${zone.zone}`} {...field.optionProps(zone)}>
{zone.zone}
</li>
))}
</ul>
<p>{field.found === null ? "Not in a connected account" : field.found.zone.label}</p>
</div>
);
}import { useState } from "react";
import { DomainField } from "@/components/domainkit/domain-field";
import { PreviewRoot, previewMarks } from "../../lib/preview-flow.tsx";
function Field() {
const [value, setValue] = useState("");
return (
<DomainField
marks={previewMarks}
onChange={setValue}
placeholder="mail.northwind.app"
value={value}
/>
);
}
export default function DomainFieldExample() {
return (
<PreviewRoot>
<Field />
</PreviewRoot>
);
}
A domain outside every connected zone leaves the input a plain text field, because the customer will
add those records by hand. onResolve reports the account a value settles in, so a form submits the
domain and its connection together.
/**
* Connecting an account names no domain: what the customer grants is the account itself. A token
* method finishes in place; an interactive one redirects and comes back through the library's own
* callback.
*/
export function ConnectAccount() {
const accounts = Connect.useAccounts();
const busy = accounts.state._tag === "Submitting" || accounts.state._tag === "Redirecting";
return accounts.providers.map((provider) => {
const methods = Connect.describeMethods(provider);
const interactive = methods.interactive[0];
if (interactive === undefined) return null;
return (
<button
disabled={busy}
key={provider.id}
onClick={() => accounts.connect({ method: interactive.kind, provider: provider.id })}
type="button"
>
Connect {provider.name}
</button>
);
});
}Connecting an account names no domain: what the customer grants is the account itself.
Connect.describeMethods arranges one provider’s methods into the interactive ones a customer
clicks through and the ones that ask for credentials, so a surface asks for one decision at a time.
Readiness the host holds
By default the flow observes on mount and re-observes at each nextCheckAt, which is what a host
with no clock of its own wants. A host that already observes — a cron, a durable job, a server
render that read Verify.latest — passes what it holds as verification instead. The flow then
makes no observation and sets no timer, flow.readiness is the supplied value, and
flow.verification.observe and retry call the host’s own. Drift replanning runs off it exactly as
it does off an observation the flow made.
/**
* The host owns the clock: it reads the stored readiness on its own schedule, and the flow renders
* what it was given. Nothing observes on mount, no timer runs, and `observe` and `retry` on the
* surface ask the host for a fresh reading.
*/
function HostObservedSetup({ domain }: { readonly domain: string }) {
const [readiness, setReadiness] = useState<Verify.Readiness | null>(null);
const refresh = useCallback(() => {
void api.verification?.latest(domain).then(setReadiness);
}, [domain]);
useEffect(refresh, [refresh]);
const flow = Domain.useFlow({
domain,
requirements,
verification: { readiness, observe: refresh },
});
const counts = Verify.summary(flow.readiness);
return (
<section>
<p>{counts.observed ? `${counts.satisfied} of ${counts.total} found` : "Not checked yet"}</p>
<table>
<tbody>
{flow.requirements.map((record) => {
// Two facts, two columns: what a pending plan will do, and what was read back.
const standing = Records.standingOf(record, {
plan: flow.plan,
readiness: flow.readiness,
});
return (
<tr key={Records.identity(record)}>
<td>{record.name}</td>
<td>{standing.planned === null ? null : standing.planned._tag}</td>
<td>{standing.observed?.status ?? "not checked"}</td>
</tr>
);
})}
</tbody>
</table>
<button onClick={() => flow.verification.observe()} type="button">
Check again
</button>
</section>
);
}
export function HostObserved({ domain }: { readonly domain: string }) {
return (
<DomainKit.Root transport={transport}>
<HostObservedSetup domain={domain} />
</DomainKit.Root>
);
}Verify.useController takes the same thing as supplied, for a surface built on that controller
alone. Verify.summary counts a readiness, or null, into { observed, total, satisfied, missing, mismatch, unknown }.
Read-only
readOnly on DomainKit.Root, or on one Domain.useFlow, refuses every command that would change
the domain, in the controller rather than in your markup, and reports itself on
flow.state.readOnly. A surface reads it and says who may connect rather than rendering an empty
row.
/** A surface of your own asks which mode it is in. */
export function RemoveDomainButton({ onRemove }: { readonly onRemove: () => void }) {
const readOnly = DomainKit.useReadOnly();
if (readOnly) return null;
return (
<button onClick={onRemove} type="button">
Remove this domain
</button>
);
}
/** Or narrow one subtree without touching the rest of the page. */
export function ReadOnlySection({ children }: { readonly children: React.ReactNode }) {
return <DomainKit.ReadOnly value={true}>{children}</DomainKit.ReadOnly>;
}DomainKit.useReadOnly() answers for a component of your own, and DomainKit.ReadOnly narrows one
subtree without touching the rest of the page.