Skip to content

Commit eda40c0

Browse files
feat(spec): add fieldGroups MVP protocol to ObjectSchema
Adds simplified metadata-layer field grouping: - New ObjectFieldGroupSchema (key/label/icon/description/defaultExpanded/visibleOn) - Optional fieldGroups: ObjectFieldGroup[] on ObjectSchema; array order = display order - Group keys validated unique + snake_case; field→group via existing Field.group - 12 new tests; CHANGELOG updated under [Unreleased] Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/9a38bcfa-8efb-46a2-a910-88a4f33364d8 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
1 parent ed98148 commit eda40c0

3 files changed

Lines changed: 206 additions & 1 deletion

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
- **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:
12+
- 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**.
13+
- `ObjectSchema` gains an optional `fieldGroups: ObjectFieldGroup[]`. Group keys are validated to be unique within an object.
14+
- 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.
15+
- 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.
16+
- New `ObjectFieldGroup` / `ObjectFieldGroupInput` type exports alongside the schema.
17+
- 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.
18+
1019
### Added
1120
- **Environment-per-database multi-tenancy (`service-tenant` v4.1)** — Refactored the multi-tenant architecture from "per-organization database" to **per-environment database** high-isolation, with a hard split between Control Plane (environment registry / addressing / credentials / RBAC) and Data Plane (one physical database per environment). See [`docs/adr/0002-environment-database-isolation.md`](docs/adr/0002-environment-database-isolation.md) for the full rationale and trade-offs.
1221
- **Zod protocol schemas** (`packages/spec/src/cloud/environment.zod.ts`): `EnvironmentSchema`, `EnvironmentDatabaseSchema`, `DatabaseCredentialSchema`, `EnvironmentMemberSchema`, `EnvironmentTypeSchema`, `EnvironmentStatusSchema`, `EnvironmentRoleSchema`, `DatabaseCredentialStatusSchema`, `ProvisionEnvironmentRequest/ResponseSchema`, `ProvisionOrganizationRequest/ResponseSchema`. `TenantDatabaseSchema` is now marked `@deprecated`.

packages/spec/src/data/object.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { ObjectSchema, ObjectCapabilities, IndexSchema, type ServiceObject } from './object.zod';
2+
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, type ServiceObject } from './object.zod';
33

44
describe('ObjectCapabilities', () => {
55
it('should apply default values correctly', () => {
@@ -847,3 +847,120 @@ describe('ObjectSchema.create() namespace auto-derivation', () => {
847847
expect(obj.tableName).toBe('crm_deal');
848848
});
849849
});
850+
851+
// =================================================================
852+
// Field Groups (MVP) — metadata-layer protocol
853+
// =================================================================
854+
855+
describe('ObjectFieldGroupSchema', () => {
856+
it('should accept a minimal group (key + label)', () => {
857+
const group = { key: 'contact_info', label: 'Contact Information' };
858+
const result = ObjectFieldGroupSchema.parse(group);
859+
expect(result.key).toBe('contact_info');
860+
expect(result.label).toBe('Contact Information');
861+
// defaultExpanded defaults to true
862+
expect(result.defaultExpanded).toBe(true);
863+
expect(result.icon).toBeUndefined();
864+
expect(result.description).toBeUndefined();
865+
expect(result.visibleOn).toBeUndefined();
866+
});
867+
868+
it('should accept a fully-specified group', () => {
869+
const group = {
870+
key: 'billing',
871+
label: 'Billing',
872+
icon: 'credit-card',
873+
description: 'Billing and payment details',
874+
defaultExpanded: false,
875+
visibleOn: '$user.isAdmin',
876+
};
877+
const result = ObjectFieldGroupSchema.parse(group);
878+
expect(result).toEqual(group);
879+
});
880+
881+
it('should reject missing key or label', () => {
882+
expect(() => ObjectFieldGroupSchema.parse({})).toThrow();
883+
expect(() => ObjectFieldGroupSchema.parse({ key: 'billing' })).toThrow();
884+
expect(() => ObjectFieldGroupSchema.parse({ label: 'Billing' })).toThrow();
885+
});
886+
887+
it('should reject non-snake_case keys', () => {
888+
expect(() => ObjectFieldGroupSchema.parse({ key: 'Contact Info', label: 'x' })).toThrow();
889+
expect(() => ObjectFieldGroupSchema.parse({ key: 'contact-info', label: 'x' })).toThrow();
890+
expect(() => ObjectFieldGroupSchema.parse({ key: 'ContactInfo', label: 'x' })).toThrow();
891+
});
892+
});
893+
894+
describe('ObjectSchema.fieldGroups', () => {
895+
it('should accept an object without fieldGroups (fully optional)', () => {
896+
const result = ObjectSchema.safeParse({
897+
name: 'account',
898+
fields: {},
899+
});
900+
expect(result.success).toBe(true);
901+
if (result.success) {
902+
expect(result.data.fieldGroups).toBeUndefined();
903+
}
904+
});
905+
906+
it('should preserve declaration order of fieldGroups (array order = display order)', () => {
907+
const result = ObjectSchema.parse({
908+
name: 'account',
909+
fields: {},
910+
fieldGroups: [
911+
{ key: 'contact_info', label: 'Contact' },
912+
{ key: 'billing', label: 'Billing' },
913+
{ key: 'system', label: 'System' },
914+
],
915+
});
916+
expect(result.fieldGroups?.map(g => g.key)).toEqual([
917+
'contact_info', 'billing', 'system',
918+
]);
919+
});
920+
921+
it('should reject duplicate fieldGroup keys', () => {
922+
const result = ObjectSchema.safeParse({
923+
name: 'account',
924+
fields: {},
925+
fieldGroups: [
926+
{ key: 'billing', label: 'Billing' },
927+
{ key: 'billing', label: 'Billing Details' },
928+
],
929+
});
930+
expect(result.success).toBe(false);
931+
});
932+
933+
it('should allow Field.group to reference a declared group key', () => {
934+
const result = ObjectSchema.safeParse({
935+
name: 'account',
936+
fields: {
937+
email: { type: 'email', group: 'contact_info' },
938+
phone: { type: 'phone', group: 'contact_info' },
939+
vat_id: { type: 'text', group: 'billing' },
940+
created: { type: 'datetime', group: 'system' },
941+
},
942+
fieldGroups: [
943+
{ key: 'contact_info', label: 'Contact Information' },
944+
{ key: 'billing', label: 'Billing' },
945+
{ key: 'system', label: 'System' },
946+
],
947+
});
948+
expect(result.success).toBe(true);
949+
});
950+
951+
it('ObjectSchema.create() should accept fieldGroups and preserve them', () => {
952+
const obj = ObjectSchema.create({
953+
name: 'project_task',
954+
fields: {
955+
title: { type: 'text' },
956+
status: { type: 'text', group: 'workflow' },
957+
},
958+
fieldGroups: [
959+
{ key: 'workflow', label: 'Workflow', icon: 'workflow' },
960+
],
961+
});
962+
expect(obj.fieldGroups).toEqual([
963+
{ key: 'workflow', label: 'Workflow', icon: 'workflow', defaultExpanded: true },
964+
]);
965+
});
966+
});

packages/spec/src/data/object.zod.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,68 @@ export const CDCConfigSchema = z.object({
211211
destination: z.string().describe('Destination endpoint (e.g., "kafka://topic", "webhook://url")'),
212212
});
213213

214+
/**
215+
* Object Field Group Schema — MVP (data-layer protocol)
216+
*
217+
* Declares the set of logical field groups for an object. A group bundles
218+
* related fields together for presentation in forms, detail pages, and
219+
* editors (e.g., "Contact Info", "Billing", "System").
220+
*
221+
* Design rules (MVP):
222+
* - Group **order** is the declaration order of this array — no `order` property.
223+
* - Field → group mapping is derived automatically from `Field.group`
224+
* matching `ObjectFieldGroup.key`; the **in-group display order** equals
225+
* the traversal order of `ObjectSchema.fields`.
226+
* - Fields whose `group` is unset (or references an undeclared key) are
227+
* considered ungrouped and must be rendered by consumers in a default
228+
* bucket after the declared groups, preserving their field declaration order.
229+
* - Extension packages and runtime code use `Field.group` to assign fields
230+
* to an existing group — no per-field order property is introduced at this
231+
* layer.
232+
*
233+
* Migration operations supported by this MVP:
234+
* - add / rename / delete / reorder groups (via the array)
235+
* - assign an existing field to a group (via `Field.group`)
236+
*
237+
* Deferred (not part of MVP):
238+
* - explicit per-field in-group ordering
239+
* - nested groups / sub-groups
240+
* - permission-scoped group visibility beyond `visibleOn`
241+
*
242+
* @example
243+
* ```ts
244+
* fieldGroups: [
245+
* { key: 'contact_info', label: 'Contact Information', icon: 'user' },
246+
* { key: 'billing', label: 'Billing', defaultExpanded: false },
247+
* { key: 'system', label: 'System', visibleOn: '$user.isAdmin' },
248+
* ]
249+
* ```
250+
*/
251+
export const ObjectFieldGroupSchema = z.object({
252+
/** Group key — referenced by `Field.group` to assign a field to this group. Must be snake_case. */
253+
key: z.string().regex(/^[a-z_][a-z0-9_]*$/, {
254+
message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")',
255+
}).describe('Group machine key (snake_case). Referenced by Field.group.'),
256+
257+
/** Human-readable label displayed as the group header. */
258+
label: z.string().describe('Group display label'),
259+
260+
/** Optional Lucide/Material icon name for the group header. */
261+
icon: z.string().optional().describe('Icon name (Lucide/Material) for the group header'),
262+
263+
/** Optional description / help text shown under the group header. */
264+
description: z.string().optional().describe('Optional description shown under the group header'),
265+
266+
/** Whether the group is expanded by default. Defaults to `true`. */
267+
defaultExpanded: z.boolean().optional().default(true).describe('Whether the group is expanded by default'),
268+
269+
/** Optional visibility expression — when false, the entire group is hidden (e.g., "$user.isAdmin", "status == \'closed\'"). */
270+
visibleOn: z.string().optional().describe('Visibility condition expression; when false the group is hidden'),
271+
});
272+
273+
export type ObjectFieldGroup = z.infer<typeof ObjectFieldGroupSchema>;
274+
export type ObjectFieldGroupInput = z.input<typeof ObjectFieldGroupSchema>;
275+
214276
/**
215277
* Base Object Schema Definition
216278
*
@@ -282,6 +344,23 @@ const ObjectSchemaBase = z.object({
282344
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
283345
}), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'),
284346
indexes: z.array(IndexSchema).optional().describe('Database performance indexes'),
347+
348+
/**
349+
* Field Groups (MVP)
350+
*
351+
* Declares logical groups for presenting fields in forms and detail
352+
* pages. The **array order is the group display order**. Each field's
353+
* `Field.group` references an entry's `key` to assign it to a group;
354+
* within a group, fields are displayed in their `ObjectSchema.fields`
355+
* declaration order.
356+
*
357+
* See {@link ObjectFieldGroupSchema} for the full MVP contract and
358+
* deferred features.
359+
*/
360+
fieldGroups: z.array(ObjectFieldGroupSchema).refine(
361+
(groups) => new Set(groups.map(g => g.key)).size === groups.length,
362+
{ message: 'fieldGroups[].key must be unique within an object' },
363+
).optional().describe('Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema.'),
285364

286365
/**
287366
* Advanced Data Management

0 commit comments

Comments
 (0)