Skip to content

Your first resource

By the end of this walkthrough you will have a resource — Manufacturer — that lists, creates, edits, and validates against a schema. You need a checked-out OnERP that runs (pnpm dev) and enough React to read JSX.

OnERP is metadata-driven. You declare a business object — the schema — and a resource: where that schema lives in the UI, and which components render it. The framework reads both, generates the routes, fetches the data, and renders the form and the list. There is no glue in between, and no Manufacturer interface to keep in step — every type is computed from the declaration.

The walkthrough adds the object to domains/master, an existing domain package; a brand-new one needs the four extra steps at the foot of the page.

One folder per business object, and the schema file inside it is always schema.ts — how a domain package is laid out.

domains/master/src/manufacturer/schema.ts
import type { BusinessObject } from "@onerp/meta/records";
import { defineBusinessObject, field } from "@onerp/meta";
import { currencyCatalog } from "@onerp/catalogs";
export const ManufacturerSchema = defineBusinessObject(
"master/Manufacturer",
{
name: field.string({ label: "Name", required: true, searchable: true }),
code: field.string({ label: "Code", searchable: true }),
currency: field.codeList(currencyCatalog, { label: "Currency" }),
minimumOrderValue: field.amount({ label: "Minimum Order Value" }),
notes: field.text({ label: "Notes" }),
},
{ representation: "name" }
);
export type Manufacturer = BusinessObject<typeof ManufacturerSchema>;

Field kinds. Money is field.amount(), not a raw decimal — along with field.quantity() and field.rate() it is a semantic builder that pins the right precision and scale. See Value types; currency is a code list.

required: true is what removes | null from a field’s type, so declare it only for a value an active record cannot lack. searchable: true puts a field behind the list’s search box; declare none and no search box appears at all. representation names the field a reference to this record renders as.

No archivable, and no status field. archivable defaults to true, giving the standard master-data lifecycle: draft → active ↔ archived; set it to false only for a record that must never be retired, such as a posted document (one flag: archivable). Status is system metadata: defineBusinessObject throws if it finds a status field. And the instance type on the last line is not decoration — every schema publishes one beside itself, and consumers import that name.

domains/master/src/plugin.ts
@Plugin({
name: "master",
businessObjects: [
// …
ManufacturerSchema,
],
// …
})
export class MasterPlugin {}

A schema that no plugin lists is never composed — it compiles, and the business object simply does not exist (what the plugin declares).

Adding a business object does not create its table. Tables are synced from meta by a provisioner the HTTP server deliberately never calls — schema state is a deploy step, not a boot step.

Terminal window
pnpm build # the CLI runs against the compiled domain packages
pnpm cli migrate # sync business object tables from meta, for every tenant

migrate is idempotent. Run it again after any schema change.

A form body calls useForm<S>() to get a schema-bound factory, then declares which input binds to which field. The type parameter ties every name to the schema, so a typo or a wrong input kind is a compile error.

apps/web/src/resources/manufacturers/manufacturer-form.tsx
import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";
import { FormGrid, useForm } from "@onerp/react/forms";
export const ManufacturerForm = () => {
const f = useForm<typeof ManufacturerSchema>();
return (
<FormGrid>
<f.Text autoComplete="off" name="name" />
<f.Text autoComplete="off" name="code" />
<f.CodeList name="currency" />
<f.Decimal name="minimumOrderValue" />
<f.Text className="md:col-span-2" multiline name="notes" rows={3} />
</FormGrid>
);
};

The import is import type — a domain package is server code, and only its types may cross into the browser. Note what is not there: no labels. Each input reads its label from the field definition.

apps/web/src/resources/manufacturers/manufacturer-list.tsx
import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";
import { DataTable } from "@onerp/react/lists";
export const ManufacturerList = () => (
<DataTable<typeof ManufacturerSchema>
columns={["name", "code", "currency", "minimumOrderValue"]}
/>
);

A bare string is a field path, checked against the schema like the form’s name props; header text, alignment and click-to-sort resolve from the field definition. You declare the columns; the framework does the rest.

apps/web/src/resources/manufacturers/manufacturer.resource.ts
import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";
import { defineResource } from "@onerp/react/resource";
import { ManufacturerForm } from "./manufacturer-form";
import { ManufacturerList } from "./manufacturer-list";
export const manufacturerResource = defineResource<typeof ManufacturerSchema>({
ref: "master/Manufacturer",
form: ManufacturerForm,
list: ManufacturerList,
});

ref is the binding between the UI and the schema. Passing the schema as the type parameter checks that string against the schema’s own name, so a drifted ref fails to compile. Add manufacturerResource to the resources array handed to <OnerpApp> in apps/web/src/main.tsx, and three routes exist: /master/Manufacturer is the list, /master/Manufacturer/create the empty form, /master/Manufacturer/:id the loaded one.

The subpath import in steps 4–6 resolves only because @onerp/master is a dependency of apps/web — in devDependencies, since nothing but types crosses.

Registering a resource creates routes, not menu items. The sidebar is the application’s own chrome, written by hand in apps/web/src/shell/app-sidebar.tsx:

const masterDataItemDefs: NavItemDef[] = [
// …
{ to: "/master/Manufacturer", icon: Factory, resource: "master/Manufacturer" },
];

Naming the resource rather than a label is what makes the item read Manufacturers in every locale — the business object’s label, derived from its name here or declared as label: { singular, plural }, lives on the schema with its translations.

Start the stack with pnpm dev and open http://localhost:5173/master/Manufacturer. The list renders your four columns, sorted and paginated, with a Create button, a search box (because two fields declared searchable), a filter bar, and a Show archived toggle. Creating a manufacturer opens the form and refuses to save until Name is filled. For a fully-featured resource, read apps/web/src/resources/products/ alongside domains/master/src/product/schema.ts.

A brand-new domain package adds five steps, all covered by how a domain package is laid out:

  1. domains/<name>/package.json — copy a neighbour’s; the exports map needs . and the ./* wildcard the subpath imports use.
  2. src/plugin.ts — the NestJS module, @Plugin({ name, businessObjects, extensions, providers, migrations, seed }), re-exported from src/index.ts.
  3. apps/api/src/app.module.ts — add the plugin class to imports, or it does not boot.
  4. apps/web/package.json — add the package to devDependencies so the import type resolves. Once the plugin is in app.module.ts, apps/api/src/meta-completeness.test.ts covers it: a def missing a German translation or a description fails the test run (see Translations).

The rest of the runtime surface — Forms, Lists, Custom screens — and the framework underneath it, in Foundations.