Field groups — frontend integration
Field groups — frontend integration
Status: backend shipped on stage (2026-07-26); render-path integration
shipped on stage (2026-07-29). Frontend in progress.
2026-07-29 — group_ineligible marks the fields that can't be sectioned
The section editor had no way to tell which fields it may offer a "move to section" control for, so it either offered it on the record title / assignee / status / tag rows (which the record shell renders itself, outside any section) or hard-coded a list client-side that drifted per object.
Every field read now carries a derived boolean:
{ "slug": "birthdate", "label": "Birthdate", "group_slug": null, "sort_order": 2, "group_ineligible": false }true when any of these holds:
| Signal | Matched on | Covers |
|---|---|---|
is_system: true | the field's own flag | every system-defined field |
name ∈ name, assignee, meta | name, case-insensitive — never label | record title, assignee picker, created/updated block |
type ∈ status, tag | type | the status and tag pickers, whatever they're named on this object |
- Same endpoints as
group_slugbelow, plus the write-echo responses — it is derived on read, so it never goes stale. - Render it, don't enforce with it: grey out or hide the move affordance.
PATCH /object/field-group/:objectSlug/assignstill accepts these slugs; the flag is presentation metadata, not a permission. - Additive only — no existing key changed.
- The key is
name, notlabel: a display-only relabel changes nothing, but editingnamedoes move a field in or out of the set (naming a custom fieldassigneemarks it ineligible). Accepted — none of the three is a real row on a stock object, so there's no stable id to key on, and the worst case is a wrongly greyed-out button.
2026-07-29 — section membership is now on the field payload
Previously the section endpoints told you what sections exist but nothing told
you which fields are in them — there was no group_slug anywhere on the
read side, so a sections editor built against one read undefined and the
record form could only render flat.
Every field read now carries group_slug (nullable) alongside the existing
sort_order:
| Endpoint | now returns |
|---|---|
GET /object/field/:objectSlug | group_slug on each field |
GET /object/field/active/:objectSlug | group_slug on each field |
GET /object/field/all/:objectSlug | group_slug on each field |
PUT /object/field/order | group_slug on the echoed list |
{ "slug": "birthdate", "label": "Birthdate", "group_slug": "832014750291", "sort_order": 2 }group_slug: null⇒ the implicit Ungrouped bucket (renders last).sort_orderis the order within the section. For a shared field placed on this Unit it is the placement'sposition, not the home definition's order — the same valuepositions[]on/assignwrites andPUT /object/field/orderround-trips. Drag-within-section is safe to enable against it.- Additive only: no existing key changed, so nothing breaks by not adopting it.
- Membership is authoritative on the list reads above and on
GET /object/field/show/:objectSlug/:fieldSlug. Write-echo responses (add-option, toggle-status) do not resolve it — re-read the list after a section change.
What's new
A new backend capability lets a workspace admin organize an object's fields into labeled, ordered, collapsible sections on the record form/detail view. Grouping is presentation metadata only — no record data changes.
The full API contract is reproduced in Full API contract at the
bottom of this page — source of truth: src/api/v1/object/fields/FIELD_GROUPS_API.md.
New endpoints (base /api/v1, admin JWT, per-object A/G/M/D levels)
| Method | Path | Purpose |
|---|---|---|
| POST | /object/field-group/:objectSlug | create a section |
| GET | /object/field-group/:objectSlug | list sections (ordered) |
| PATCH | /object/field-group/:objectSlug/reorder | reorder sections (full permutation) |
| PATCH | /object/field-group/:objectSlug/assign | move fields into a section (or Ungrouped) |
| PATCH | /object/field-group/:objectSlug/:groupSlug | rename / recolor / set condition |
| DELETE | /object/field-group/:objectSlug/:groupSlug | delete a section (fields fall to Ungrouped) |
What the FE needs to do
- "Organize fields" editor: drag fields between sections; drag section
headers to reorder (commit via
/reorderwith the complete slug set). - Record form/detail: render fields grouped by section
order, bucketing the field list by each field'sgroup_slug, fields within a section bysort_order, Ungrouped last. Honorcollapsed_by_default. - List/table/kanban/export: unchanged — grouping does not affect these.
- Field-list cache: the existing
object_<slug>_fields_*andfield_manager_*reads are invalidated automatically on assign/delete. - XSS: section
name/descriptionare stored raw — HTML-escape on render. - Shared fields: a field placed on two objects can be in a different section on each — show a tooltip so users aren't confused.
Not yet wired (future backend work)
- Conditional-section evaluation at render (the
conditionfield is stored; evaluation is a FE/read-path concern reusing the conditional-requirement engine). - Object-template snapshot/install and schema export/import carrying groups.
Full API contract
Organize an object's fields into labeled, ordered, collapsible sections ("Personal Info", "Client Documents", …) on the record form/detail view (FRD-34).
Grouping is pure presentation metadata — it adds no column to the record table, mutates no field value, and changes no field type. Deleting a section never deletes a field: its fields fall back to the implicit Ungrouped section. List/table/kanban/export layouts are unaffected — grouping is a form/detail concern only.
Base URL: {{base_url}}/api/v1
Auth (all endpoints): Authorization: Bearer <admin_jwt>
Content-Type: application/json
Permission: the per-object A/G/M/D levels gate every route, not the binary
object.* field-manager rights. Listing and creating a section ride the
object's read level (read.{objectSlug}) — any admin who can see the
object can add one; reorder, assign, and update need edit
(update.{objectSlug}) and delete needs delete (delete.{objectSlug}).
Objects, sections, and fields are always referenced by slug, never numeric id.
Section object shape
Returned by every endpoint that returns a section:
{
"slug": "832014750291",
"name": "Personal Info",
"order": 0,
"icon": "tabler-user",
"description": null,
"collapsed_by_default": false,
"condition": null,
"is_system": false,
"created_at": "2026-07-26T12:00:00.000Z",
"updated_at": "2026-07-26T12:00:00.000Z"
}order— render order of the section within the object (0-based).collapsed_by_default— initial collapse state on first open.condition— optional show/hide rule (see Conditional sections);null⇒ always shown.is_system— a seeded section that cannot be deleted (its fields stay movable).
Where a field's section lives
Membership is not on the section object — it is on the field. Every field
returned by GET /object/field/:objectSlug, GET /object/field/active/:objectSlug,
GET /object/field/all/:objectSlug, and PUT /object/field/order carries:
| Key | Meaning |
|---|---|
group_slug | the section this field is in on this Unit; null ⇒ Ungrouped |
sort_order | order within that section (and within Ungrouped) |
Membership is per-Unit: for a shared field, group_slug and sort_order
resolve this Unit's placement, never the home definition.
Endpoints
Create a section
POST /object/field-group/:objectSlug
{ "name": "Personal Info", "icon": "tabler-user", "collapsed_by_default": false }name(required, 1–100 chars, unique per object →409 GROUP_NAME_EXISTS).icon(optional, allow-listed key^[a-z0-9][a-z0-9-_]*$— no URLs/SVG).description(optional, ≤500 chars),collapsed_by_default(optional, defaultfalse).slug,order,is_system,workspace_idare server-set — do not send them.
→ 201 + the section object.
List sections
GET /object/field-group/:objectSlug → 200 + data: Section[] (ordered by order).
Rename / recolor / set condition
PATCH /object/field-group/:objectSlug/:groupSlug
Any subset of { name, icon, description, collapsed_by_default, condition }.
Send "condition": null to clear a condition. → 200 + the section.
Reorder sections (full permutation)
PATCH /object/field-group/:objectSlug/reorder
{ "group_slugs": ["client-documents-c3d4", "personal-info-a1b2"] }Send every section slug of the object, in the desired order. A partial or
padded set is rejected with 422 INCOMPLETE_REORDER. → 200 + reordered sections.
Assign fields to a section
PATCH /object/field-group/:objectSlug/assign
{ "group_slug": "personal-info-a1b2", "field_slugs": ["name", "birthdate"], "positions": [0, 1] }group_slugomitted ornull⇒ move the fields to Ungrouped.field_slugs(1–200, deduped). Each must belong to the object → else422 FIELD_NOT_IN_OBJECT.positions(optional) — order within the section; omit to leave ordering untouched.
→ 200 + { "assigned": <count> }.
Delete a section
DELETE /object/field-group/:objectSlug/:groupSlug
Removes the section; its fields fall back to Ungrouped in the same
transaction (never lost). A system section returns 403 SYSTEM_GROUP_IMMUTABLE.
→ 200.
Conditional sections
A section may carry a condition reusing the per-field conditional-requirement
shape; the whole section (label + fields) is hidden when the condition is false:
{
"condition": {
"enabled": true,
"trigger_field_slug": "type",
"operator": "equals",
"trigger_values": ["VIP"]
}
}Hiding a section never deletes its field values. A required field inside a hidden section is treated as not-required while the section is inapplicable.
Rendering rules
- Render sections by
order; fields within a section by theirsort_order; the implicit Ungrouped bucket (any field withgroup_slug: null) renders last. - A shared field placed on two objects has an independent section per object.
- A client that ignores sections can keep rendering the flat field list: every
field still carries
sort_order, andgroup_slugis additive.
Error codes
| Status | Code | When |
|---|---|---|
| 403 | SYSTEM_GROUP_IMMUTABLE | deleting an is_system section |
| 409 | GROUP_NAME_EXISTS | duplicate section name in the object |
| 422 | INCOMPLETE_REORDER | reorder payload is not the full section set |
| 422 | FIELD_NOT_IN_OBJECT:<slug> | assigning a field that isn't on the object |