From ed98148714950b09fc84fea7471b1ecfbf12507f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:58:22 +0000 Subject: [PATCH 1/4] Initial plan From eda40c0a39367a6878d5d17e129290c1c6a5b827 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:06:23 +0000 Subject: [PATCH 2/4] feat(spec): add fieldGroups MVP protocol to ObjectSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CHANGELOG.md | 9 ++ packages/spec/src/data/object.test.ts | 119 +++++++++++++++++++++++++- packages/spec/src/data/object.zod.ts | 79 +++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcc293b1..132400e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### Added - **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. - **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`. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 5db4bf954..73c8a3527 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ObjectSchema, ObjectCapabilities, IndexSchema, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, type ServiceObject } from './object.zod'; describe('ObjectCapabilities', () => { it('should apply default values correctly', () => { @@ -847,3 +847,120 @@ describe('ObjectSchema.create() namespace auto-derivation', () => { expect(obj.tableName).toBe('crm_deal'); }); }); + +// ================================================================= +// Field Groups (MVP) — metadata-layer protocol +// ================================================================= + +describe('ObjectFieldGroupSchema', () => { + it('should accept a minimal group (key + label)', () => { + const group = { key: 'contact_info', label: 'Contact Information' }; + const result = ObjectFieldGroupSchema.parse(group); + expect(result.key).toBe('contact_info'); + expect(result.label).toBe('Contact Information'); + // defaultExpanded defaults to true + expect(result.defaultExpanded).toBe(true); + expect(result.icon).toBeUndefined(); + expect(result.description).toBeUndefined(); + expect(result.visibleOn).toBeUndefined(); + }); + + it('should accept a fully-specified group', () => { + const group = { + key: 'billing', + label: 'Billing', + icon: 'credit-card', + description: 'Billing and payment details', + defaultExpanded: false, + visibleOn: '$user.isAdmin', + }; + const result = ObjectFieldGroupSchema.parse(group); + expect(result).toEqual(group); + }); + + it('should reject missing key or label', () => { + expect(() => ObjectFieldGroupSchema.parse({})).toThrow(); + expect(() => ObjectFieldGroupSchema.parse({ key: 'billing' })).toThrow(); + expect(() => ObjectFieldGroupSchema.parse({ label: 'Billing' })).toThrow(); + }); + + it('should reject non-snake_case keys', () => { + expect(() => ObjectFieldGroupSchema.parse({ key: 'Contact Info', label: 'x' })).toThrow(); + expect(() => ObjectFieldGroupSchema.parse({ key: 'contact-info', label: 'x' })).toThrow(); + expect(() => ObjectFieldGroupSchema.parse({ key: 'ContactInfo', label: 'x' })).toThrow(); + }); +}); + +describe('ObjectSchema.fieldGroups', () => { + it('should accept an object without fieldGroups (fully optional)', () => { + const result = ObjectSchema.safeParse({ + name: 'account', + fields: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.fieldGroups).toBeUndefined(); + } + }); + + it('should preserve declaration order of fieldGroups (array order = display order)', () => { + const result = ObjectSchema.parse({ + name: 'account', + fields: {}, + fieldGroups: [ + { key: 'contact_info', label: 'Contact' }, + { key: 'billing', label: 'Billing' }, + { key: 'system', label: 'System' }, + ], + }); + expect(result.fieldGroups?.map(g => g.key)).toEqual([ + 'contact_info', 'billing', 'system', + ]); + }); + + it('should reject duplicate fieldGroup keys', () => { + const result = ObjectSchema.safeParse({ + name: 'account', + fields: {}, + fieldGroups: [ + { key: 'billing', label: 'Billing' }, + { key: 'billing', label: 'Billing Details' }, + ], + }); + expect(result.success).toBe(false); + }); + + it('should allow Field.group to reference a declared group key', () => { + const result = ObjectSchema.safeParse({ + name: 'account', + fields: { + email: { type: 'email', group: 'contact_info' }, + phone: { type: 'phone', group: 'contact_info' }, + vat_id: { type: 'text', group: 'billing' }, + created: { type: 'datetime', group: 'system' }, + }, + fieldGroups: [ + { key: 'contact_info', label: 'Contact Information' }, + { key: 'billing', label: 'Billing' }, + { key: 'system', label: 'System' }, + ], + }); + expect(result.success).toBe(true); + }); + + it('ObjectSchema.create() should accept fieldGroups and preserve them', () => { + const obj = ObjectSchema.create({ + name: 'project_task', + fields: { + title: { type: 'text' }, + status: { type: 'text', group: 'workflow' }, + }, + fieldGroups: [ + { key: 'workflow', label: 'Workflow', icon: 'workflow' }, + ], + }); + expect(obj.fieldGroups).toEqual([ + { key: 'workflow', label: 'Workflow', icon: 'workflow', defaultExpanded: true }, + ]); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 8f444d881..b662fb02c 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -211,6 +211,68 @@ export const CDCConfigSchema = z.object({ destination: z.string().describe('Destination endpoint (e.g., "kafka://topic", "webhook://url")'), }); +/** + * Object Field Group Schema — MVP (data-layer protocol) + * + * Declares the set of logical field groups for an object. A group bundles + * related fields together for presentation in forms, detail pages, and + * editors (e.g., "Contact Info", "Billing", "System"). + * + * Design rules (MVP): + * - Group **order** is the declaration order of this array — no `order` property. + * - Field → group mapping is derived automatically from `Field.group` + * matching `ObjectFieldGroup.key`; the **in-group display order** equals + * the traversal order of `ObjectSchema.fields`. + * - Fields whose `group` is unset (or references an undeclared key) are + * considered ungrouped and must be rendered by consumers in a default + * bucket after the declared groups, preserving their field declaration order. + * - Extension packages and runtime code use `Field.group` to assign fields + * to an existing group — no per-field order property is introduced at this + * layer. + * + * Migration operations supported by this MVP: + * - add / rename / delete / reorder groups (via the array) + * - assign an existing field to a group (via `Field.group`) + * + * Deferred (not part of MVP): + * - explicit per-field in-group ordering + * - nested groups / sub-groups + * - permission-scoped group visibility beyond `visibleOn` + * + * @example + * ```ts + * fieldGroups: [ + * { key: 'contact_info', label: 'Contact Information', icon: 'user' }, + * { key: 'billing', label: 'Billing', defaultExpanded: false }, + * { key: 'system', label: 'System', visibleOn: '$user.isAdmin' }, + * ] + * ``` + */ +export const ObjectFieldGroupSchema = z.object({ + /** Group key — referenced by `Field.group` to assign a field to this group. Must be snake_case. */ + key: z.string().regex(/^[a-z_][a-z0-9_]*$/, { + message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', + }).describe('Group machine key (snake_case). Referenced by Field.group.'), + + /** Human-readable label displayed as the group header. */ + label: z.string().describe('Group display label'), + + /** Optional Lucide/Material icon name for the group header. */ + icon: z.string().optional().describe('Icon name (Lucide/Material) for the group header'), + + /** Optional description / help text shown under the group header. */ + description: z.string().optional().describe('Optional description shown under the group header'), + + /** Whether the group is expanded by default. Defaults to `true`. */ + defaultExpanded: z.boolean().optional().default(true).describe('Whether the group is expanded by default'), + + /** Optional visibility expression — when false, the entire group is hidden (e.g., "$user.isAdmin", "status == \'closed\'"). */ + visibleOn: z.string().optional().describe('Visibility condition expression; when false the group is hidden'), +}); + +export type ObjectFieldGroup = z.infer; +export type ObjectFieldGroupInput = z.input; + /** * Base Object Schema Definition * @@ -282,6 +344,23 @@ const ObjectSchemaBase = z.object({ message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', }), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), + + /** + * Field Groups (MVP) + * + * Declares logical groups for presenting fields in forms and detail + * pages. The **array order is the group display order**. Each field's + * `Field.group` references an entry's `key` to assign it to a group; + * within a group, fields are displayed in their `ObjectSchema.fields` + * declaration order. + * + * See {@link ObjectFieldGroupSchema} for the full MVP contract and + * deferred features. + */ + fieldGroups: z.array(ObjectFieldGroupSchema).refine( + (groups) => new Set(groups.map(g => g.key)).size === groups.length, + { message: 'fieldGroups[].key must be unique within an object' }, + ).optional().describe('Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema.'), /** * Advanced Data Management From 55743eb8cbd6978e4e0caa274319e97004f6709a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:03:15 +0000 Subject: [PATCH 3/4] docs(skills): document fieldGroups MVP in schema skill and data-modeling guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Field Groups (MVP)" section to skills/objectstack-schema/SKILL.md with a full worked example, ObjectFieldGroup property table, and migration scope - Link fieldGroups in the "Important optional properties" table and references list - Add "Field Groups" section to content/docs/guides/data-modeling.mdx (with TOC entry) - Regenerate skill references via `pnpm --filter @objectstack/spec gen:skill-refs` so every skill's object.zod.ts copy now includes ObjectFieldGroupSchema (sync also picks up unrelated drift in manifest.zod.ts / dashboard.zod.ts / filter+query → objectstack-query move, per the script's source-of-truth role) Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/2dfd93fb-72b4-4e49-bbba-c239c6b5b81c Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- content/docs/guides/data-modeling.mdx | 64 ++++++++++++++- skills/objectstack-query/references/_index.md | 6 +- .../references/data/filter.zod.ts | 0 .../references/data/query.zod.ts | 0 .../references/kernel/manifest.zod.ts | 41 ++++++++-- skills/objectstack-schema/SKILL.md | 45 ++++++++++- .../objectstack-schema/references/_index.md | 2 - .../references/data/object.zod.ts | 79 +++++++++++++++++++ .../references/ui/dashboard.zod.ts | 6 ++ 9 files changed, 226 insertions(+), 17 deletions(-) rename skills/{objectstack-schema => objectstack-query}/references/data/filter.zod.ts (100%) rename skills/{objectstack-schema => objectstack-query}/references/data/query.zod.ts (100%) diff --git a/content/docs/guides/data-modeling.mdx b/content/docs/guides/data-modeling.mdx index f23496e30..e271749a1 100644 --- a/content/docs/guides/data-modeling.mdx +++ b/content/docs/guides/data-modeling.mdx @@ -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) --- @@ -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: diff --git a/skills/objectstack-query/references/_index.md b/skills/objectstack-query/references/_index.md index c62953024..28f8105aa 100644 --- a/skills/objectstack-query/references/_index.md +++ b/skills/objectstack-query/references/_index.md @@ -6,7 +6,5 @@ ## Core Schemas -- [`data/query.zod.ts`](./data/query.zod.ts) — QueryAST, SortNode, AggregationNode, JoinNode, WindowFunction -- [`data/filter.zod.ts`](./data/filter.zod.ts) — FilterCondition, FieldOperators, Comparison/Set/String operators - -## Dependencies (auto-resolved) +- [`data/filter.zod.ts`](./data/filter.zod.ts) — Unified Query DSL Specification +- [`data/query.zod.ts`](./data/query.zod.ts) — Sort Node diff --git a/skills/objectstack-schema/references/data/filter.zod.ts b/skills/objectstack-query/references/data/filter.zod.ts similarity index 100% rename from skills/objectstack-schema/references/data/filter.zod.ts rename to skills/objectstack-query/references/data/filter.zod.ts diff --git a/skills/objectstack-schema/references/data/query.zod.ts b/skills/objectstack-query/references/data/query.zod.ts similarity index 100% rename from skills/objectstack-schema/references/data/query.zod.ts rename to skills/objectstack-query/references/data/query.zod.ts diff --git a/skills/objectstack-quickstart/references/kernel/manifest.zod.ts b/skills/objectstack-quickstart/references/kernel/manifest.zod.ts index a27070f73..0c02aba46 100644 --- a/skills/objectstack-quickstart/references/kernel/manifest.zod.ts +++ b/skills/objectstack-quickstart/references/kernel/manifest.zod.ts @@ -54,10 +54,25 @@ export const ManifestSchema = z.object({ .regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore') .optional() .describe('Short namespace identifier for metadata scoping (e.g. "crm", "todo")'), - - /** + + /** + * Default datasource for all objects in this package. + * + * When set, all objects defined in this package will use this datasource + * by default unless they explicitly override it with their own `datasource` field. + * + * This provides package-level datasource configuration without needing to + * specify it on every individual object. + * + * @example "memory" // Use in-memory driver for all package objects + * @example "turso" // Use Turso/LibSQL for all package objects + */ + defaultDatasource: z.string().optional().default('default') + .describe('Default datasource for all objects in this package'), + + /** * Package version following semantic versioning (major.minor.patch). - * + * * @example "1.0.0" * @example "2.1.0-beta.1" */ @@ -77,17 +92,27 @@ export const ManifestSchema = z.object({ * - adapter: Host adapter (Express, Fastify) */ type: z.enum([ - 'plugin', + 'plugin', ...CORE_PLUGIN_TYPES, - 'module', + 'module', 'gateway', // Deprecated: use 'server' 'adapter' ]).describe('Type of package'), - - /** + + /** + * Deployment scope of this package. + * - `platform`: Provided by the runtime layer (auth, identity, i18n, etc.). + * Never installed per-environment; always globally available. + * - `environment`: A business solution (CRM, project management, custom app). + * Installed independently into each environment (Power Apps "solution" model). + */ + scope: z.enum(['platform', 'environment']).default('environment') + .describe('Deployment scope: platform (runtime-global) or environment (per-env install)'), + + /** * Human-readable name of the package. * Displayed in the UI for users. - * + * * @example "Project Management" */ name: z.string().describe('Human-readable package name'), diff --git a/skills/objectstack-schema/SKILL.md b/skills/objectstack-schema/SKILL.md index 0972aacb1..2a9d6586f 100644 --- a/skills/objectstack-schema/SKILL.md +++ b/skills/objectstack-schema/SKILL.md @@ -72,6 +72,7 @@ database table and exposes automatic CRUD APIs. | `datasource` | `'default'` | Target datasource ID for virtualized data | | `displayNameField` | `'name'` | Field used as record display name | | `enable` | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) | +| `fieldGroups` | — | Ordered list of logical field groups for forms/detail pages (see [Field Groups](#field-groups-mvp)) | ### Object Capabilities (`enable`) @@ -92,6 +93,48 @@ Toggle system behaviours per object: --- +## Field Groups (MVP) + +Organize fields into logical groups (e.g., "Contact Information", "Billing", +"System") for forms, detail pages, and editors. + +- Declare groups on `ObjectSchema.fieldGroups` — **array order is the display order**. +- Assign each field to a group via `Field.group`, which references an + `ObjectFieldGroup.key`. In-group display order equals the traversal order + of `fields`. +- Group keys must be `snake_case`; group labels are human-readable. + +```typescript +import { ObjectSchema } from '@objectstack/spec'; + +export default 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' }, + }, +}); +``` + +**Supported migrations at this layer:** add / rename / delete / reorder groups +(edit the `fieldGroups` array), assign a field to a group (edit `Field.group`). +Explicit per-field in-group ordering is deferred to a future iteration. + +--- + ## Quick Reference — Detailed Rules For comprehensive documentation with incorrect/correct examples: @@ -299,7 +342,7 @@ When extending an object you do not own: - [rules/indexing.md](./rules/indexing.md) — Index types, composite/partial strategies - [rules/hooks.md](./rules/hooks.md) — Data lifecycle hooks quick reference (→ [objectstack-hooks](../objectstack-hooks/references/data-hooks.md)) - [references/data/field.zod.ts](./references/data/field.zod.ts) — FieldType enum, FieldSchema -- [references/data/object.zod.ts](./references/data/object.zod.ts) — ObjectSchema, capabilities +- [references/data/object.zod.ts](./references/data/object.zod.ts) — ObjectSchema, capabilities, `ObjectFieldGroupSchema` - [references/data/validation.zod.ts](./references/data/validation.zod.ts) — Validation rule types - [references/data/hook.zod.ts](./references/data/hook.zod.ts) — Hook schema, HookContext - [Schema index](./references/_index.md) — All bundled schemas with dependency tree diff --git a/skills/objectstack-schema/references/_index.md b/skills/objectstack-schema/references/_index.md index f71493b94..6a2fbea38 100644 --- a/skills/objectstack-schema/references/_index.md +++ b/skills/objectstack-schema/references/_index.md @@ -8,10 +8,8 @@ - [`data/datasource.zod.ts`](./data/datasource.zod.ts) — Driver Identifier - [`data/field.zod.ts`](./data/field.zod.ts) — Field Type Enum -- [`data/filter.zod.ts`](./data/filter.zod.ts) — Unified Query DSL Specification - [`data/hook.zod.ts`](./data/hook.zod.ts) — Hook Lifecycle Events - [`data/object.zod.ts`](./data/object.zod.ts) — API Operations Enum -- [`data/query.zod.ts`](./data/query.zod.ts) — Sort Node - [`data/validation.zod.ts`](./data/validation.zod.ts) — ObjectStack Validation Protocol - [`security/permission.zod.ts`](./security/permission.zod.ts) — Entity (Object) Level Permissions diff --git a/skills/objectstack-schema/references/data/object.zod.ts b/skills/objectstack-schema/references/data/object.zod.ts index 8f444d881..b662fb02c 100644 --- a/skills/objectstack-schema/references/data/object.zod.ts +++ b/skills/objectstack-schema/references/data/object.zod.ts @@ -211,6 +211,68 @@ export const CDCConfigSchema = z.object({ destination: z.string().describe('Destination endpoint (e.g., "kafka://topic", "webhook://url")'), }); +/** + * Object Field Group Schema — MVP (data-layer protocol) + * + * Declares the set of logical field groups for an object. A group bundles + * related fields together for presentation in forms, detail pages, and + * editors (e.g., "Contact Info", "Billing", "System"). + * + * Design rules (MVP): + * - Group **order** is the declaration order of this array — no `order` property. + * - Field → group mapping is derived automatically from `Field.group` + * matching `ObjectFieldGroup.key`; the **in-group display order** equals + * the traversal order of `ObjectSchema.fields`. + * - Fields whose `group` is unset (or references an undeclared key) are + * considered ungrouped and must be rendered by consumers in a default + * bucket after the declared groups, preserving their field declaration order. + * - Extension packages and runtime code use `Field.group` to assign fields + * to an existing group — no per-field order property is introduced at this + * layer. + * + * Migration operations supported by this MVP: + * - add / rename / delete / reorder groups (via the array) + * - assign an existing field to a group (via `Field.group`) + * + * Deferred (not part of MVP): + * - explicit per-field in-group ordering + * - nested groups / sub-groups + * - permission-scoped group visibility beyond `visibleOn` + * + * @example + * ```ts + * fieldGroups: [ + * { key: 'contact_info', label: 'Contact Information', icon: 'user' }, + * { key: 'billing', label: 'Billing', defaultExpanded: false }, + * { key: 'system', label: 'System', visibleOn: '$user.isAdmin' }, + * ] + * ``` + */ +export const ObjectFieldGroupSchema = z.object({ + /** Group key — referenced by `Field.group` to assign a field to this group. Must be snake_case. */ + key: z.string().regex(/^[a-z_][a-z0-9_]*$/, { + message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', + }).describe('Group machine key (snake_case). Referenced by Field.group.'), + + /** Human-readable label displayed as the group header. */ + label: z.string().describe('Group display label'), + + /** Optional Lucide/Material icon name for the group header. */ + icon: z.string().optional().describe('Icon name (Lucide/Material) for the group header'), + + /** Optional description / help text shown under the group header. */ + description: z.string().optional().describe('Optional description shown under the group header'), + + /** Whether the group is expanded by default. Defaults to `true`. */ + defaultExpanded: z.boolean().optional().default(true).describe('Whether the group is expanded by default'), + + /** Optional visibility expression — when false, the entire group is hidden (e.g., "$user.isAdmin", "status == \'closed\'"). */ + visibleOn: z.string().optional().describe('Visibility condition expression; when false the group is hidden'), +}); + +export type ObjectFieldGroup = z.infer; +export type ObjectFieldGroupInput = z.input; + /** * Base Object Schema Definition * @@ -282,6 +344,23 @@ const ObjectSchemaBase = z.object({ message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', }), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), + + /** + * Field Groups (MVP) + * + * Declares logical groups for presenting fields in forms and detail + * pages. The **array order is the group display order**. Each field's + * `Field.group` references an entry's `key` to assign it to a group; + * within a group, fields are displayed in their `ObjectSchema.fields` + * declaration order. + * + * See {@link ObjectFieldGroupSchema} for the full MVP contract and + * deferred features. + */ + fieldGroups: z.array(ObjectFieldGroupSchema).refine( + (groups) => new Set(groups.map(g => g.key)).size === groups.length, + { message: 'fieldGroups[].key must be unique within an object' }, + ).optional().describe('Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema.'), /** * Advanced Data Management diff --git a/skills/objectstack-ui/references/ui/dashboard.zod.ts b/skills/objectstack-ui/references/ui/dashboard.zod.ts index c651402a3..6116be945 100644 --- a/skills/objectstack-ui/references/ui/dashboard.zod.ts +++ b/skills/objectstack-ui/references/ui/dashboard.zod.ts @@ -253,6 +253,12 @@ export const DashboardSchema = z.object({ /** Collection of widgets */ widgets: z.array(DashboardWidgetSchema).describe('Widgets to display'), + /** Grid column count — defaults to 12 for a standard 12-column grid */ + columns: z.number().int().min(1).max(24).optional().describe('Number of grid columns (default 12)'), + + /** Grid gap in Tailwind spacing units (e.g. 4 = 1rem) */ + gap: z.number().int().min(0).optional().describe('Grid gap in Tailwind spacing units'), + /** Auto-refresh */ refreshInterval: z.number().optional().describe('Auto-refresh interval in seconds'), From be962ae844ec6f68b80b6a8835e8ae277d2efe0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:24:13 +0000 Subject: [PATCH 4/4] examples(app-crm): showcase fieldGroups MVP on Account/Contact/Opportunity/Lead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare logical `fieldGroups` on each of the four primary CRM objects and opt every field in via `group: ''`. No business-logic changes — only form-layout metadata — so validations, workflows, indexes, and state machines are unaffected. - Account: basic / financials / contact_info / ownership / branding / system - Contact: identity / account_info / contact_info / mailing_address / additional / preferences - Opportunity: basic / financials / sales_process / classification / competition / notes / forecast - Lead: identity / company_info / contact_info / qualification / assignment / address / additional / preferences / conversion Verified every field maps to a declared group key (0 orphaned, 0 ungrouped) and all four objects parse successfully with `ObjectSchema.safeParse`. Pre-existing typecheck errors in `src/views/lead.view.ts` and `objectstack.config.ts` (unrelated `'1'|'2'|'3'|'4'` string enum and sharing-rule types) are untouched by this change. CHANGELOG entry added under `[Unreleased] → Changed`. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/c2759af4-65bb-4cff-8b2b-ed605c936273 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- CHANGELOG.md | 3 + .../app-crm/src/objects/account.object.ts | 86 +++++++++----- .../app-crm/src/objects/contact.object.ts | 112 +++++++++++------- examples/app-crm/src/objects/lead.object.ts | 91 ++++++++++---- .../app-crm/src/objects/opportunity.object.ts | 71 +++++++---- 5 files changed, 244 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 132400e54..601988cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 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: ''`. 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**. diff --git a/examples/app-crm/src/objects/account.object.ts b/examples/app-crm/src/objects/account.object.ts index 3b47e4936..ab5eb44ee 100644 --- a/examples/app-crm/src/objects/account.object.ts +++ b/examples/app-crm/src/objects/account.object.ts @@ -10,25 +10,39 @@ 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: ''`. + 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' }, @@ -36,9 +50,10 @@ export const Account = ObjectSchema.create({ { label: 'Former Customer', value: 'former', color: '#999999' }, ] }), - + industry: Field.select({ label: 'Industry', + group: 'basic', options: [ { label: 'Technology', value: 'technology' }, { label: 'Finance', value: 'finance' }, @@ -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', }), }, diff --git a/examples/app-crm/src/objects/contact.object.ts b/examples/app-crm/src/objects/contact.object.ts index 7f206ed62..6d328f548 100644 --- a/examples/app-crm/src/objects/contact.object.ts +++ b/examples/app-crm/src/objects/contact.object.ts @@ -9,10 +9,20 @@ export const Contact = ObjectSchema.create({ icon: 'user', description: 'People associated with accounts', + fieldGroups: [ + { key: 'identity', label: 'Identity', icon: 'user' }, + { key: 'account_info', label: 'Account & Role', icon: 'briefcase' }, + { key: 'contact_info', label: 'Contact Information', icon: 'phone' }, + { key: 'mailing_address', label: 'Mailing Address', icon: 'map-pin', defaultExpanded: false }, + { key: 'additional', label: 'Additional Info', icon: 'info', defaultExpanded: false }, + { key: 'preferences', label: 'Communication Preferences', icon: 'bell-off', defaultExpanded: false }, + ], + fields: { // Name fields salutation: Field.select({ label: 'Salutation', + group: 'identity', options: [ { label: 'Mr.', value: 'mr' }, { label: 'Ms.', value: 'ms' }, @@ -21,55 +31,50 @@ export const Contact = ObjectSchema.create({ { label: 'Prof.', value: 'prof' }, ] }), - first_name: Field.text({ + first_name: Field.text({ label: 'First Name', required: true, searchable: true, + group: 'identity', }), - last_name: Field.text({ + last_name: Field.text({ label: 'Last Name', required: true, searchable: true, + group: 'identity', }), - + // Formula field - Full name full_name: Field.formula({ label: 'Full Name', expression: 'CONCAT(salutation, " ", first_name, " ", last_name)', + group: 'identity', }), - + + // Avatar field + avatar: Field.avatar({ + label: 'Profile Picture', + group: 'identity', + }), + // Relationship: Link to Account (Master-Detail) account: Field.masterDetail('account', { label: 'Account', required: true, writeRequiresMasterRead: true, deleteBehavior: 'cascade', // Delete contacts when account is deleted + group: 'account_info', }), - - // Contact Information - email: Field.email({ - label: 'Email', - required: true, - unique: true, - }), - - phone: Field.text({ - label: 'Phone', - format: 'phone', - }), - - mobile: Field.text({ - label: 'Mobile', - format: 'phone', - }), - + // Professional Information title: Field.text({ label: 'Job Title', + group: 'account_info', }), - + department: Field.select({ label: 'Department', + group: 'account_info', options: [ { label: 'Executive', value: 'executive' }, { label: 'Sales', value: 'sales' }, @@ -81,32 +86,56 @@ export const Contact = ObjectSchema.create({ { label: 'Operations', value: 'operations' }, ] }), - + // Relationship fields reports_to: Field.lookup('contact', { label: 'Reports To', description: 'Direct manager/supervisor', + group: 'account_info', }), - + owner: Field.lookup('user', { label: 'Contact Owner', required: true, + group: 'account_info', }), - + + // Contact Information + email: Field.email({ + label: 'Email', + required: true, + unique: true, + group: 'contact_info', + }), + + phone: Field.text({ + label: 'Phone', + format: 'phone', + group: 'contact_info', + }), + + mobile: Field.text({ + label: 'Mobile', + format: 'phone', + group: 'contact_info', + }), + // Mailing Address - mailing_street: Field.textarea({ label: 'Mailing Street' }), - mailing_city: Field.text({ label: 'Mailing City' }), - mailing_state: Field.text({ label: 'Mailing State/Province' }), - mailing_postal_code: Field.text({ label: 'Mailing Postal Code' }), - mailing_country: Field.text({ label: 'Mailing Country' }), - + mailing_street: Field.textarea({ label: 'Mailing Street', group: 'mailing_address' }), + mailing_city: Field.text({ label: 'Mailing City', group: 'mailing_address' }), + mailing_state: Field.text({ label: 'Mailing State/Province', group: 'mailing_address' }), + mailing_postal_code: Field.text({ label: 'Mailing Postal Code', group: 'mailing_address' }), + mailing_country: Field.text({ label: 'Mailing Country', group: 'mailing_address' }), + // Additional Information birthdate: Field.date({ label: 'Birthdate', + group: 'additional', }), - + lead_source: Field.select({ label: 'Lead Source', + group: 'additional', options: [ { label: 'Web', value: 'web' }, { label: 'Referral', value: 'referral' }, @@ -115,31 +144,30 @@ export const Contact = ObjectSchema.create({ { label: 'Advertisement', value: 'advertisement' }, ] }), - + description: Field.markdown({ label: 'Description', + group: 'additional', }), - + // Flags is_primary: Field.boolean({ label: 'Primary Contact', defaultValue: false, description: 'Is this the main contact for the account?', + group: 'preferences', }), - + do_not_call: Field.boolean({ label: 'Do Not Call', defaultValue: false, + group: 'preferences', }), - + email_opt_out: Field.boolean({ label: 'Email Opt Out', defaultValue: false, - }), - - // Avatar field - avatar: Field.avatar({ - label: 'Profile Picture', + group: 'preferences', }), }, diff --git a/examples/app-crm/src/objects/lead.object.ts b/examples/app-crm/src/objects/lead.object.ts index 2cca094d3..00c465285 100644 --- a/examples/app-crm/src/objects/lead.object.ts +++ b/examples/app-crm/src/objects/lead.object.ts @@ -10,10 +10,23 @@ export const Lead = ObjectSchema.create({ icon: 'user-plus', description: 'Potential customers not yet qualified', + fieldGroups: [ + { key: 'identity', label: 'Identity', icon: 'user-plus' }, + { key: 'company_info', label: 'Company Information', icon: 'building' }, + { key: 'contact_info', label: 'Contact Information', icon: 'phone' }, + { key: 'qualification', label: 'Qualification', icon: 'star' }, + { key: 'assignment', label: 'Assignment', icon: 'user' }, + { key: 'address', label: 'Address', icon: 'map-pin', defaultExpanded: false }, + { key: 'additional', label: 'Additional Info', icon: 'info', defaultExpanded: false }, + { key: 'preferences', label: 'Communication Preferences', icon: 'bell-off', defaultExpanded: false }, + { key: 'conversion', label: 'Conversion', icon: 'check-circle', defaultExpanded: false }, + ], + fields: { // Personal Information salutation: Field.select({ label: 'Salutation', + group: 'identity', options: [ { label: 'Mr.', value: 'mr' }, { label: 'Ms.', value: 'ms' }, @@ -21,37 +34,43 @@ export const Lead = ObjectSchema.create({ { label: 'Dr.', value: 'dr' }, ] }), - + first_name: Field.text({ label: 'First Name', required: true, searchable: true, + group: 'identity', }), - + last_name: Field.text({ label: 'Last Name', required: true, searchable: true, + group: 'identity', }), - + full_name: Field.formula({ label: 'Full Name', expression: 'CONCAT(salutation, " ", first_name, " ", last_name)', + group: 'identity', }), - + // Company Information company: Field.text({ label: 'Company', required: true, searchable: true, + group: 'company_info', }), - + title: Field.text({ label: 'Job Title', + group: 'company_info', }), - + industry: Field.select({ label: 'Industry', + group: 'company_info', options: [ { label: 'Technology', value: 'technology' }, { label: 'Finance', value: 'finance' }, @@ -61,32 +80,37 @@ export const Lead = ObjectSchema.create({ { label: 'Education', value: 'education' }, ] }), - + // Contact Information email: Field.email({ label: 'Email', required: true, unique: true, + group: 'contact_info', }), - + phone: Field.text({ label: 'Phone', format: 'phone', + group: 'contact_info', }), - + mobile: Field.text({ label: 'Mobile', format: 'phone', + group: 'contact_info', }), - + website: Field.url({ label: 'Website', + group: 'contact_info', }), - + // Lead Qualification status: Field.select({ label: 'Lead Status', required: true, + group: 'qualification', options: [ { label: 'New', value: 'new', color: '#808080', default: true }, { label: 'Contacted', value: 'contacted', color: '#FFA500' }, @@ -95,15 +119,17 @@ export const Lead = ObjectSchema.create({ { label: 'Converted', value: 'converted', color: '#00AA00' }, ] }), - + rating: Field.rating(5, { label: 'Lead Score', description: 'Lead quality score (1-5 stars)', allowHalf: true, + group: 'qualification', }), - + lead_source: Field.select({ label: 'Lead Source', + group: 'qualification', options: [ { label: 'Web', value: 'web' }, { label: 'Referral', value: 'referral' }, @@ -113,75 +139,88 @@ export const Lead = ObjectSchema.create({ { label: 'Cold Call', value: 'cold_call' }, ] }), - + // Assignment owner: Field.lookup('user', { label: 'Lead Owner', required: true, + group: 'assignment', }), - + // Conversion tracking is_converted: Field.boolean({ label: 'Converted', defaultValue: false, readonly: true, + group: 'conversion', }), - + converted_account: Field.lookup('account', { label: 'Converted Account', readonly: true, + group: 'conversion', }), - + converted_contact: Field.lookup('contact', { label: 'Converted Contact', readonly: true, + group: 'conversion', }), - + converted_opportunity: Field.lookup('opportunity', { label: 'Converted Opportunity', readonly: true, + group: 'conversion', }), - + converted_date: Field.datetime({ label: 'Converted Date', readonly: true, + group: 'conversion', }), - + // Address (using new address field type) address: Field.address({ label: 'Address', addressFormat: 'international', + group: 'address', }), - + // Additional Info annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2, + group: 'additional', }), - + number_of_employees: Field.number({ label: 'Number of Employees', + group: 'additional', }), - + description: Field.markdown({ label: 'Description', + group: 'additional', }), - + // Custom notes with rich text formatting notes: Field.richtext({ label: 'Notes', description: 'Rich text notes with formatting', + group: 'additional', }), - + // Flags do_not_call: Field.boolean({ label: 'Do Not Call', defaultValue: false, + group: 'preferences', }), - + email_opt_out: Field.boolean({ label: 'Email Opt Out', defaultValue: false, + group: 'preferences', }), }, diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index cf17cec37..771ecb532 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -11,49 +11,66 @@ export const Opportunity = ObjectSchema.create({ description: 'Sales opportunities and deals in the pipeline', titleFormat: '{name} - {stage}', compactLayout: ['name', 'account', 'amount', 'stage', 'owner'], - + + fieldGroups: [ + { key: 'basic', label: 'Basic Information', icon: 'dollar-sign' }, + { key: 'financials', label: 'Financials', icon: 'trending-up' }, + { key: 'sales_process', label: 'Sales Process', icon: 'target' }, + { key: 'classification', label: 'Classification', icon: 'tag' }, + { key: 'competition', label: 'Competition & Campaigns', icon: 'flag', defaultExpanded: false }, + { key: 'notes', label: 'Notes & Next Steps', icon: 'file-text' }, + { key: 'forecast', label: 'Forecast & Metrics', icon: 'bar-chart', defaultExpanded: false }, + ], + fields: { // Basic Information - name: Field.text({ + name: Field.text({ label: 'Opportunity Name', required: true, searchable: true, + group: 'basic', }), - + // Relationships - account: Field.lookup('account', { + account: Field.lookup('account', { label: 'Account', required: true, + group: 'basic', }), - + primary_contact: Field.lookup('contact', { label: 'Primary Contact', referenceFilters: ['account = {opportunity.account}'], // Filter contacts by account + group: 'basic', }), - + owner: Field.lookup('user', { label: 'Opportunity Owner', required: true, + group: 'basic', }), - + // Financial Information amount: Field.currency({ label: 'Amount', required: true, scale: 2, min: 0, + group: 'financials', }), - + expected_revenue: Field.currency({ label: 'Expected Revenue', scale: 2, readonly: true, // Calculated field + group: 'financials', }), - + // Sales Process stage: Field.select({ label: 'Stage', required: true, + group: 'sales_process', options: [ { label: 'Prospecting', value: 'prospecting', color: '#808080', default: true }, { label: 'Qualification', value: 'qualification', color: '#FFA500' }, @@ -64,28 +81,32 @@ export const Opportunity = ObjectSchema.create({ { label: 'Closed Lost', value: 'closed_lost', color: '#FF0000' }, ] }), - + probability: Field.percent({ label: 'Probability (%)', min: 0, max: 100, defaultValue: 10, + group: 'sales_process', }), - + // Important Dates close_date: Field.date({ label: 'Close Date', required: true, + group: 'sales_process', }), - + created_date: Field.datetime({ label: 'Created Date', readonly: true, + group: 'sales_process', }), - + // Additional Classification type: Field.select({ label: 'Opportunity Type', + group: 'classification', options: [ { label: 'New Business', value: 'new_business' }, { label: 'Existing Customer - Upgrade', value: 'existing_upgrade' }, @@ -93,9 +114,10 @@ export const Opportunity = ObjectSchema.create({ { label: 'Existing Customer - Expansion', value: 'existing_expansion' }, ] }), - + lead_source: Field.select({ label: 'Lead Source', + group: 'classification', options: [ { label: 'Web', value: 'web' }, { label: 'Referral', value: 'referral' }, @@ -105,47 +127,54 @@ export const Opportunity = ObjectSchema.create({ { label: 'Cold Call', value: 'cold_call' }, ] }), - + // Competitor Analysis competitors: Field.select({ label: 'Competitors', multiple: true, + group: 'competition', options: [ { label: 'Competitor A', value: 'competitor_a' }, { label: 'Competitor B', value: 'competitor_b' }, { label: 'Competitor C', value: 'competitor_c' }, ] }), - + // Campaign tracking campaign: Field.lookup('campaign', { label: 'Campaign', description: 'Marketing campaign that generated this opportunity', + group: 'competition', }), - + // Sales cycle metrics days_in_stage: Field.number({ label: 'Days in Current Stage', readonly: true, + group: 'forecast', }), - + // Additional information description: Field.markdown({ label: 'Description', + group: 'notes', }), - + next_step: Field.textarea({ label: 'Next Steps', + group: 'notes', }), - + // Flags is_private: Field.boolean({ label: 'Private', defaultValue: false, + group: 'forecast', }), - + forecast_category: Field.select({ label: 'Forecast Category', + group: 'forecast', options: [ { label: 'Pipeline', value: 'pipeline' }, { label: 'Best Case', value: 'best_case' },