Tables and forms
Apps/Build/Tables and forms

Tables and forms

AutoTable lists a model's records and AutoForm creates and edits them, both drawn from the model's schema: no hand-written list or form per model. Each comes in Polaris web components for the Shopify admin and in standard elements in the app's own look.

The records route

The components run in the browser, where the app's environment token must never go, so they read and write through the app's own route: serveRecords(request, api) from @inferencebureau/apps-sdk at web/app/api/records/route.ts. Every call goes through the api the route passes: the merchant's session in a Shopify app, the signed-in user's role in a web app. permissions.ib.ts decides what a page may read and change; the route answers 401 when there's no one signed in.

// web/app/api/records/route.ts (in the Shopify starters)
import { serveRecords, verifyShopifySession } from "@inferencebureau/apps-sdk";

export async function POST(request: Request) {
  const session = await verifyShopifySession(request);
  return serveRecords(request, session?.api ?? null);
}

New apps start with the route. An app made before it existed adds the file. serveRecords takes { models } to answer for only some models, { globalActions } to run global actions, and { enqueueActions } to queue actions as background jobs, both of which it otherwise refuses. In a web app, grant the signed-in role the models its pages show: the starter's role reads only the user's own record.

AutoTable

"use client";
import { AutoTable } from "@inferencebureau/apps-sdk/polaris";

export default function Reviews() {
  return (
    <s-page heading="Reviews">
      <AutoTable model="review" columns={["title", "rating", "status", "product"]} selectable rowHref={(r) => `/reviews/${r.id}`} />
    </s-page>
  );
}
PropDoes
modelThe model whose records it lists.
columnsFields in order, or { field, label } for a heading of its own. Unset: the first six fields that read well in a cell (no long text, JSON, relationship lists, or fields ending in Hash, Token or Secret), then Updated.
pageSizeRecords a page: 25 unless given, at most 250.
filter, sortOnly records matching, always; and the order before the viewer picks one (newest first unless given).
selectableCheckboxes for choosing rows, and a bulk delete that asks to confirm. Deletes run the model's delete action when it has one.
rowHref, onRowClickWhat a row opens. In Polaris the whole row is clickable through the first cell's link.
searchableThe search box, on unless false. It searches the model's text fields as the API's search does.
liveShows changes to the records as they happen, whoever makes them. See Live queries.

Columns sort by their heading in the app's look, and from a sort menu in Polaris (Shopify's table has no sortable headings). A belongsTo column shows its record's title, name or email; yes/no and choice fields show as badges; files as their name, with a thumbnail for an image.

AutoForm

"use client";
import { useRouter } from "next/navigation";
import { AutoForm } from "@inferencebureau/apps-sdk/polaris"; // or /react

// web/app/reviews/[id]/edit.tsx: an edit form; leave out id for a new record.
export function EditReview({ id }: { id: string }) {
  const router = useRouter();
  return <AutoForm model="review" id={id} exclude={["moderatedBy"]} saveBar onSuccess={() => router.push("/reviews")} />;
}

// A custom action: its params are the fields named.
<AutoForm model="review" id={id} action="reply" fields={[{ field: "merchantReply", label: "Your reply" }]} />
  • Without id it creates a record, with only the fields filled in, so the rest take their defaults. With id it loads the record and sends only what changed. Both go the way the API's writes do: through the model's create or update action when it has one.
  • Fields come from the schema: every field a value can be written to, unless fields names them. Each type gets its control: a select for an enum (choices for a list of them), a picker of the parent's records for a belongsTo, a date and time for a dateTime, a file chooser that uploads straight to storage for a file, JSON for a json field.
  • Required fields, numbers, dates and JSON are checked before sending. What the API refuses shows on the field it's about; anything else shows above the form.
  • In Polaris, saveBar uses Shopify's contextual save bar instead of a Save button.

An action's params aren't described to the browser, so a form for a custom action (action) takes its fields from fields, each named after a model field whose control it uses.

Polaris or the app's look

@inferencebureau/apps-sdk/polaris draws them in Polaris web components, Shopify's index table and fields, for a Shopify app whose layout loads polaris-2.js. @inferencebureau/apps-sdk/react draws them in standard elements with the starters' classes (.btn and .auto-* in web/app/globals.css) and its CSS variables (--bg, --line, --text, …), so they look like the rest of an app in Inference Bureau's look, Shopify or web. Neither brings a stylesheet of its own: change the variables, or the .auto-* rules, and they follow. An app made before the rules existed can copy them from a new app's globals.css.

Hooks

For pages of their own, @inferencebureau/apps-sdk/react has the hooks the components are built on. Reads give { data, fetching, error, refetch }; writes give { run, running, data, error }, and once one succeeds, every read of that model on the page reads again.

The app's generated types (web/ib-client.d.ts) type them as they type api: the model's name, the fields a filter, sort or select names, and an action's params and result, so a typo fails the type check rather than the request. Records come back as the model's type, or as what select picked of it. An app without the file yet takes any name.

HookDoes
useFindMany(model, options)A page of records, with filter, sort, search, select, first, and after or skip, as the API takes them. nextPage() and previousPage() move by cursor, with hasNextPage, hasPreviousPage and page; new options start again from the first page.
useFindOne(model, id)One record, or null. Waits while the id is null.
useFindFirst(model, options)The first record matching, or null.
useAggregate(model, options)Groups of records with their counts, sums and averages, for a chart or a report: { groups, hasMore }, as aggregate answers.
useAction(model, action)Runs one of the model's actions: run({ id, params }). create, update and delete write as the API's writes do.
useGlobalAction(action)Runs a global action the route allows (globalActions).
useEnqueue(action)Queues a global action, or "<model>.<action>" with id, as a background job the route allows (enqueueActions): run({ id, params, delay }) answers with the job. It runs for the page's store and as its role.
useModel(model)The model's fields, as the components read them.
"use client";
import { useAction, useFindMany } from "@inferencebureau/apps-sdk/react";

export function Pending() {
  const reviews = useFindMany("review", { filter: { status: { equals: "pending" } }, select: { title: true, product: { select: { title: true } } }, first: 20 });
  const approve = useAction("review", "approve");
  if (reviews.error) return <p>{reviews.error.message}</p>;
  return (
    <>
      <ul>
        {reviews.data?.records.map((r) => (
          <li key={r.id}>
            {r.title} on {r.product?.title} <button disabled={approve.running} onClick={() => approve.run({ id: r.id })}>Approve</button>
          </li>
        ))}
        {reviews.fetching && <li>Loading…</li>}
      </ul>
      <button disabled={!reviews.hasPreviousPage} onClick={reviews.previousPage}>Previous</button>
      <button disabled={!reviews.hasNextPage} onClick={reviews.nextPage}>Next</button>
    </>
  );
}

They call /api/records with sessionFetch, which adds the session token in the Shopify admin and is a plain same-origin request elsewhere, carrying the signed-in user's cookie. <RecordsProvider endpoint="…"> points them at another route.

Live queries

A read given live reads again whenever its model's records change, whoever changes them: an action, a job, a Shopify sync, a webhook, another tab or another staff member. Without it, a read refreshes only after a write made on the same page.

"use client";
import { AutoTable, useFindMany } from "@inferencebureau/apps-sdk/react";

// New orders appear as the sync writes them, without reloading.
<AutoTable model="shopifyOrder" columns={["name", "totalPrice", "financialStatus"]} live />

// Any read takes live.
const { data } = useFindMany("review", { filter: { status: { equals: "pending" } } }, { live: true });
  • The page keeps one request open to the records route for all its live reads (a long poll, answered within about a second of a change), and stops while the tab is hidden. The route asks Inference Bureau which of the models changed; nothing about the records comes back, only the models' names.
  • The read that follows goes through the route like any other, so permissions.ib.ts and the store decide what the page then sees. A merchant's page hears only of its own store's records of store-owned models, and a role may watch only models it can read.
  • A watch waits up to 25 seconds before the route answers that nothing changed, so the route's function must be allowed to run that long (the default is longer). serveRecords(request, api, { liveWait: 10 }) waits less.
  • Server code asks the same with api.$changes({ models, cursor, wait }), which answers { cursor, models }.

A live read on a busy model reads again for every change to it, a sync's included. Filter the read to what the page shows; a change to records outside the filter still triggers a read, which comes back the same.

Drawing your own

Both looks draw what useAutoTable and useAutoForm work out. A page can use them to draw a table or form its own way and keep the schema-driven columns, search, sorting, pages, selection, field controls and refusals.

"use client";
import { useAutoTable } from "@inferencebureau/apps-sdk/react";

// The same columns, search, sort, pages and selection, drawn however the page likes.
const t = useAutoTable("review", { pageSize: 50 });
t.columns;   // [{ name, label, sortable, numeric, field }]
t.rows;      // [{ id, record, cells: [{ kind: "text" | "boolean" | "tags" | "link" | … }] }]
t.setSearch("late delivery"); t.toggleSort("rating"); t.nextPage();