Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **`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.

### Added
- **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:
- 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**.
- `ObjectSchema` gains an optional `fieldGroups: ObjectFieldGroup[]`. Group keys are validated to be unique within an object.
- 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.
- 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.
- New `ObjectFieldGroup` / `ObjectFieldGroupInput` type exports alongside the schema.
- 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.
### Fixed
- **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`.

Expand Down
64 changes: 62 additions & 2 deletions content/docs/guides/data-modeling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Complete guide to designing robust data models in ObjectStack following enterpri
3. [Relationships & Lookups](#relationships--lookups)
4. [Validation Rules](#validation-rules)
5. [Formula Fields](#formula-fields)
6. [Database Indexing](#database-indexing)
7. [Best Practices](#best-practices)
6. [Field Groups](#field-groups)
7. [Database Indexing](#database-indexing)
8. [Best Practices](#best-practices)

---

Expand Down Expand Up @@ -482,6 +483,65 @@ Field.formula({

---

## Field Groups

Organize related fields into logical groups for forms, detail pages, and
editors. The group protocol is intentionally minimal (MVP):

- `ObjectSchema.fieldGroups` declares the groups; **array order is the
display order** (no separate `order` property).
- `Field.group` assigns a field to a group by referencing an
`ObjectFieldGroup.key`. In-group display order equals the traversal
order of `fields`.
- Fields whose `group` is unset (or references an undeclared key) render
in a default bucket after the declared groups.

```typescript
import { ObjectSchema } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
name: 'account',
label: 'Account',

fieldGroups: [
{ key: 'contact_info', label: 'Contact Information', icon: 'user' },
{ key: 'billing', label: 'Billing', defaultExpanded: false },
{ key: 'system', label: 'System', visibleOn: '$user.isAdmin' },
],

fields: {
name: { type: 'text', required: true, group: 'contact_info' },
email: { type: 'email', group: 'contact_info' },
phone: { type: 'phone', group: 'contact_info' },
vat_id: { type: 'text', group: 'billing' },
billing_address: { type: 'address', group: 'billing' },
created_at: { type: 'datetime', readonly: true, group: 'system' },
created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
},
});
```

### `ObjectFieldGroup` Properties

| Property | Type | Description |
|:---------|:-----|:------------|
| `key` | `string` (snake_case) | Group machine key; referenced by `Field.group` |
| `label` | `string` | Human-readable group header |
| `icon` | `string?` | Lucide/Material icon name for the group header |
| `description` | `string?` | Optional description under the header |
| `defaultExpanded` | `boolean` (default `true`) | Whether the group is expanded initially |
| `visibleOn` | `string?` | Expression controlling group-level visibility |

### Supported Migrations (MVP)

✅ Add / rename / delete / reorder groups — edit the `fieldGroups` array.
✅ Assign an existing field to a group — set `Field.group`.

⏳ Deferred (future iterations): explicit per-field in-group ordering, nested
groups, permission-scoped group visibility beyond `visibleOn`.

---

## Database Indexing

Optimize query performance with indexes:
Expand Down
86 changes: 56 additions & 30 deletions examples/app-crm/src/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,50 @@ export const Account = ObjectSchema.create({
description: 'Companies and organizations doing business with us',
titleFormat: '{account_number} - {name}',
compactLayout: ['account_number', 'name', 'type', 'owner'],


// Field groups organize the form layout. Array order == display order.
// Each field below opts in via `group: '<key>'`.
fieldGroups: [
{ key: 'basic', label: 'Basic Information', icon: 'building' },
{ key: 'financials', label: 'Financials', icon: 'dollar-sign' },
{ key: 'contact_info', label: 'Contact Information', icon: 'phone' },
{ key: 'ownership', label: 'Ownership & Status', icon: 'users' },
{ key: 'branding', label: 'Branding', icon: 'palette', defaultExpanded: false },
{ key: 'system', label: 'System', icon: 'settings', defaultExpanded: false },
],

fields: {
// AutoNumber field - Unique account identifier
account_number: Field.autonumber({
label: 'Account Number',
format: 'ACC-{0000}',
group: 'basic',
}),

// Basic Information
name: Field.text({
label: 'Account Name',
required: true,
name: Field.text({
label: 'Account Name',
required: true,
searchable: true,
maxLength: 255,
group: 'basic',
}),

// Select fields with custom options
type: Field.select({
label: 'Account Type',
group: 'basic',
options: [
{ label: 'Prospect', value: 'prospect', color: '#FFA500', default: true },
{ label: 'Customer', value: 'customer', color: '#00AA00' },
{ label: 'Partner', value: 'partner', color: '#0000FF' },
{ label: 'Former Customer', value: 'former', color: '#999999' },
]
}),

industry: Field.select({
label: 'Industry',
group: 'basic',
options: [
{ label: 'Technology', value: 'technology' },
{ label: 'Finance', value: 'finance' },
Expand All @@ -48,75 +63,86 @@ export const Account = ObjectSchema.create({
{ label: 'Education', value: 'education' },
]
}),


description: Field.markdown({
label: 'Description',
group: 'basic',
}),

// Number fields
annual_revenue: Field.currency({
annual_revenue: Field.currency({
label: 'Annual Revenue',
scale: 2,
min: 0,
group: 'financials',
}),

number_of_employees: Field.number({
label: 'Employees',
min: 0,
group: 'financials',
}),

// Contact Information
phone: Field.text({
phone: Field.text({
label: 'Phone',
format: 'phone',
group: 'contact_info',
}),

website: Field.url({
label: 'Website',
group: 'contact_info',
}),

// Structured Address field (new field type)
billing_address: Field.address({
label: 'Billing Address',
addressFormat: 'international',
group: 'contact_info',
}),

// Office Location (new field type)
office_location: Field.location({
label: 'Office Location',
displayMap: true,
allowGeocoding: true,
group: 'contact_info',
}),

// Relationship fields
owner: Field.lookup('user', {
label: 'Account Owner',
required: true,
group: 'ownership',
}),

parent_account: Field.lookup('account', {
label: 'Parent Account',
description: 'Parent company in hierarchy',
group: 'ownership',
}),

// Rich text field
description: Field.markdown({
label: 'Description',
}),


// Boolean field
is_active: Field.boolean({
label: 'Active',
defaultValue: true,
group: 'ownership',
}),

// Date field
last_activity_date: Field.date({
label: 'Last Activity Date',
readonly: true,
}),


// Brand color (new field type)
brand_color: Field.color({
label: 'Brand Color',
colorFormat: 'hex',
presetColors: ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'],
group: 'branding',
}),

// Date field
last_activity_date: Field.date({
label: 'Last Activity Date',
readonly: true,
group: 'system',
}),
},

Expand Down
Loading
Loading