Example: Todo List with Custom Fields
This example extends the example module with a new Todo entity, declares custom fields using the entities module, seeds 10 demo rows with values, and exposes a backend page to browse the data via the query engine.
What’s included
- New entity
Todoinsrc/modules/example/data/entities.ts(table:todos). – Custom fields forexample:todoare defined insrc/modules/example/ce.tsunderentities[].fields:priority(integer, 1–5)severity(select: low/medium/high)blocked(boolean)
- Backend page at
/backend/example/todosusing the query engine to project CFs. - CLI seeding command
mercato example seed-todosto insert 10 demo rows and CF values. - Quick link added to the home page.
Run it
-
Generate modules and DI
npm run modules:prepare
-
Create migrations and apply
npm run db:generatenpm run db:migrate
-
Sync custom entity definitions for your tenant
npm run mercato -- entities install -- --tenant <tenantId>
-
Seed todos and their custom field values (scoped to org and tenant)
npm run mercato -- example seed-todos -- --org <orgId> --tenant <tenantId>
-
Open the page
- Go to
/backend/example/todos(also linked on the home page under Quick Links).
- Go to
Notes
- The page uses the query engine with
organizationIdset from auth and fields imported from@open-mercato/example/datamodel/entities/todolike[id, title, tenant_id, organization_id, is_done, 'cf:priority', 'cf:severity', 'cf:blocked']so custom fields are joined and projected. - You can filter or sort on base fields today; custom-field filters are supported as
filters: [{ field: 'cf:priority', op: 'gte', value: 3 }]or, using the Mongo-style object syntax:filters: { 'cf:priority': { $gte: 3 } }. For sorting, prefer theSortDirenum:sort: [{ field: id, dir: SortDir.Asc }]. - Soft delete: if the
todostable hasdeleted_at, results exclude soft-deleted rows by default. PasswithDeleted: trueto include them. - The example entity includes both
organization_idandtenant_idfor proper multi-tenant scoping. All queries must include bothorganizationIdandtenantIdin query options.- In this example, the
todostable includestenant_idandorganization_id, and the page filters byorganizationIdfrom the authenticated user.
- In this example, the
Create form (mobile-friendly)
The create page (/backend/example/todos/create) uses the shared CrudForm component for a quick, validated form with custom fields.
Key behaviors:
-
Mobile screens render a fullscreen form with a header and back link.
-
Inline help text per field uses the
descriptionproperty.
Example usage:
<CrudForm
title="Create Todo"
backHref="/backend/todos"
schema={todoCreateSchema}
fields={fields}
submitLabel="Create Todo"
cancelHref="/backend/todos"
successRedirect="/backend/todos"
onSubmit={async (vals) => { /* ... */ }}
/>
Advanced editors supported out of the box: tags, richtext, relation, plus custom renderers for bespoke components. See Admin forms with CrudForm for the full API.
Fields used in the example create page:
cf_labels(tags): Stored as a multi-select custom fieldlabels.cf_description(richtext): Stored asdescription(multiline) CF, HTML string.cf_assignee(relation-like): Stored asassigneetext CF (just an ID string). The UI uses an async loader to demonstrate dynamic options.
The API accepts these on create and persists them via the custom fields module.
Optional page metadata
You can co-locate admin navigation and access control metadata with the page.
Add next to the page file:
// packages/example/src/modules/example/backend/todos/page.meta.ts
import type { PageMetadata } from '@open-mercato/shared/modules/registry'
export const metadata: PageMetadata = {
requireAuth: true,
pageTitle: 'Todos',
pageGroup: 'Example',
pageOrder: 20,
}
Or, since this page is a server component, export metadata from the page itself:
// packages/example/src/modules/example/backend/todos/page.tsx
import type { PageMetadata } from '@open-mercato/shared/modules/registry'
export const metadata: PageMetadata = {
requireAuth: true,
pageTitle: 'Todos',
pageGroup: 'Example',
}
export default async function Page() { /* ... */ }
If both exist, the separate *.meta.ts file takes precedence.
Filters with custom fields
To keep pages consistent and avoid forgetting to wire dynamic filters, the DataTable can auto-append filter controls for filterable custom fields.
Usage:
<DataTable
columns={columns}
data={rows}
// Built-in filter bar (search + filters)
searchValue={search}
onSearchChange={setSearch}
filters={[{ id: 'created_at', label: 'Created', type: 'dateRange' }]}
filterValues={values}
onFiltersApply={setValues}
onFiltersClear={() => setValues({})}
// Auto-include custom-field filters for this entity
entityId="example:todo"
/>
This appends controls for all custom fields marked filterable in their definitions (boolean → checkbox, select → dropdown; multi-select uses a checkbox list, text-like kinds → text input). Selected values should be mapped to query params as cf_<key> or cf_<key>In for multi.
Note: customFieldFiltersEntityId has been renamed to entityId.
Organization scope refresh
When a user changes the active organization from the global switcher, the app emits a browser event so client components can immediately refetch their data without waiting for a full page reload. Instead of wiring the event manually, reach for the hooks in @/lib/frontend/useOrganizationScope:
import { useOrganizationScopeVersion } from '@/lib/frontend/useOrganizationScope'
import { useQuery } from '@tanstack/react-query'
export function TodosTable() {
const scopeVersion = useOrganizationScopeVersion()
const { data } = useQuery({
queryKey: ['todos', params, scopeVersion],
queryFn: fetchTodos,
})
// ...
}
useOrganizationScopeVersion() increments whenever the organization changes, making it easy to append to query keys, effect dependency arrays, or SWR keys. If you need the selected organization id directly, call useOrganizationScopeDetail() which returns { organizationId }.
DataTable already subscribes internally and calls router.refresh(), so any list rendered with it will refresh as soon as the user picks another organization. Use the hooks when you have custom fetch logic (e.g. React Query, SWR, or manual effects) that should re-run on organization changes.