skills/nocobase/skills/nocobase-dsl-reconciler

nocobase-dsl-reconciler

Installation
SKILL.md

NocoBase Application Builder (DSL path)

Before you use this skill

This is the opt-in DSL path. Default is nocobase-ui-builder. Stay on this skill only when the user explicitly wants YAML files they can commit + cli push. If you arrived here from a generic "build me a NocoBase app" request without the user naming DSL/YAML/git, switch to nocobase-ui-builder instead — it's the default entry point.

Golden rule

templates/crm/ is a read-only reference library. When you're unsure how a specific field, block, or popup is shaped, open the closest CRM example, read it, then write your own adapted version in your workspace. Per-scenario pointers live in the workflow sections below.

Never copy CRM files wholesale into your workspace. Do not cp -r templates/crm/... to get started, do not duplicate collections/nb_crm_*.yaml, do not base your routes.yaml on CRM's. Bulk-copying drags unrelated leads/opportunities/orders state and workflows into your project — you then spend the whole session fighting hundreds of irrelevant validator errors instead of building your module.

The pre-deploy spec validator catches most structural mistakes with a clear error message. Trust the validator: when it errors, fix what it says rather than guessing — don't grep through src/deploy/*.ts.

When NOT to use this skill

Hand off to the matching skill when the user's request is orthogonal:

User asks for… Skill
One-off live-UI tweak on an already-running page (move / reorder / reconfigure a single block, field, action) — no DSL commit wanted nocobase-ui-builder
ACL / role permissions / route permissions nocobase-acl-manage
Workflow create / update / revision / execution nocobase-workflow-manage (this skill only wires the trigger button; authoring the graph goes there)
Collection / field / relation authoring outside a DSL project nocobase-data-modeling
Plugin development (.tsx components, server code) nocobase-plugin-development
Install / enable plugin nocobase-plugin-manage
Environment setup / app install / upgrade nocobase-env-manage

Any change that should live as a committed YAML file under workspaces/<project>/ — stays here.

Environment

cd <skill-dir>/src
export NB_USER=admin@nocobase.com NB_PASSWORD=admin123 NB_URL=http://localhost:14000

Quick start — new build

Default path for "build me a NocoBase app": copy the starter and modify it. Do not hand-write the skeleton; do not study CRM first.

cd <skill-dir>/src
cp -r ../templates/starter ../workspaces/<name>

# First push: --copy bypasses validation rules that only matter once
# popups exist (m2o popup binding, clickToOpen file presence). The
# starter ships with its own popups so the first push is actually fully
# valid — --copy is for your *extensions* before popups are wired.
npx tsx cli/cli.ts push <name> --force

The starter is a complete minimal CRUD — 1 collection (Projects), 1 Dashboard page (4 KPI tiles + 2 charts), 1 list page with filterForm / table / addNew popup / detail popup / 2 updateRecord recordActions. Push as-is → visible in NB → then edit.

Customizing the starter (the agile loop)

Iterate one concern at a time, push between each:

  1. Rename identifiers to match the user's domain.
    • collections/nb_starter_projects.yaml → your collection name (match the nb_<module>_<entity> convention)
    • routes.yaml: change "Starter" / "Projects" titles
    • pages/starter/ directory name if you want (match routes.yaml key)
    • Find-replace nb_starter_projects across all files
  2. Adjust the field list in your collection YAML to match the user's entity. Update pages/<...>/layout.yaml fields: and the popup templates' field_layout: accordingly.
  3. Add a 2nd entity only when the first one works end-to-end. Create a new collections/*.yaml + pages/<module>/<entity>/ dir, copy pages/starter/projects/layout.yaml as a starting point.
  4. Extend incrementally: add a tab, a chart, a workflow trigger. Push after every change. See "Advanced patterns" below for which CRM file matches which pattern.

Never write the whole module in one shot. For customer-facing builds — land the skeleton, show the user, gather feedback, iterate. The starter push takes minutes; a hand-built module takes hours.

Fast-track: when --copy helps

Pass --copy when the workspace has no popup files yet (early stage — validator would fire errors about "m2o field X has no popup binding" that the user will fix in the next push). The reconciler auto-bypasses spec errors in this state. Once any popups/*.yaml exists, drop --copy and let validation run.

Incremental edits — existing workspace

  • Add a block / field / action / popup → write the DSL → push.
  • Remove from DSL → push. The reconciler destroys the matching live model on the NB side and cleans state.yaml. Manual NB-UI authored elements (not tracked in state.yaml) are left alone.
  • Rename → not supported automatically. Delete + re-add.

Targeted pushes:

  • --group <key> scopes to one menu subtree
  • --page <key> scopes to one page
  • --incremental skips pages whose DSL hasn't changed since last push

For pure live-UI tweaks without a DSL commit, hand off to nocobase-ui-builder instead (see the routing table above).

Advanced workflow — when the starter isn't enough

Triggers for going beyond the agile starter loop:

  • More than ~3 collections with cross-relations (m2m, tree structures)
  • Dedicated workflow / approval / permission logic that the user wants designed up-front
  • Multi-tab pages, sub-tables, or cross-module navigation
  • Dashboard with bespoke KPIs mapped to the user's domain language

Progression: Round 0 design → Round 1 scaffold → Round 2 fill → Round 2' seed → Round 3 JS. Each round is a deployable state.

Round 0: System architecture — confirm with user

Write a DESIGN.md (markdown, not YAML) covering:

  1. Collections — every table, its fields, and its relations. See nocobase-data-modeling skill for field-interface reference.
  2. Page list — every page, one-line purpose each, grouped by menu.
  3. Navigation wiring — which m2o fields open which popup templates; which pages link to each other.

Wait for user confirmation before writing YAML. A single design pass saves 3× redesigns.

Skip Round 0 if the user's ask fits the starter shape (single entity, basic CRUD).

Example:

## Collections
- nb_lib_books (title, author, isbn, category, status, loans: o2m → nb_lib_loans)
- nb_lib_members (name, email, phone, join_date, loans: o2m → nb_lib_loans)
- nb_lib_loans (loan_no, book: m2o, member: m2o, borrowed_at, due_date, returned_at, status)

## Pages (under menu "Library")
- Books list — browse + search books, add new
- Members list — browse members, their loan history
- Loans list — active/overdue loans, return action
- Dashboard — KPIs + charts

## Navigation
- books.table.title → books detail popup (shared template)
- loans.table.book → books detail popup (shared via defaults.yaml)
- loans.table.member → members detail popup (shared via defaults.yaml)

Round 0.5: Sub-agent CWD (only when spawning)

If launching a sub-agent (kimi TUI, Claude Code subprocess), its CWD becomes the default write target. Set it before launch:

mkdir -p <user-workdir>
cd <user-workdir>
kimi --yolo       # or claude, codex

Skip the cd and the agent writes to the parent project root.

Round 1: Scaffold — still start from the starter

Even in the advanced path, don't hand-write routes.yaml + collections/*.yaml from scratch. Copy the starter, then grow:

Step What to do
Base cp -r templates/starter workspaces/<name> — push once
Add collection Write collections/<next_coll>.yaml (format matches starter's). Match nb_<module>_<entity> convention.
Add page mkdir pages/<module>/<page>/ + layout.yaml; mirror starter's projects layout.
Update routes Add entry under the existing group in routes.yaml.
Deploy cli push <name> --force after each collection/page addition

CRM references (consult only when stuck on structure):

  • templates/crm/collections/nb_crm_leads.yaml — collection format
  • templates/crm/routes.yaml — multi-group routes (shape only)

Round 2: Fill content — blocks, popups, templates

For each page beyond the starter basics:

Building CRM reference
Main list table + filter templates/crm/pages/main/leads/layout.yaml
Multi-tab page templates/crm/pages/main/customers/ (page.yaml + tab_*/layout.yaml)
Create-form with inline sub-table for o2m children templates/crm/templates/block/form_add_new_opportunities_quotations_quotations.yamlitems is an o2m field listed in fields: and rendered as an inline editable sub-table. Also templates/crm/pages/main/products/ for master/child UX.
Detail-popup template templates/crm/templates/popup/activity_view.yaml
m2o auto-popup bindings templates/crm/defaults.yaml
Parent-detail + child-list popup templates/crm/pages/main/customers/tab_customers/popups/
addNew + field click-popup pattern templates/crm/pages/main/leads/popups/

Rules for copying from CRM:

  • Copy 10–30 lines, adapt names. Never copy whole files.
  • Don't copy uid: / targetUid: / route_id: — deployer assigns fresh.
  • For every m2o field displayed in a table, either set clickToOpen: templates/popup/popup_detail_<target>.yaml OR add popups.<target>: ... in defaults.yaml. Validator errors otherwise.

Per-row actions (recordActions)

By default a table's row-action column is empty — NB won't render any action buttons unless recordActions: lists them. "Just edit and delete" is a common but weak default: for most list tables the user actually wants a one-click state change (Mark Done, Approve, Archive) sitting next to edit.

Decision order:

  1. Is there a boolean / enum status field? → add updateRecord with linkageRules to show the matching button only when the record is in the right state. One button per state transition (Mark Done hidden when already done; Reopen hidden when not done).
  2. Does the record need a second detail/form view different from the default edit popup?popup with templateRef.
  3. Tree/hierarchy collection? → add addChild.
  4. Need to navigate elsewhere with this record's id/filter?link with url: /admin/...?filter={{ctx.record.id}}.
  5. Want to let the user clone a complex record?duplicate.
  6. Need to start a workflow manually?workflowTrigger.

Prefer updateRecord + linkageRules over custom JS buttons for state changes. linkageRules covers 80% of row-level UX (conditional show/hide, field-based gating, role checks via ctx.user) without touching JS. JS actions are only needed for multi-step logic (query, then update, then navigate) and are not currently deployable by this reconciler.

Full per-row action palette (declared in recordActions:):

DSL type Purpose CRM reference
edit Edit popup (default shape) many
view Read-only detail popup templates/crm/templates/popup/opportunity_view.yaml
delete Single-row delete with confirm many
updateRecord Assign fields + optional linkageRules — the state-change workhorse templates/crm/pages/main/overview/layout.yaml (Done / Undone pair)
popup Open a custom popup with templateRef (form/detail different from edit) templates/crm/pages/main/leads/popups/table.name.yaml
link Navigate to another admin page, carrying record context in URL templates/crm/pages/lookup/layout.yaml
addChild Tree collection: add a child node under this row templates/crm/pages/main/products/tab_categories/layout.yaml
duplicate Clone the record into a new form
workflowTrigger Manually trigger a workflow on this record (toolbar in templates/crm/pages/main/customers/tab_customers/layout.yaml — same shape works per-row)
historyExpand / historyCollapse Record-history plugin inline expand
ai AI employee button (tasks_file + employee) templates/crm/pages/main/leads/layout.yaml (toolbar — same shape per-row)

Toolbar actions (declared in block-level actions:) use the same type names plus filter, refresh, addNew, bulkDelete, export, import. Not every type makes sense in both contexts — updateRecord as a toolbar action would apply to no specific row, so put it in recordActions.

Goal of Round 2: all pages have working CRUD — add / edit / view popups wired correctly, row-action columns reflect real per-record operations (not just edit+delete). Validator clean, NB UI shows no "Collection may have been deleted" banners.

Round 2': Test data (parallel with Round 2)

Can run concurrently with page-filling. Insert data via API:

TOKEN=$(curl -sS -X POST $NB_URL/api/auth:signIn \
  -H 'Content-Type: application/json' -H 'X-Authenticator: basic' \
  -d '{"account":"'$NB_USER'","password":"'$NB_PASSWORD'"}' \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["token"])')

# Always GET existing record IDs first — they're snowflake integers
# (e.g. 359571523764224), NEVER 1/2/3.
curl -sS -X POST $NB_URL/api/<collection>:create -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{...fields..., "owner":{"id": <real-user-id>}}'

Parent tables first; fill every FK on children (leaving it null orphans the row). Then:

npx tsx cli/cli.ts verify-data <name>     # FK & completeness check

Why parallel: Round 3 JS (charts, KPIs) needs data to render anything. Start the seed once you have collections (end of Round 1); by the time Round 2 finishes pages, you have records to test against.

Round 3: JS — where CRM uses it, you probably need it

Now that CRUD + data work, audit where JavaScript adds value. Walk the CRM template and ask "does the CRM have JS here?" for each spot in your project. Three typical JS opportunities:

Spot CRM has JS? Your project likely needs JS if...
Field renderer / column (e.g. color-coded status tag, days-until-due badge) ✅ in most list tables Any field whose display depends on a derived value (date math, status-to-color, multi-field composite)
Block (KPI card, custom widget inside a form) ✅ overview, analytics, per-form tips A summary widget, inline chart, or "helper panel" that reads from multiple collections
Dashboard page (whole page of charts + KPIs) ✅ analytics page Module has ≥3 measurable metrics users care about; validator requires ≥5 charts on pages titled "Dashboard" / "Analytics"

Start by grepping the matching CRM page and its js/ + charts/ folders to confirm where the CRM adds JS. Then write YOUR JS file adapted from the single-file table below.

Dashboards specifically look bad when designed freehand — mirror the CRM shape. Open the reference layout first, copy its block count, ordering, and grid widths into your own layout.yaml; then fill in leaf files with your content.

Reference layout What to mirror
templates/crm/pages/main/overview/layout.yaml Overview: 1 jsBlock hero row, 2 small tables underneath. Use for a landing page with a few KPIs.
templates/crm/pages/main/analytics/layout.yaml Full dashboard: filterForm row → 4 KPI jsBlocks in one row → 5 charts in a 16/8 ∣ full ∣ 14/10 grid. Use when you want ≥5 charts (validator requires this when the page title contains dashboard or analytics).

Procedure:

  1. Open the reference layout.yaml. Note the block keys / types / widths.
  2. Write YOUR layout.yaml with the SAME shape — same number of blocks, same grid widths in the layout: section — but your own block keys and your own collection names.
  3. For each block's leaf JS/SQL file, copy from the single-file table below. Copy files individually; do not cp -r the folder.
Leaf file to copy Reference
KPI card jsBlock templates/crm/pages/main/overview/js/overview_jsBlock.js
Filtered summary jsBlock templates/crm/pages/main/analytics/js/analytics_jsBlock.js
Chart SQL (grouped counts) templates/crm/pages/main/analytics/charts/analytics_chart_2.sql
Chart render (echarts bar/pie) templates/crm/pages/main/analytics/charts/analytics_chart_2_render.js
Filter stat buttons on filterForm templates/crm/pages/main/customers/tab_customers/js/customers_customers_filterForm_customer_stats_filter_block.js
Full-page custom UI (wizard / multi-step / custom flow) templates/crm/pages/main/customers/tab_merge/js/customers_merge_jsBlock.js — whole page is one type: jsBlock, ~580 lines React

After copying each leaf file:

  • Rename in place and retarget SQL/collection/field names.
  • Remove ns: 'nb_crm' i18n wrappers unless your module has i18n.
  • Simplify ctx.var_form1.* filter var references if your page's filterForm uses different field keys.

SQL charts: save + run as a two-step pattern — ctx.sql.save({uid, sql}) then ctx.sql.runById(uid).

Core concepts

Two identifiers: key and title

key = lower_snake_ascii identity — drives directory names under pages/ and entries in state.yaml. Always write it explicitly when the title isn't pure ASCII (Chinese/spaces slugify to gibberish).

title = display text as the user wants it shown.

- key: it_ops
  title: IT 运维
  type: group
  children:
    - key: tickets
      title: 工单

Two popup modes: key: reference vs bare ref:

key: reference — popup block is a reference to the template. Editing the template updates every popup that references it. Use for any shared Add/Edit form.

blocks:
  - ref: templates/block/form_add_new_tickets.yaml
    key: reference           # REQUIRED for shared refs

Bare ref: (no key: reference) — template content is inlined per popup; each copy is independent. Use only to factor a bulky block out of the page file.

After deploy, a shared template's usageCount should be ≥ 1. If it stays at 0, key: reference was forgotten.

Auto-created columns — do NOT declare them

NocoBase auto-creates these; declaring them causes silent filtering or type conflicts:

  • System columns: id, createdAt, updatedAt, createdBy, updatedBy
  • m2o / o2m FK columns: declaring owner: m2o → users auto-creates owner_id; don't add a second owner_id: integer row
  • m2m join tables: through: nb_x_y is auto-created; don't write a collection YAML for it

Table vs sub-table — two different things, don't confuse

Table Sub-table
What it is Full CRUD block (filter + list + add/edit popups) for child records of a parent Inline editable grid for child rows, lives INSIDE a parent form
DSL type: table + resource_binding.sourceId + associationName { field: tasks, type: subTable, columns: [...] } inside a createForm/editForm's fields:
Where used Detail popup, tab page, standalone-list popup Inside createForm / editForm
Use when Children browsed separately (customer detail → orders list) Children entered alongside parent (invoice + line items)

Bare - tasks in a form is the third option: a RecordSelect picker ("pick existing record"). Rarely what you want — validator warns.

Canonical CRM examples:

  • Table (standalone CRUD block): templates/crm/pages/main/customers/tab_customers/popups/table.name.yaml
  • Sub-table (inline editable grid): templates/crm/templates/block/form_add_new_opportunities_quotations_quotations.yaml (items)

foreignKey flips meaning

On m2o, foreignKey names the FK column on the current table (owner: m2o, foreignKey: owner_idowner_id on SELF).

On o2m, foreignKey names the FK column on the target table (tasks: o2m → nb_pm_tasks, foreignKey: project_idproject_id on nb_pm_tasks).

Command reference

cd <skill-dir>/src
export NB_USER=... NB_PASSWORD=... NB_URL=...

npx tsx cli/cli.ts push <name> --force          # deploy DSL → NocoBase
npx tsx cli/cli.ts push <name> --group <key>    # only one subtree
npx tsx cli/cli.ts push <name> --incremental    # skip unchanged (git diff)

npx tsx cli/cli.ts pull <name>                  # NocoBase → DSL (full round-trip)
npx tsx cli/cli.ts diff <left> <right>          # compare two DSL trees
npx tsx cli/cli.ts duplicate-project <src> <dst> --key-suffix _v2

npx tsx cli/cli.ts verify-data <name>           # FK / completeness check

push and pull are both one-way. Round-tripping = push + pull + git diff.

Common errors

Error Fix
fields not in collection Field names don't match the collection YAML
titleField is missing Set titleField: <field> or add a name/title field
Only some pages deployed key mismatch with a pages/<key>/ directory
string violation on create createdAt/updatedAt declared in YAML — remove
Chart SQL failed Seed data first; quote field names like "createdAt"
m2o link 400 in UI Missing defaults.yaml popups: binding for target collection
Collection X not found in data source main associationName used a short name — use the full collection name (nb_pm_projects.tasks, not project.tasks). See templates/crm/pages/main/customers/tab_customers/popups/
Per-row column shows only edit+delete recordActions missing — see "Per-row actions" below for what to add. Removing an action/field/column from the DSL now does destroy it on the NB side on next push.

If any of the above contradicts what you observe at runtime, the manual is stale — note what was missing and tell the user.

Weekly Installs
143
Repository
nocobase/skills
GitHub Stars
23
First Seen
Today