Skip to content

Commit 2b48e62

Browse files
Merge pull request #1199 from objectstack-ai/copilot/add-field-groups-mvp
feat(spec): add fieldGroups MVP to ObjectSchema
2 parents c2bbde1 + 28bcae8 commit 2b48e62

16 files changed

Lines changed: 675 additions & 137 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **`examples/app-crm` — showcase `fieldGroups` MVP** — The CRM reference example (`Account`, `Contact`, `Opportunity`, `Lead`) now demonstrates the new `fieldGroups` protocol end to end. Each object declares logical groups (e.g., *Basic Information*, *Financials*, *Contact Information*, *Ownership & Status*, *System*) and every field opts in via `group: '<key>'`. No business logic changed — only field-layout metadata — so existing validations, workflows, indexes, and state machines are unaffected. Useful as a reference when designing multi-group forms and detail pages.
12+
13+
### Added
14+
- **Field Groups (`fieldGroups`) — simplified MVP protocol** — Introduced a data-layer protocol for grouping fields on an object in forms, detail pages, and editors. Designed to be AI-generation- and extension-friendly by intentionally minimizing surface area:
15+
- New `ObjectFieldGroupSchema` in `packages/spec/src/data/object.zod.ts` with `key` (snake_case machine key), `label`, optional `icon`, `description`, `defaultExpanded` (default `true`), and `visibleOn` (expression for conditional visibility). No `order` property — **array declaration order is the display order**.
16+
- `ObjectSchema` gains an optional `fieldGroups: ObjectFieldGroup[]`. Group keys are validated to be unique within an object.
17+
- The existing `Field.group: string` property on `FieldSchema` is the sole field→group assignment mechanism. Field → group mapping is derived automatically from metadata registration; in-group display order equals the traversal order of `ObjectSchema.fields`. Extension packages and runtime code use `Field.group` uniformly.
18+
- Supported migrations at this layer: add / rename / delete / reorder groups (by editing the `fieldGroups` array) and assigning an existing field to a group (by editing `Field.group`). Explicit per-field in-group ordering is deferred to a future iteration.
19+
- New `ObjectFieldGroup` / `ObjectFieldGroupInput` type exports alongside the schema.
20+
- Tests: 12 new round-trip cases in `packages/spec/src/data/object.test.ts` covering minimal/full-group parsing, required fields, snake_case key validation, declaration-order preservation, duplicate-key rejection, `Field.group` referencing, and `ObjectSchema.create()` integration.
1021
### Fixed
1122
- **Doubly-prefixed FQN for `@objectstack/objectos` system objects** — The ObjectOS-layer object definitions (`SysObject`, `SysView`, `SysAgent`, `SysTool`, `SysFlow`, `SysMetadata`) were being registered with fully-qualified names like `sys__sys_object`, `sys__sys_view`, `sys__sys_metadata`, because each object hard-coded a `sys_` prefix into its `name` **and** its manifest was registered under `namespace: 'sys'`, causing `SchemaRegistry.computeFQN(namespace, name)` to apply the prefix twice. The object `name` values are now the unprefixed short form (`object`, `view`, `agent`, `tool`, `flow`, `metadata`), producing the correct FQNs (`sys__object`, `sys__view`, `sys__agent`, `sys__tool`, `sys__flow`). `SysMetadata` (which would collide with the canonical `sys__metadata` owned by `@objectstack/metadata`) is now exported separately and excluded from the auto-registered `SystemObjects` catalog to avoid ownership conflicts; consumers that need it can still import it directly. See `packages/objectos/src/objects/*.ts` and `packages/objectos/src/registry.ts`.
1223

content/docs/guides/data-modeling.mdx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ Complete guide to designing robust data models in ObjectStack following enterpri
1414
3. [Relationships & Lookups](#relationships--lookups)
1515
4. [Validation Rules](#validation-rules)
1616
5. [Formula Fields](#formula-fields)
17-
6. [Database Indexing](#database-indexing)
18-
7. [Best Practices](#best-practices)
17+
6. [Field Groups](#field-groups)
18+
7. [Database Indexing](#database-indexing)
19+
8. [Best Practices](#best-practices)
1920

2021
---
2122

@@ -482,6 +483,65 @@ Field.formula({
482483

483484
---
484485

486+
## Field Groups
487+
488+
Organize related fields into logical groups for forms, detail pages, and
489+
editors. The group protocol is intentionally minimal (MVP):
490+
491+
- `ObjectSchema.fieldGroups` declares the groups; **array order is the
492+
display order** (no separate `order` property).
493+
- `Field.group` assigns a field to a group by referencing an
494+
`ObjectFieldGroup.key`. In-group display order equals the traversal
495+
order of `fields`.
496+
- Fields whose `group` is unset (or references an undeclared key) render
497+
in a default bucket after the declared groups.
498+
499+
```typescript
500+
import { ObjectSchema } from '@objectstack/spec/data';
501+
502+
export const Account = ObjectSchema.create({
503+
name: 'account',
504+
label: 'Account',
505+
506+
fieldGroups: [
507+
{ key: 'contact_info', label: 'Contact Information', icon: 'user' },
508+
{ key: 'billing', label: 'Billing', defaultExpanded: false },
509+
{ key: 'system', label: 'System', visibleOn: '$user.isAdmin' },
510+
],
511+
512+
fields: {
513+
name: { type: 'text', required: true, group: 'contact_info' },
514+
email: { type: 'email', group: 'contact_info' },
515+
phone: { type: 'phone', group: 'contact_info' },
516+
vat_id: { type: 'text', group: 'billing' },
517+
billing_address: { type: 'address', group: 'billing' },
518+
created_at: { type: 'datetime', readonly: true, group: 'system' },
519+
created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
520+
},
521+
});
522+
```
523+
524+
### `ObjectFieldGroup` Properties
525+
526+
| Property | Type | Description |
527+
|:---------|:-----|:------------|
528+
| `key` | `string` (snake_case) | Group machine key; referenced by `Field.group` |
529+
| `label` | `string` | Human-readable group header |
530+
| `icon` | `string?` | Lucide/Material icon name for the group header |
531+
| `description` | `string?` | Optional description under the header |
532+
| `defaultExpanded` | `boolean` (default `true`) | Whether the group is expanded initially |
533+
| `visibleOn` | `string?` | Expression controlling group-level visibility |
534+
535+
### Supported Migrations (MVP)
536+
537+
✅ Add / rename / delete / reorder groups — edit the `fieldGroups` array.
538+
✅ Assign an existing field to a group — set `Field.group`.
539+
540+
⏳ Deferred (future iterations): explicit per-field in-group ordering, nested
541+
groups, permission-scoped group visibility beyond `visibleOn`.
542+
543+
---
544+
485545
## Database Indexing
486546

487547
Optimize query performance with indexes:

examples/app-crm/src/objects/account.object.ts

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,50 @@ export const Account = ObjectSchema.create({
1010
description: 'Companies and organizations doing business with us',
1111
titleFormat: '{account_number} - {name}',
1212
compactLayout: ['account_number', 'name', 'type', 'owner'],
13-
13+
14+
// Field groups organize the form layout. Array order == display order.
15+
// Each field below opts in via `group: '<key>'`.
16+
fieldGroups: [
17+
{ key: 'basic', label: 'Basic Information', icon: 'building' },
18+
{ key: 'financials', label: 'Financials', icon: 'dollar-sign' },
19+
{ key: 'contact_info', label: 'Contact Information', icon: 'phone' },
20+
{ key: 'ownership', label: 'Ownership & Status', icon: 'users' },
21+
{ key: 'branding', label: 'Branding', icon: 'palette', defaultExpanded: false },
22+
{ key: 'system', label: 'System', icon: 'settings', defaultExpanded: false },
23+
],
24+
1425
fields: {
1526
// AutoNumber field - Unique account identifier
1627
account_number: Field.autonumber({
1728
label: 'Account Number',
1829
format: 'ACC-{0000}',
30+
group: 'basic',
1931
}),
20-
32+
2133
// Basic Information
22-
name: Field.text({
23-
label: 'Account Name',
24-
required: true,
34+
name: Field.text({
35+
label: 'Account Name',
36+
required: true,
2537
searchable: true,
2638
maxLength: 255,
39+
group: 'basic',
2740
}),
28-
41+
2942
// Select fields with custom options
3043
type: Field.select({
3144
label: 'Account Type',
45+
group: 'basic',
3246
options: [
3347
{ label: 'Prospect', value: 'prospect', color: '#FFA500', default: true },
3448
{ label: 'Customer', value: 'customer', color: '#00AA00' },
3549
{ label: 'Partner', value: 'partner', color: '#0000FF' },
3650
{ label: 'Former Customer', value: 'former', color: '#999999' },
3751
]
3852
}),
39-
53+
4054
industry: Field.select({
4155
label: 'Industry',
56+
group: 'basic',
4257
options: [
4358
{ label: 'Technology', value: 'technology' },
4459
{ label: 'Finance', value: 'finance' },
@@ -48,75 +63,86 @@ export const Account = ObjectSchema.create({
4863
{ label: 'Education', value: 'education' },
4964
]
5065
}),
51-
66+
67+
description: Field.markdown({
68+
label: 'Description',
69+
group: 'basic',
70+
}),
71+
5272
// Number fields
53-
annual_revenue: Field.currency({
73+
annual_revenue: Field.currency({
5474
label: 'Annual Revenue',
5575
scale: 2,
5676
min: 0,
77+
group: 'financials',
5778
}),
58-
79+
5980
number_of_employees: Field.number({
6081
label: 'Employees',
6182
min: 0,
83+
group: 'financials',
6284
}),
63-
85+
6486
// Contact Information
65-
phone: Field.text({
87+
phone: Field.text({
6688
label: 'Phone',
6789
format: 'phone',
90+
group: 'contact_info',
6891
}),
69-
92+
7093
website: Field.url({
7194
label: 'Website',
95+
group: 'contact_info',
7296
}),
73-
97+
7498
// Structured Address field (new field type)
7599
billing_address: Field.address({
76100
label: 'Billing Address',
77101
addressFormat: 'international',
102+
group: 'contact_info',
78103
}),
79-
104+
80105
// Office Location (new field type)
81106
office_location: Field.location({
82107
label: 'Office Location',
83108
displayMap: true,
84109
allowGeocoding: true,
110+
group: 'contact_info',
85111
}),
86-
112+
87113
// Relationship fields
88114
owner: Field.lookup('user', {
89115
label: 'Account Owner',
90116
required: true,
117+
group: 'ownership',
91118
}),
92-
119+
93120
parent_account: Field.lookup('account', {
94121
label: 'Parent Account',
95122
description: 'Parent company in hierarchy',
123+
group: 'ownership',
96124
}),
97-
98-
// Rich text field
99-
description: Field.markdown({
100-
label: 'Description',
101-
}),
102-
125+
103126
// Boolean field
104127
is_active: Field.boolean({
105128
label: 'Active',
106129
defaultValue: true,
130+
group: 'ownership',
107131
}),
108-
109-
// Date field
110-
last_activity_date: Field.date({
111-
label: 'Last Activity Date',
112-
readonly: true,
113-
}),
114-
132+
115133
// Brand color (new field type)
116134
brand_color: Field.color({
117135
label: 'Brand Color',
118136
colorFormat: 'hex',
119137
presetColors: ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'],
138+
group: 'branding',
139+
}),
140+
141+
// Date field
142+
last_activity_date: Field.date({
143+
label: 'Last Activity Date',
144+
readonly: true,
145+
group: 'system',
120146
}),
121147
},
122148

0 commit comments

Comments
 (0)