Corteksa

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:

SignalMatched onCovers
is_system: truethe field's own flagevery system-defined field
name ∈ name, assignee, metaname, case-insensitive — never labelrecord title, assignee picker, created/updated block
type ∈ status, tagtypethe status and tag pickers, whatever they're named on this object
  • Same endpoints as group_slug below, 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/assign still accepts these slugs; the flag is presentation metadata, not a permission.
  • Additive only — no existing key changed.
  • The key is name, not label: a display-only relabel changes nothing, but editing name does move a field in or out of the set (naming a custom field assignee marks 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:

Endpointnow returns
GET /object/field/:objectSluggroup_slug on each field
GET /object/field/active/:objectSluggroup_slug on each field
GET /object/field/all/:objectSluggroup_slug on each field
PUT /object/field/ordergroup_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_order is the order within the section. For a shared field placed on this Unit it is the placement's position, not the home definition's order — the same value positions[] on /assign writes and PUT /object/field/order round-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)

MethodPathPurpose
POST/object/field-group/:objectSlugcreate a section
GET/object/field-group/:objectSluglist sections (ordered)
PATCH/object/field-group/:objectSlug/reorderreorder sections (full permutation)
PATCH/object/field-group/:objectSlug/assignmove fields into a section (or Ungrouped)
PATCH/object/field-group/:objectSlug/:groupSlugrename / recolor / set condition
DELETE/object/field-group/:objectSlug/:groupSlugdelete 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 /reorder with the complete slug set).
  • Record form/detail: render fields grouped by section order, bucketing the field list by each field's group_slug, fields within a section by sort_order, Ungrouped last. Honor collapsed_by_default.
  • List/table/kanban/export: unchanged — grouping does not affect these.
  • Field-list cache: the existing object_<slug>_fields_* and field_manager_* reads are invalidated automatically on assign/delete.
  • XSS: section name/description are 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 condition field 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:

KeyMeaning
group_slugthe section this field is in on this Unit; nullUngrouped
sort_orderorder 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, default false).
  • slug, order, is_system, workspace_id are server-set — do not send them.

201 + the section object.

List sections

GET /object/field-group/:objectSlug200 + 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_slug omitted or null ⇒ move the fields to Ungrouped.
  • field_slugs (1–200, deduped). Each must belong to the object → else 422 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 their sort_order; the implicit Ungrouped bucket (any field with group_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, and group_slug is additive.

Error codes

StatusCodeWhen
403SYSTEM_GROUP_IMMUTABLEdeleting an is_system section
409GROUP_NAME_EXISTSduplicate section name in the object
422INCOMPLETE_REORDERreorder payload is not the full section set
422FIELD_NOT_IN_OBJECT:<slug>assigning a field that isn't on the object

On this page