Skip to main content

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 Todo in src/modules/example/data/entities.ts (table: todos). – Custom fields for example:todo are defined in src/modules/example/ce.ts under entities[].fields:
    • priority (integer, 1–5)
    • severity (select: low/medium/high)
    • blocked (boolean)
  • Backend page at /backend/example/todos using the query engine to project CFs.
  • CLI seeding command mercato example seed-todos to insert 10 demo rows and CF values.
  • Quick link added to the home page.

Run it

  1. Generate modules and DI

    • npm run modules:prepare
  2. Create migrations and apply

    • npm run db:generate
    • npm run db:migrate
  3. Sync custom entity definitions for your tenant

    • npm run mercato -- entities install -- --tenant <tenantId>
  4. Seed todos and their custom field values (scoped to org and tenant)

    • npm run mercato -- example seed-todos -- --org <orgId> --tenant <tenantId>
  5. Open the page

    • Go to /backend/example/todos (also linked on the home page under Quick Links).

Notes

  • The page uses the query engine with organizationId set from auth and fields imported from @open-mercato/example/datamodel/entities/todo like [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 the SortDir enum: sort: [{ field: id, dir: SortDir.Asc }].
  • Soft delete: if the todos table has deleted_at, results exclude soft-deleted rows by default. Pass withDeleted: true to include them.
  • The example entity includes both organization_id and tenant_id for proper multi-tenant scoping. All queries must include both organizationId and tenantId in query options.
    • In this example, the todos table includes tenant_id and organization_id, and the page filters by organizationId from the authenticated user.

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 description property.

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 field labels.
  • cf_description (richtext): Stored as description (multiline) CF, HTML string.
  • cf_assignee (relation-like): Stored as assignee text 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.