resourceui

Tutorials and Examples

This page works through the parts of resourceui the Getting Started guide doesn’t cover: schema-driven setup, actions, custom layouts, filtering, standalone items, and custom transports. Each section is self-contained.

Table of contents


Deriving fields from JSON Schema

If you already have a JSON Schema for your entity (hand-written, or generated from an OpenAPI spec), you don’t need to hand-write FieldMetadata. fieldsFromJsonSchema reads a pragmatic subset of JSON Schema — plus a few x-* extensions for things JSON Schema doesn’t natively express — and produces a field list.

import { fieldsFromJsonSchema, type JsonSchema } from "@ferrumec/resourceui";

const productSchema: JsonSchema = {
  title: "product",
  type: "object",
  required: ["name", "price"],
  properties: {
    id: { type: "integer", readOnly: true },
    name: { type: "string", title: "Product name", "x-sortable": true },
    price: { type: "number", "x-sortable": true, "x-filterable": true },
    status: {
      type: "string",
      enum: ["draft", "published", "archived"],
      "x-labels": ["Draft", "Published", "Archived"],
    },
    internalNotes: { type: "string", "x-hidden": true },
  },
};

const fields = fieldsFromJsonSchema<Product>(productSchema);

This maps: enum → a select field with options built from enum + x-labels; type: "boolean"boolean; type: "number" | "integer"number; format: "email"email; format: "date" | "date-time"date; everything else → text. readOnly: true sets editable: false; x-hidden: true sets visible: false; x-sortable / x-filterable map directly.

To build a full Resource in one step — fields and a wired API together — use ResourceFromSchema. You still supply the ResourceApi yourself (hand-written or via createRestResourceApi); this only saves you from writing the field list separately:

import { ResourceFromSchema, createRestResourceApi } from "@ferrumec/resourceui";

const products = ResourceFromSchema<Product>({
  schema: productSchema,
  api: createRestResourceApi<Product>("/api/products"),
  // optional: fill in anything JSON Schema can't express (functions)
  fieldOverrides: {
    price: { format: (value) => `$${(value as number).toFixed(2)}` },
  },
});

ItemFromSchema is the standalone-record equivalent — see Standalone items.


Generating everything from an API schema

When you also want the request functions themselves generated — not just the field list — describe both fields and endpoints as a plain, JSON-serializable object using ResourceSchema / ItemSchema. This is the fastest path from “here are my REST endpoints” to a fully working set of views, and the schema itself is plain data, so it can come from a config file or be generated by a backend that already knows its own routes.

import { ResourceFromApiSchema, type ResourceSchema } from "@ferrumec/resourceui";

const orderSchema: ResourceSchema = {
  name: "order",
  idField: "id",
  fields: [
    { name: "id", type: "number", editable: false },
    { name: "customerName", label: "Customer", type: "text", sortable: true },
    { name: "total", type: "number", sortable: true },
    { name: "status", type: "select", options: [
      { label: "Pending", value: "pending" },
      { label: "Shipped", value: "shipped" },
      { label: "Cancelled", value: "cancelled" },
    ]},
  ],
  list: "/api/orders",
  create: "/api/orders",
  item: "/api/orders/{id}",       // {id} is substituted per record
  actions: [
    { name: "exportCsv", endpoint: "/api/orders/export", method: "GET" },
  ],
  itemActions: [
    { name: "markShipped", endpoint: "/api/orders/{id}/ship", method: "POST" },
  ],
};

const orders = ResourceFromApiSchema<Order>({ schema: orderSchema });

What you get back is a complete ResourceController<Order>:

The standalone-item counterpart is ItemFromApiSchema, which takes an ItemSchema ({ fields, actions, get, update, delete } — no id, since get/update/delete are already full endpoints):

import { ItemFromApiSchema, type ItemSchema } from "@ferrumec/resourceui";

const meSchema: ItemSchema = {
  name: "profile",
  fields: [
    { name: "displayName", type: "text", required: true },
    { name: "email", type: "email", editable: false },
  ],
  get: "/api/me",
  update: "/api/me",
  delete: "/api/me",
  actions: [{ name: "regenerateApiKey", endpoint: "/api/me/regenerate-key", method: "POST" }],
};

const me = ItemFromApiSchema<Profile>({ schema: meSchema });

If you only need the raw generated API (no controller) — for instance to plug into a hand-rolled Resource(...) call — use resourceApiFromSchema / itemApiFromSchema directly; ResourceFromApiSchema / ItemFromApiSchema are thin conveniences on top of them.


Standalone items

Not every screen belongs to a collection. A user’s own profile, account settings, or “the currently active organization” is a single record with no list around it — modeling it as a one-item Resource is unnecessary indirection. Item covers this directly:

import { Item, createRestItemApi, type FieldMetadata } from "@ferrumec/resourceui";

interface Profile {
  displayName: string;
  email: string;
  timezone: string;
}

const profileFields: FieldMetadata<Profile>[] = [
  { name: "displayName", type: "text", required: true },
  { name: "email", type: "email", editable: false },
  { name: "timezone", type: "text" },
];

const profile = Item<Profile>({
  metadata: { name: "profile", fields: profileFields },
  api: createRestItemApi<Profile>("/api/me"),
});
function ProfilePage() {
  return profile.DetailView({ onEdit: () => setEditing(true) });
}

function ProfileEditForm() {
  return profile.UpdateView({ onUpdated: () => setEditing(false) });
}

ItemController never imports or references ResourceController — the dependency only runs the other way. That’s what makes this work with no collection in sight: createRestItemApi("/api/me") gives you get/update/delete bound directly to that one URL.

ItemFromSchema combines this with JSON-Schema-derived fields the same way ResourceFromSchema does for resources, and ItemFromApiSchema (shown above) combines it with a fully generated API from an ItemSchema.


Collection-level and per-record actions

Beyond get/update/delete/list/create, real APIs usually have named operations: activate, resendInvite, exportCsv, markShipped. resourceui models these as an ActionMap — a plain object of already-bound (payload?) => Promise<any> functions — at three possible levels:

Level Where it’s declared How it’s called
Item-level ItemMetadata.actions item.callAction(name, payload)
Resource-level (collection) ResourceMetadata.actions (inherited from ItemMetadata) resource.callAction(name, payload)
Per-record, derived from id ResourceMetadata.itemActions?: (id) => ActionMap resource.item(id).callAction(...) — the same cached Item a row click produces

Hand-writing actions:

const users = Resource<User>({
  name: "user",
  fields: userFields,
  api: usersApi,
  actions: {
    exportCsv: () => fetch("/api/users/export").then((r) => r.blob()),
  },
  itemActions: (id) => ({
    resendInvite: () => fetch(`/api/users/${id}/resend-invite`, { method: "POST" }),
    deactivate: (reason) =>
      fetch(`/api/users/${id}/deactivate`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ reason }),
      }),
  }),
});

The default list and detail layouts already render a button per action name and wire it to runAction / runItemAction for you — no extra work needed to make actions clickable. Calling from code directly:

await users.callAction("exportCsv");
await users.item(42).callAction("deactivate", { reason: "left the company" });

When you’re generating from an ApiSchema, actions are declared as ActionDescriptor[] ({ name, endpoint, method? }, with {id} substitution for itemActions) — see Generating everything from an API schema above; resourceui builds the ActionMap for you.


Custom layouts

The default layouts (DefaultListLayout, DefaultCreateLayout, DefaultDetailLayout, DefaultUpdateLayout) exist to make the library usable out of the box, not to be your final UI. Every view accepts a layout option — any component matching that view’s props contract fully replaces the rendering, while resourceui keeps handling fetching, mutation, and state.

import type { ListLayoutComponent } from "@ferrumec/resourceui";

const CardListLayout: ListLayoutComponent<User> = ({ data, loading, error, actions, navigation }) => {
  if (loading) return <Spinner />;
  if (error) return <ErrorBanner message={error} />;
  return (
    <div className="card-grid">
      {data.map((user) => (
        <UserCard
          key={user.id}
          user={user}
          onClick={() => navigation.viewItem(user.id)}
          onDelete={() => actions.delete(user.id)}
        />
      ))}
    </div>
  );
};

users.ListView({ layout: CardListLayout });

Every layout receives metadata (so you can still drive column/field generation off the same source of truth), the data/loading/error state, an actions object scoped to that view (refresh, setPage, create, update, delete, runAction, runItemAction for List; submit for Create; refresh/delete/runAction for Detail; submit/runAction for Update), and, for List/Detail, a navigation object for moving between screens.

You can reuse the library’s own building blocks inside a custom layout instead of starting from scratch — FieldInput (a single controlled input sized to a field’s type) and Pager (prev/next + page count) are exported for exactly this:

import { FieldInput, Pager } from "@ferrumec/resourceui";

Swap layouts per-call, so the same controller can back a compact table on one screen and a card grid on another — the underlying store and data stay shared regardless of which layout is rendering it.


Filtering, sorting, and pagination

A ResourceController’s list state is driven by a single ListQuery<T> (page, pageSize, sortBy, sortDir, filters), and every mutation of it goes through refresh, which merges the patch into the current query and re-fetches:

users.refresh({ sortBy: "name", sortDir: "asc" });
users.refresh({ filters: { isActive: true } });
users.setPage(2);          // shorthand for refresh({ page: 2 })
users.setPageSize(50);     // shorthand for refresh({ pageSize: 50, page: 1 })

From inside a list layout, the same operations are available as actions.setQuery(patch) / actions.setPage(page) / actions.setPageSize(size) — the default list layout uses these to implement clickable sortable column headers and the page-size selector.

Seed the initial query when constructing the resource:

const users = Resource<User>(
  { name: "user", fields: userFields, api: usersApi },
  { pageSize: 50, sortBy: "name", sortDir: "asc", filters: { isActive: true } }
);

Or per-ListView call, when the same controller backs multiple differently-filtered views:

users.ListView({ query: { filters: { isActive: false } } });

Only fields marked sortable: true / filterable: true in metadata are treated as such by the default layout — this is advisory metadata, not enforcement, so a custom layout is free to expose sorting/filtering on any field it wants.

If you’re using createRestResourceApi / resourceApiFromSchema, buildListQueryParams is what turns a ListQuery into URLSearchParams (page, pageSize, sortBy, sortDir, filter[key]=value per active filter) — export it directly if you’re wiring a custom list function but want the same query-string convention.


Custom HTTP client (auth headers, etc.)

Every REST-facing function in resourceui accepts an optional HttpClient and falls back to fetchHttpClient when omitted:

export interface HttpClient {
  get: (url: string) => Promise<any>;
  post: (url: string, body: unknown) => Promise<any>;
  put: (url: string, body: unknown) => Promise<any>;
  patch: (url: string, body: unknown) => Promise<any>;
  delete: (url: string) => Promise<void>;
}

Implement your own to add auth headers, handle token refresh, or swap fetch for axios:

import type { HttpClient } from "@ferrumec/resourceui";

function withAuth(token: string): HttpClient {
  const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}` };
  return {
    get: (url) => fetch(url, { headers }).then((r) => r.json()),
    post: (url, body) => fetch(url, { method: "POST", headers, body: JSON.stringify(body) }).then((r) => r.json()),
    put: (url, body) => fetch(url, { method: "PUT", headers, body: JSON.stringify(body) }).then((r) => r.json()),
    patch: (url, body) => fetch(url, { method: "PATCH", headers, body: JSON.stringify(body) }).then((r) => r.json()),
    delete: (url) => fetch(url, { method: "DELETE", headers }).then(() => undefined),
  };
}

const http = withAuth(currentToken);

const orders = ResourceFromApiSchema<Order>({ schema: orderSchema, http });
const products = createRestResourceApi<Product>("/api/products", http);

Any function that ultimately calls a REST endpoint — createRestItemApi, createRestResourceApi, itemApiFromSchema, resourceApiFromSchema, ItemFromApiSchema, ResourceFromApiSchema — takes this same http parameter.


Layering custom behavior onto derived fields

JsonSchemaProperty and FieldDescriptor are both JSON-serializable, so neither can carry function-valued behavior — a custom format, a validate function, or a visible predicate that depends on the record. Every schema-driven builder accepts a fieldOverrides map for exactly this: named per-field patches merged on top of whatever the schema derived.

const products = ResourceFromSchema<Product>({
  schema: productSchema,
  api: productsApi,
  fieldOverrides: {
    price: {
      format: (value) => `$${(value as number).toFixed(2)}`,
      validate: (value) => (value <= 0 ? "Price must be positive" : null),
    },
    status: {
      // hide the field entirely for archived records instead of just showing it
      visible: (record) => record.status !== "archived",
    },
  },
});

This is the general pattern for the whole schema layer: the schema is the source of truth for shape (types, required-ness, options), and fieldOverrides is where behavior that can’t be expressed as data gets layered back on afterward.