Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Last updated on

Was this page helpful?