diff --git a/apps/core/entities/entity/entity.entity.yml b/apps/core/entities/entity/entity.entity.yml
index 1a86e7f..3cf9b5e 100644
--- a/apps/core/entities/entity/entity.entity.yml
+++ b/apps/core/entities/entity/entity.entity.yml
@@ -78,6 +78,10 @@ fields:
label: Naming
type: json
+ - name: form
+ label: Form
+ type: json
+
- name: retired
label: Retired
type: boolean
diff --git a/apps/studio/ui/src/design/index.ts b/apps/studio/ui/src/design/index.ts
index 5986f2c..fe99bb7 100644
--- a/apps/studio/ui/src/design/index.ts
+++ b/apps/studio/ui/src/design/index.ts
@@ -51,3 +51,5 @@ export { default as SwitchField } from './molecules/SwitchField.vue'
export { default as TextareaField } from './molecules/TextareaField.vue'
export { default as TextField } from './molecules/TextField.vue'
export { default as DataTable } from './organisms/DataTable.vue'
+
+export { TabsRoot as Tabs, TabsList, TabsTrigger, TabsContent } from 'reka-ui'
diff --git a/apps/studio/ui/src/features/metadata/metadata.api.ts b/apps/studio/ui/src/features/metadata/metadata.api.ts
index 22369ac..54a12c0 100644
--- a/apps/studio/ui/src/features/metadata/metadata.api.ts
+++ b/apps/studio/ui/src/features/metadata/metadata.api.ts
@@ -75,7 +75,26 @@ export type MetadataLinkOptions = {
filters: MetadataLinkFilter[]
}
+export type MetadataFormItem = {
+ kind: 'field' | 'column' | 'section'
+ name?: string
+ label?: string
+ description?: string
+}
+
+export type MetadataFormTab = {
+ key: string
+ label: string
+ icon?: string
+ items: MetadataFormItem[]
+}
+
+export type MetadataFormLayout = {
+ tabs: MetadataFormTab[]
+}
+
export type MetadataEntityMeta = MetadataEntity & {
+ form?: MetadataFormLayout
fields: MetadataField[]
'system-fields': MetadataField[]
indexes: unknown[]
diff --git a/apps/studio/ui/src/pages/RecordFormPage.vue b/apps/studio/ui/src/pages/RecordFormPage.vue
index f15a473..a2dd4d9 100644
--- a/apps/studio/ui/src/pages/RecordFormPage.vue
+++ b/apps/studio/ui/src/pages/RecordFormPage.vue
@@ -926,6 +926,7 @@ function draftValuesEqual(left: unknown, right: unknown): boolean {
:entity-key="entityMeta?.key ?? ''"
:entity-label="entityLabel"
:fields="fields"
+ :form="entityMeta?.form"
:system-fields="systemFields"
:collections="entityMeta.collections"
:record="record"
diff --git a/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue b/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue
index c47beae..d6738fa 100644
--- a/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue
+++ b/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue
@@ -1,18 +1,31 @@
-
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
diff --git a/apps/studio/ui/src/renderers/records/record-form-layout.test.ts b/apps/studio/ui/src/renderers/records/record-form-layout.test.ts
new file mode 100644
index 0000000..108800c
--- /dev/null
+++ b/apps/studio/ui/src/renderers/records/record-form-layout.test.ts
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { resolveFormLayout } from './record-form-layout.ts'
+
+test('flat forms keep all visible fields without a tab strip', () => {
+ const result = resolveFormLayout(null, [{ name: 'name' }, { name: 'title' }], 'Customer')
+ assert.equal(result.showTabStrip, false)
+ assert.deepEqual(result.form.tabs[0]?.items.map((item) => item.name), ['name', 'title'])
+})
+
+test('one explicit tab remains visible and includes the manual Record ID once', () => {
+ const authored = { tabs: [{ key: 'general', label: 'General', items: [{ kind: 'field' as const, name: 'title' }] }] }
+ const result = resolveFormLayout(authored, [{ name: 'name' }, { name: 'title' }], 'Customer')
+ assert.equal(result.showTabStrip, true)
+ assert.deepEqual(result.form.tabs[0]?.items.map((item) => item.name), ['name', 'title'])
+ assert.deepEqual(authored.tabs[0]?.items.map((item) => item.name), ['title'])
+})
+
+test('fields assigned to another tab retain their position', () => {
+ const authored = { tabs: [
+ { key: 'general', label: 'General', items: [{ kind: 'field' as const, name: 'title' }] },
+ { key: 'details', label: 'Details', items: [{ kind: 'section' as const, label: 'Details' }, { kind: 'field' as const, name: 'note' }] },
+ ] }
+ const result = resolveFormLayout(authored, [{ name: 'name' }, { name: 'title' }, { name: 'note' }], 'Customer')
+ assert.deepEqual(result.form.tabs[0]?.items.map((item) => item.name), ['name', 'title'])
+ assert.deepEqual(result.form.tabs[1], authored.tabs[1])
+})
diff --git a/apps/studio/ui/src/renderers/records/record-form-layout.ts b/apps/studio/ui/src/renderers/records/record-form-layout.ts
new file mode 100644
index 0000000..4f25b5d
--- /dev/null
+++ b/apps/studio/ui/src/renderers/records/record-form-layout.ts
@@ -0,0 +1,28 @@
+import type { MetadataFormLayout, MetadataFormItem } from '@/features/metadata/metadata.api'
+
+export function resolveFormLayout(
+ authored: MetadataFormLayout | null | undefined,
+ fields: { name: string }[],
+ label: string,
+): { form: MetadataFormLayout; showTabStrip: boolean } {
+ const fieldItems = (names: { name: string }[]): MetadataFormItem[] => names.map(({ name }) => ({ kind: 'field', name }))
+ if (!authored?.tabs.length) {
+ return {
+ form: { tabs: [{ key: 'default', label, items: fieldItems(fields) }] },
+ showTabStrip: false,
+ }
+ }
+
+ const assigned = new Set(authored.tabs.flatMap((tab) => tab.items.filter((item) => item.kind === 'field').map((item) => item.name)))
+ // Runtime fields include the manual Record ID, which is not an authored storage Field.
+ const extraFields = fieldItems(fields.filter((field) => !assigned.has(field.name)))
+ return {
+ form: {
+ tabs: authored.tabs.map((tab, index) => ({
+ ...tab,
+ items: index === 0 ? [...extraFields, ...tab.items] : tab.items,
+ })),
+ },
+ showTabStrip: true,
+ }
+}
diff --git a/docs/entity-metadata.md b/docs/entity-metadata.md
index c3b8f37..d0bb48c 100644
--- a/docs/entity-metadata.md
+++ b/docs/entity-metadata.md
@@ -427,3 +427,34 @@ Both commands discover the dygo project root before loading apps, so they can be
`link` and `collection` targets use `{app, entity}` identity when `options.app` is set. Without `options.app`, dygo resolves same-app targets first, then a single globally unambiguous target. If no Entity matches or multiple external apps match, validation fails. Collection targets must resolve to collection Entities.
Validation errors include the app name, Entity key, field name when relevant, file path, and a best-effort YAML line number.
+
+## Form tabs
+
+Use either top-level `fields` or `tabs`. Each tab needs a label in `tab`, a unique kebab-case `name`, and a non-empty `fields` list. Set `icon` to add an optional Lucide icon. An explicit tab remains visible even when it is the only tab.
+
+```yaml
+label: Contact
+name:
+ strategy: random
+tabs:
+ - tab: Details
+ name: details
+ fields:
+ - name: full-name
+ label: Full Name
+ type: text
+ - type: column
+ - name: email
+ label: Email
+ type: email
+ - type: section
+ label: Notes
+ description: Additional contact information
+ - name: notes
+ label: Notes
+ type: text
+```
+
+`type: column` starts the next column. `type: section` starts a new section and requires a label. Its description is optional. These markers control the form layout only. Do not add required, unique, index, default, check, fetch, or options settings to a marker.
+
+Storage field names must be unique across all tabs. Markers do not create database columns or Record fields. Indexes, constraints, permissions, and Record APIs use storage fields only. Metadata sync saves the ordered layout in `entity.form` for Studio.
diff --git a/internal/db/metadata_reader.go b/internal/db/metadata_reader.go
index b6c2db1..8c7bbda 100644
--- a/internal/db/metadata_reader.go
+++ b/internal/db/metadata_reader.go
@@ -68,6 +68,7 @@ func (e MetadataEntity) RouteSlug() string {
// MetadataEntityMeta is the complete persisted metadata for one Entity.
type MetadataEntityMeta struct {
MetadataEntity
+ Form *schema.FormLayout `json:"form,omitempty"`
Fields []MetadataField `json:"fields"`
SystemFields []MetadataField `json:"system-fields"`
Indexes []MetadataIndex `json:"indexes"`
@@ -240,7 +241,7 @@ ORDER BY a.name, e.key`, args...)
// GetEntityMeta returns complete persisted metadata for one Entity slug.
func (r MetadataReader) GetEntityMeta(ctx context.Context, slug string) (MetadataEntityMeta, error) {
return r.getEntityMeta(ctx, slug, `
-SELECT e.id, e.name, e.key, COALESCE(e.slug, ''), e.label, COALESCE(e.description, ''), COALESCE(e.icon, ''), COALESCE(e.is_single, false), COALESCE(e.is_system, false), COALESCE(e.is_collection, false), COALESCE(e.is_private, false), COALESCE(e.private_owner_field, ''), e.naming, a.name, a.label, to_jsonb(e)->'tree'
+SELECT e.id, e.name, e.key, COALESCE(e.slug, ''), e.label, COALESCE(e.description, ''), COALESCE(e.icon, ''), COALESCE(e.is_single, false), COALESCE(e.is_system, false), COALESCE(e.is_collection, false), COALESCE(e.is_private, false), COALESCE(e.private_owner_field, ''), e.naming, a.name, a.label, to_jsonb(e)->'tree', to_jsonb(e)->'form'
FROM "entity" e
JOIN "app" a ON a.id = e.app_id
WHERE e.slug = $1 AND NOT e.retired`, slug)
@@ -249,7 +250,7 @@ WHERE e.slug = $1 AND NOT e.retired`, slug)
// GetEntityMetaByIdentity returns complete persisted metadata for one app-scoped Entity identity.
func (r MetadataReader) GetEntityMetaByIdentity(ctx context.Context, appName string, entity string) (MetadataEntityMeta, error) {
return r.getEntityMeta(ctx, appName+"/"+entity, `
-SELECT e.id, e.name, e.key, COALESCE(e.slug, ''), e.label, COALESCE(e.description, ''), COALESCE(e.icon, ''), COALESCE(e.is_single, false), COALESCE(e.is_system, false), COALESCE(e.is_collection, false), COALESCE(e.is_private, false), COALESCE(e.private_owner_field, ''), e.naming, a.name, a.label, to_jsonb(e)->'tree'
+SELECT e.id, e.name, e.key, COALESCE(e.slug, ''), e.label, COALESCE(e.description, ''), COALESCE(e.icon, ''), COALESCE(e.is_single, false), COALESCE(e.is_system, false), COALESCE(e.is_collection, false), COALESCE(e.is_private, false), COALESCE(e.private_owner_field, ''), e.naming, a.name, a.label, to_jsonb(e)->'tree', to_jsonb(e)->'form'
FROM "entity" e
JOIN "app" a ON a.id = e.app_id
WHERE a.name = $1 AND e.key = $2 AND NOT e.retired`, appName, entity)
@@ -264,7 +265,8 @@ func (r MetadataReader) getEntityMeta(ctx context.Context, name string, sql stri
var meta MetadataEntityMeta
var naming []byte
var slug string
- err := r.queryer.QueryRow(ctx, sql, args...).Scan(&meta.ID, &meta.Name, &meta.Key, &slug, &meta.Label, &meta.Description, &meta.Icon, &meta.IsSingle, &meta.IsSystem, &meta.IsCollection, &meta.IsPrivate, &meta.PrivateOwnerField, &naming, &meta.App.Name, &meta.App.Label, &meta.Tree)
+ var formJSON []byte
+ err := r.queryer.QueryRow(ctx, sql, args...).Scan(&meta.ID, &meta.Name, &meta.Key, &slug, &meta.Label, &meta.Description, &meta.Icon, &meta.IsSingle, &meta.IsSystem, &meta.IsCollection, &meta.IsPrivate, &meta.PrivateOwnerField, &naming, &meta.App.Name, &meta.App.Label, &meta.Tree, &formJSON)
if errors.Is(err, pgx.ErrNoRows) {
return MetadataEntityMeta{}, MetadataNotFoundError{Kind: "entity", Name: name}
}
@@ -273,6 +275,15 @@ func (r MetadataReader) getEntityMeta(ctx context.Context, name string, sql stri
}
meta.Slug = stringPointerOrNil(slug)
meta.Naming = rawJSONOrNil(naming)
+ if len(formJSON) > 0 && string(formJSON) != "null" {
+ var form schema.FormLayout
+ if err := json.Unmarshal(formJSON, &form); err != nil {
+ return MetadataEntityMeta{}, fmt.Errorf("decode entity form %q: %w", name, err)
+ }
+ if len(form.Tabs) > 0 {
+ meta.Form = &form
+ }
+ }
fields, err := r.entityFields(ctx, meta.ID)
if err != nil {
diff --git a/internal/db/metadata_reader_test.go b/internal/db/metadata_reader_test.go
index 1b06194..5df4456 100644
--- a/internal/db/metadata_reader_test.go
+++ b/internal/db/metadata_reader_test.go
@@ -76,7 +76,7 @@ func TestMetadataReaderListEntities(t *testing.T) {
func TestMetadataReaderGetEntityMeta(t *testing.T) {
queryer := &fakeMetadataQueryer{
- row: newFakeRow(int64(10), "core.user", "user", "user", "User", "User identity", "user", true, true, false, false, "", []byte(`{"strategy":"format","format":"{email}"}`), "core", "Core", nil),
+ row: newFakeRow(int64(10), "core.user", "user", "user", "User", "User identity", "user", true, true, false, false, "", []byte(`{"strategy":"format","format":"{email}"}`), "core", "Core", nil, []byte(`{"tabs":[{"key":"identity","label":"Identity","icon":"user","items":[{"kind":"field","name":"email"},{"kind":"column"},{"kind":"section","label":"Settings","description":"Account settings"},{"kind":"field","name":"enabled"}]}]}`)),
rows: []pgx.Rows{
newFakeRows([][]any{
{int64(1), "email", "Email", "email", true, true, true, nil, nil, []byte(`{"from":"profile.email"}`), 1, []byte(`{"entity":"user"}`)},
@@ -99,6 +99,13 @@ func TestMetadataReaderGetEntityMeta(t *testing.T) {
if meta.Name != "core.user" || meta.Key != "user" || meta.RouteSlug() != "user" || meta.Icon != "user" || meta.App.Name != "core" || !meta.IsSingle || !meta.IsSystem {
t.Fatalf("GetEntityMeta() = %+v, want core/user", meta.MetadataEntity)
}
+ if meta.Form == nil || len(meta.Form.Tabs) != 1 {
+ t.Fatalf("form = %+v, want one explicit tab", meta.Form)
+ }
+ tab := meta.Form.Tabs[0]
+ if tab.Key != "identity" || tab.Label != "Identity" || tab.Icon != "user" || len(tab.Items) != 4 || tab.Items[0].Name != "email" || tab.Items[1].Kind != "column" || tab.Items[2].Kind != "section" || tab.Items[2].Description != "Account settings" || tab.Items[3].Name != "enabled" {
+ t.Fatalf("form tab = %+v, want ordered fields and layout markers", tab)
+ }
if len(meta.Fields) != 2 || meta.Fields[0].Name != "email" || string(meta.Fields[0].Options) != `{"entity":"user"}` {
t.Fatalf("GetEntityMeta() fields = %+v, want ordered fields", meta.Fields)
}
@@ -124,9 +131,9 @@ func TestMetadataReaderGetEntityMeta(t *testing.T) {
func TestMetadataReaderEmbedsCollectionMetadata(t *testing.T) {
queryer := &fakeMetadataQueryer{
- row: newFakeRow(int64(20), "crm.lead", "lead", "lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil),
+ row: newFakeRow(int64(20), "crm.lead", "lead", "lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil, nil),
identityRows: map[string]pgx.Row{
- "crm/lead-contact": newFakeRow(int64(21), "crm.lead-contact", "lead-contact", "", "Lead Contact", "Child row", "contact", false, false, true, false, "", nil, "crm", "CRM", nil),
+ "crm/lead-contact": newFakeRow(int64(21), "crm.lead-contact", "lead-contact", "", "Lead Contact", "Child row", "contact", false, false, true, false, "", nil, "crm", "CRM", nil, nil),
},
rows: []pgx.Rows{
newFakeRows([][]any{
@@ -161,7 +168,7 @@ func TestMetadataReaderEmbedsCollectionMetadata(t *testing.T) {
func TestMetadataReaderGetEntityMetaByIdentity(t *testing.T) {
queryer := &fakeMetadataQueryer{
- row: newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil),
+ row: newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil, nil),
rows: []pgx.Rows{
newFakeRows([][]any{
{int64(1), "status", "Status", "select", true, false, false, nil, nil, nil, 1, []byte(`{"values":["New"]}`)},
diff --git a/internal/db/metadata_records.go b/internal/db/metadata_records.go
index 33ee057..eb01161 100644
--- a/internal/db/metadata_records.go
+++ b/internal/db/metadata_records.go
@@ -63,6 +63,7 @@ type entityRecord struct {
PrivateOwnerField string
Naming []byte
Tree []byte
+ Form []byte
}
type pageRecord struct {
@@ -187,8 +188,8 @@ RETURNING id`, app.Name, app.Label, app.Version, app.Status).Scan(&id); err != n
}
var id int64
if err := tx.QueryRow(ctx, `
-INSERT INTO "entity" (app_id, name, key, slug, label, description, icon, is_single, is_system, is_collection, is_private, private_owner_field, naming, tree)
-VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+INSERT INTO "entity" (app_id, name, key, slug, label, description, icon, is_single, is_system, is_collection, is_private, private_owner_field, naming, tree, form)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (name) DO UPDATE
SET app_id = EXCLUDED.app_id,
name = EXCLUDED.name,
@@ -204,9 +205,10 @@ SET app_id = EXCLUDED.app_id,
private_owner_field = EXCLUDED.private_owner_field,
naming = EXCLUDED.naming,
tree = EXCLUDED.tree,
+ form = EXCLUDED.form,
retired = false,
updated_at = now()
-RETURNING id`, appID, entity.Name, entity.Key, entity.Slug, entity.Label, entity.Description, entity.Icon, entity.IsSingle, entity.IsSystem, entity.IsCollection, entity.IsPrivate, nullIfEmpty(entity.PrivateOwnerField), entity.Naming, entity.Tree).Scan(&id); err != nil {
+RETURNING id`, appID, entity.Name, entity.Key, entity.Slug, entity.Label, entity.Description, entity.Icon, entity.IsSingle, entity.IsSystem, entity.IsCollection, entity.IsPrivate, nullIfEmpty(entity.PrivateOwnerField), entity.Naming, entity.Tree, entity.Form).Scan(&id); err != nil {
return metadataPersistResult{}, fmt.Errorf("persist entity metadata %s/%s: %w", entity.AppName, entity.Key, err)
}
entityIDs[entityKey(entity.AppName, entity.Key)] = id
@@ -551,6 +553,13 @@ func buildMetadataRecords(metadata metadataCatalog) (metadataRecordSet, error) {
return metadataRecordSet{}, err
}
}
+ var formJSON []byte
+ if loaded.Entity.Form != nil && len(loaded.Entity.Form.Tabs) > 0 {
+ formJSON, err = json.Marshal(loaded.Entity.Form)
+ if err != nil {
+ return metadataRecordSet{}, fmt.Errorf("build entity metadata %s/%s form: %w", loaded.AppName, loaded.Entity.Name, err)
+ }
+ }
records.Entities = append(records.Entities, entityRecord{
AppName: loaded.AppName,
Name: entityName,
@@ -566,6 +575,7 @@ func buildMetadataRecords(metadata metadataCatalog) (metadataRecordSet, error) {
PrivateOwnerField: strings.TrimSpace(loaded.Entity.PrivateOwnerField),
Naming: namingJSON,
Tree: treeJSON,
+ Form: formJSON,
})
for index, field := range loaded.Entity.Fields {
defaultJSON, err := fieldDefaultJSON(field.Default)
diff --git a/internal/db/metadata_records_test.go b/internal/db/metadata_records_test.go
index 6f530a0..66272b4 100644
--- a/internal/db/metadata_records_test.go
+++ b/internal/db/metadata_records_test.go
@@ -63,6 +63,9 @@ func TestBuildMetadataRecords(t *testing.T) {
if len(records.Entities) != 1 || records.Entities[0].Name != "core.user" || records.Entities[0].Key != "user" || records.Entities[0].Slug == nil || *records.Entities[0].Slug != "user" || records.Entities[0].Icon != "user" || records.Entities[0].AppName != "core" || !records.Entities[0].IsSingle || !records.Entities[0].IsSystem {
t.Fatalf("entity records = %+v, want core/user", records.Entities)
}
+ if records.Entities[0].Form != nil {
+ t.Fatalf("flat entity form metadata = %s, want nil", records.Entities[0].Form)
+ }
if records.Entities[0].Naming != nil {
t.Fatalf("single entity naming metadata = %s, want nil", records.Entities[0].Naming)
}
@@ -421,3 +424,43 @@ func TestFieldDefaultJSONRejectsNonScalar(t *testing.T) {
t.Fatalf("fieldDefaultJSON() error = %q, want scalar context", err.Error())
}
}
+
+func TestBuildMetadataRecordsStoresAuthoredForm(t *testing.T) {
+ entity, err := schema.Decode([]byte(`label: Contact
+name: {strategy: random}
+tabs:
+ - tab: Identity
+ name: identity
+ icon: user
+ fields:
+ - {name: email, label: Email, type: email}
+ - {type: column}
+ - {type: section, label: Settings, description: Account settings}
+ - {name: enabled, label: Enabled, type: boolean}
+`), fieldtype.DefaultRegistry())
+ if err != nil {
+ t.Fatal(err)
+ }
+ entity.Name = "contact"
+ records, err := buildMetadataRecords(metadataCatalog{
+ Apps: []manifest.LoadedApp{{Manifest: manifest.Manifest{Name: "crm", Label: "CRM", Version: "0.1.0"}}},
+ Entities: []catalog.LoadedEntity{{AppName: "crm", Path: testEntityPath("crm", "contact"), Entity: entity}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(records.Fields) != 2 || records.Fields[0].Name != "email" || records.Fields[1].Name != "enabled" || records.Fields[0].Position != 1 || records.Fields[1].Position != 2 {
+ t.Fatalf("storage fields = %+v, want only email and enabled in order", records.Fields)
+ }
+ var form schema.FormLayout
+ if err := json.Unmarshal(records.Entities[0].Form, &form); err != nil {
+ t.Fatal(err)
+ }
+ if len(form.Tabs) != 1 {
+ t.Fatalf("form = %+v, want one tab", form)
+ }
+ tab := form.Tabs[0]
+ if tab.Key != "identity" || tab.Label != "Identity" || tab.Icon != "user" || len(tab.Items) != 4 || tab.Items[0].Name != "email" || tab.Items[1].Kind != "column" || tab.Items[2].Kind != "section" || tab.Items[2].Label != "Settings" || tab.Items[2].Description != "Account settings" || tab.Items[3].Name != "enabled" {
+ t.Fatalf("form tab = %+v, want authored field and marker order", tab)
+ }
+}
diff --git a/internal/db/patch_apply_test.go b/internal/db/patch_apply_test.go
index 7ad2165..7ee60ae 100644
--- a/internal/db/patch_apply_test.go
+++ b/internal/db/patch_apply_test.go
@@ -478,7 +478,7 @@ func (tx *fakePatchApplyTx) QueryRow(_ context.Context, sql string, args ...any)
return newFakeRow(int64(10))
case isPatchRunMetadataQuery(sql, args...):
tx.events = append(tx.events, "queryrow:metadata")
- return newFakeRow(int64(2), "core.patch-run", "patch-run", "patch-run", "Patch Run", "Ledger entry", "git-pull-request-arrow", false, true, false, false, "", []byte(`{"strategy":"format","format":"{app}.{patch-id}"}`), "core", "Core", nil)
+ return newFakeRow(int64(2), "core.patch-run", "patch-run", "patch-run", "Patch Run", "Ledger entry", "git-pull-request-arrow", false, true, false, false, "", []byte(`{"strategy":"format","format":"{app}.{patch-id}"}`), "core", "Core", nil, nil)
case strings.Contains(sql, `SELECT "name" FROM "app"`) && len(args) == 1 && args[0] == int64(10):
tx.events = append(tx.events, "queryrow:link")
return newFakeRow("sales")
diff --git a/internal/db/patch_ledger_test.go b/internal/db/patch_ledger_test.go
index 538b753..10579c4 100644
--- a/internal/db/patch_ledger_test.go
+++ b/internal/db/patch_ledger_test.go
@@ -235,7 +235,7 @@ func (q *fakePatchLedgerQueryer) QueryRow(_ context.Context, sql string, args ..
q.rowSQL = append(q.rowSQL, sql)
q.rowArgs = append(q.rowArgs, args)
if isPatchRunMetadataQuery(sql, args...) {
- return newFakeRow(int64(2), "core.patch-run", "patch-run", "patch-run", "Patch Run", "Ledger entry", "git-pull-request-arrow", false, true, false, false, "", []byte(`{"strategy":"format","format":"{app}.{patch-id}"}`), "core", "Core", nil)
+ return newFakeRow(int64(2), "core.patch-run", "patch-run", "patch-run", "Patch Run", "Ledger entry", "git-pull-request-arrow", false, true, false, false, "", []byte(`{"strategy":"format","format":"{app}.{patch-id}"}`), "core", "Core", nil, nil)
}
if strings.Contains(sql, `SELECT "id" FROM "app"`) && len(args) == 1 && args[0] == "crm" {
return newFakeRow(int64(10))
diff --git a/internal/db/records_test.go b/internal/db/records_test.go
index 2d3dfb7..bcb21a8 100644
--- a/internal/db/records_test.go
+++ b/internal/db/records_test.go
@@ -266,7 +266,7 @@ func TestRecordStoreListRecordsSortByIDSkipsTieBreaker(t *testing.T) {
func TestRecordStoreListRecordsByIdentityHonorsFiltersAndSort(t *testing.T) {
now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
queryer := newLeadRecordQueryer()
- queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil)
+ queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil, nil)
queryer.rows = append(queryer.rows, newFakeRows([][]any{
{int64(7), "lead-7", now, now, "New"},
}))
@@ -315,7 +315,7 @@ func TestRecordStoreGetRecord(t *testing.T) {
func TestRecordStoreGetRecordByIdentityUsesAppEntityLookup(t *testing.T) {
now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
queryer := newLeadRecordQueryer()
- queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil)
+ queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil, nil)
queryer.rows = append(queryer.rows, newFakeRows([][]any{
{int64(7), "lead-7", now, now, "New"},
}))
@@ -342,7 +342,7 @@ func TestRecordStoreGetRecordByIdentityUsesAppEntityLookup(t *testing.T) {
func TestRecordStoreRouteSlugMethodsKeepUsingRouteSlugLookup(t *testing.T) {
now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
queryer := newLeadRecordQueryer()
- queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil)
+ queryer.row = newFakeRow(int64(20), "crm.lead", "lead", "crm-lead", "Lead", "Sales lead", "contact", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "crm", "CRM", nil, nil)
queryer.rows = append(queryer.rows, newFakeRows([][]any{
{int64(7), "lead-7", now, now, "New"},
}))
@@ -1724,6 +1724,7 @@ func (meta testEntityMeta) row() pgx.Row {
meta.app,
meta.appLabel,
nil, // tree metadata
+ nil, // form metadata
)
}
diff --git a/internal/entity/schema/form.go b/internal/entity/schema/form.go
new file mode 100644
index 0000000..d5a7183
--- /dev/null
+++ b/internal/entity/schema/form.go
@@ -0,0 +1,142 @@
+package schema
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/hapyco/dygo/internal/entity/fieldtype"
+)
+
+const (
+ FormItemKindField = "field"
+ FormItemKindColumn = "column"
+ FormItemKindSection = "section"
+
+ layoutFieldTypeColumn = "column"
+ layoutFieldTypeSection = "section"
+)
+
+// Tab is one authored form tab containing storage fields and layout markers.
+type Tab struct {
+ Line int `yaml:"-"`
+ Label string `yaml:"tab"`
+ Name string `yaml:"name"`
+ Icon string `yaml:"icon,omitempty"`
+ Fields []Field `yaml:"fields"`
+}
+
+// FormLayout is the Studio form presentation tree derived from tabs.
+type FormLayout struct {
+ Tabs []FormTab `json:"tabs"`
+}
+
+// FormTab is one form tab.
+type FormTab struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Icon string `json:"icon,omitempty"`
+ Items []FormItem `json:"items"`
+}
+
+// FormItem is one ordered layout node inside a tab.
+type FormItem struct {
+ Kind string `json:"kind"`
+ Name string `json:"name,omitempty"`
+ Label string `json:"label,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+func isLayoutFieldType(fieldType string) bool {
+ switch strings.TrimSpace(fieldType) {
+ case layoutFieldTypeColumn, layoutFieldTypeSection:
+ return true
+ default:
+ return false
+ }
+}
+
+// normalizeFormLayout flattens authored tabs into Fields and Form.
+func normalizeFormLayout(entity *Entity) error {
+ if entity == nil {
+ return nil
+ }
+ if len(entity.Tabs) == 0 {
+ return nil
+ }
+ if len(entity.Fields) > 0 {
+ return fmt.Errorf("entity cannot define both fields and tabs")
+ }
+
+ seenFields := map[string]struct{}{}
+ seenTabs := map[string]struct{}{}
+ form := FormLayout{Tabs: make([]FormTab, 0, len(entity.Tabs))}
+ fields := make([]Field, 0)
+
+ for _, tab := range entity.Tabs {
+ label := strings.TrimSpace(tab.Label)
+ if label == "" {
+ return fmt.Errorf("%s", withLine(tab.Line, "tab label is required"))
+ }
+ key := strings.TrimSpace(tab.Name)
+ if key == "" {
+ return fmt.Errorf("%s", withLine(tab.Line, fmt.Sprintf("tab %q requires name", label)))
+ }
+ if !fieldtype.IsName(key) {
+ return fmt.Errorf("%s", withLine(tab.Line, fmt.Sprintf("tab name %q must be kebab-case", key)))
+ }
+ if _, exists := seenTabs[key]; exists {
+ return fmt.Errorf("%s", withLine(tab.Line, fmt.Sprintf("duplicate tab name %q", key)))
+ }
+ seenTabs[key] = struct{}{}
+
+ if len(tab.Fields) == 0 {
+ return fmt.Errorf("%s", withLine(tab.Line, fmt.Sprintf("tab %q requires fields", label)))
+ }
+ items := make([]FormItem, 0, len(tab.Fields))
+ for _, field := range tab.Fields {
+ if isLayoutFieldType(field.Type) && (field.Required || field.Unique || field.Index || field.Default.Kind != 0 || field.Check != nil || field.Fetch != nil || fieldtype.NoOptions(field.Options) != nil) {
+ return fmt.Errorf("%s", withLine(field.Line, "layout markers cannot define storage field settings"))
+ }
+ switch strings.TrimSpace(field.Type) {
+ case layoutFieldTypeColumn:
+ items = append(items, FormItem{Kind: FormItemKindColumn})
+ case layoutFieldTypeSection:
+ sectionLabel := strings.TrimSpace(field.Label)
+ if sectionLabel == "" {
+ return fmt.Errorf("%s", withLine(field.Line, fmt.Sprintf("tab %q section requires label", label)))
+ }
+ items = append(items, FormItem{
+ Kind: FormItemKindSection,
+ Name: strings.TrimSpace(field.Name),
+ Label: sectionLabel,
+ Description: strings.TrimSpace(field.Description),
+ })
+ default:
+ name := strings.TrimSpace(field.Name)
+ if name == "" {
+ return fmt.Errorf("%s", withLine(field.Line, fmt.Sprintf("tab %q field is missing name", label)))
+ }
+ if _, exists := seenFields[name]; exists {
+ return fmt.Errorf("%s", withLine(field.Line, fmt.Sprintf("duplicate field %q across tabs", name)))
+ }
+ seenFields[name] = struct{}{}
+ fields = append(fields, field)
+ items = append(items, FormItem{Kind: FormItemKindField, Name: name})
+ }
+ }
+ form.Tabs = append(form.Tabs, FormTab{
+ Key: key,
+ Label: label,
+ Icon: strings.TrimSpace(tab.Icon),
+ Items: items,
+ })
+ }
+
+ if len(fields) == 0 {
+ return fmt.Errorf("tabs must include at least one storage field")
+ }
+ entity.Fields = fields
+ entity.Form = &form
+ entity.Tabs = nil
+ return nil
+}
diff --git a/internal/entity/schema/form_test.go b/internal/entity/schema/form_test.go
new file mode 100644
index 0000000..f42ec93
--- /dev/null
+++ b/internal/entity/schema/form_test.go
@@ -0,0 +1,148 @@
+package schema
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/hapyco/dygo/internal/entity/fieldtype"
+)
+
+func TestNormalizeFormLayoutFlattensTabs(t *testing.T) {
+ entity := Entity{
+ Label: "Currency",
+ Naming: Naming{Strategy: NamingStrategyFormat, Format: "{code}"},
+ Tabs: []Tab{
+ {
+ Label: "Identity",
+ Name: "identity",
+ Icon: "badge-check",
+ Fields: []Field{
+ {Name: "code", Label: "Code", Type: "text", Required: true},
+ {Name: "split", Label: "Split", Type: "column"},
+ {Name: "symbol", Label: "Symbol", Type: "text"},
+ },
+ },
+ {
+ Label: "Formatting",
+ Name: "formatting",
+ Fields: []Field{
+ {Name: "rounding", Label: "Rounding", Type: "section", Description: "Cash rounding"},
+ {Name: "enabled", Label: "Enabled", Type: "boolean"},
+ },
+ },
+ },
+ }
+ if err := normalizeFormLayout(&entity); err != nil {
+ t.Fatalf("normalizeFormLayout() error = %v", err)
+ }
+ if len(entity.Fields) != 3 {
+ t.Fatalf("Fields len = %d, want 3", len(entity.Fields))
+ }
+ if entity.Form == nil || len(entity.Form.Tabs) != 2 {
+ t.Fatalf("Form tabs = %#v, want 2", entity.Form)
+ }
+ if entity.Form.Tabs[0].Key != "identity" || entity.Form.Tabs[0].Icon != "badge-check" {
+ t.Fatalf("Identity tab = %#v", entity.Form.Tabs[0])
+ }
+ if got := entity.Form.Tabs[0].Items; len(got) != 3 || got[1].Kind != FormItemKindColumn {
+ t.Fatalf("Identity items = %#v", got)
+ }
+ if got := entity.Form.Tabs[1].Items; len(got) != 2 || got[0].Kind != FormItemKindSection || got[0].Description != "Cash rounding" {
+ t.Fatalf("Formatting items = %#v", got)
+ }
+ if err := entity.Validate(fieldtype.DefaultRegistry()); err != nil {
+ t.Fatalf("Validate() error = %v", err)
+ }
+}
+
+func TestDecodeTabsEntity(t *testing.T) {
+ const source = `
+label: Currency
+name:
+ strategy: format
+ format: "{code}"
+tabs:
+ - tab: Identity
+ name: identity
+ icon: badge-check
+ fields:
+ - name: code
+ label: Code
+ type: text
+ required: true
+ - name: split
+ label: Split
+ type: column
+ - name: symbol
+ label: Symbol
+ type: text
+`
+ entity, err := Decode([]byte(source), fieldtype.DefaultRegistry())
+ if err != nil {
+ t.Fatalf("Decode() error = %v", err)
+ }
+ if len(entity.Fields) != 2 {
+ t.Fatalf("Fields = %#v, want code and symbol", entity.Fields)
+ }
+ if entity.Form == nil || len(entity.Form.Tabs) != 1 || entity.Form.Tabs[0].Key != "identity" || entity.Form.Tabs[0].Icon != "badge-check" {
+ t.Fatalf("Form = %#v", entity.Form)
+ }
+}
+
+func TestNormalizeFormLayoutRequiresTabName(t *testing.T) {
+ err := normalizeFormLayout(&Entity{
+ Label: "Currency",
+ Tabs: []Tab{{
+ Label: "Identity",
+ Fields: []Field{{Name: "code", Label: "Code", Type: "text"}},
+ }},
+ })
+ if err == nil || !strings.Contains(err.Error(), "requires name") {
+ t.Fatalf("normalizeFormLayout() error = %v, want requires name", err)
+ }
+}
+
+func TestFormLayoutSourceLines(t *testing.T) {
+ source := `label: Contact
+name:
+ strategy: random
+tabs:
+ - tab: Details
+ name: details
+ fields:
+ - name: email
+ label: Email
+ type: email
+ - type: column
+ - type: section
+ label: Notes
+ description: Additional information
+ - name: notes
+ label: Notes
+ type: text
+`
+ entity, err := Decode([]byte(source), fieldtype.DefaultRegistry())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entity.Fields[0].Line != 8 || entity.Fields[1].Line != 15 {
+ t.Fatalf("field source lines = %d, %d", entity.Fields[0].Line, entity.Fields[1].Line)
+ }
+}
+
+func TestDecodeRejectsInvalidFormLayouts(t *testing.T) {
+ const prefix = "label: Contact\nname: {strategy: random}\n"
+ for _, tc := range []struct{ name, body, want string }{
+ {"mixed empty fields", "fields: []\ntabs:\n - tab: Details\n name: details\n fields: [{name: email, label: Email, type: email}]\n", "both fields and tabs"},
+ {"empty tab", "tabs: [{tab: Details, name: details, fields: []}]\n", "requires fields"},
+ {"marker storage setting", "tabs:\n - tab: Details\n name: details\n fields:\n - {type: column, required: true}\n - {name: email, label: Email, type: email}\n", "line 7: layout markers"},
+ {"invalid nested field", "tabs:\n - tab: Details\n name: details\n fields:\n - {name: email, label: Email, type: missing}\n", "line 7:"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := Decode([]byte(prefix+tc.body), fieldtype.DefaultRegistry())
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("error = %v, want %q", err, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/entity/schema/schema.go b/internal/entity/schema/schema.go
index dbecf79..75ecb4a 100644
--- a/internal/entity/schema/schema.go
+++ b/internal/entity/schema/schema.go
@@ -29,7 +29,9 @@ type Entity struct {
Route Route `yaml:"route,omitempty"`
Naming Naming `yaml:"name,omitempty"`
Tree *Tree `yaml:"tree,omitempty"`
- Fields []Field `yaml:"fields"`
+ Fields []Field `yaml:"fields,omitempty"`
+ Tabs []Tab `yaml:"tabs,omitempty"`
+ Form *FormLayout `yaml:"-"`
Indexes []Index `yaml:"indexes,omitempty"`
Constraints []Constraint `yaml:"constraints,omitempty"`
}
@@ -96,17 +98,18 @@ func SupportedConstraintTypes() []string {
// Field describes one field inside an Entity.
type Field struct {
- Line int `yaml:"-"`
- Name string `yaml:"name"`
- Label string `yaml:"label"`
- Type string `yaml:"type"`
- Required bool `yaml:"required,omitempty"`
- Unique bool `yaml:"unique,omitempty"`
- Index bool `yaml:"index,omitempty"`
- Default yaml.Node `yaml:"default,omitempty"`
- Check *Check `yaml:"check,omitempty"`
- Fetch *Fetch `yaml:"fetch,omitempty"`
- Options fieldtype.Options `yaml:"options,omitempty"`
+ Line int `yaml:"-"`
+ Name string `yaml:"name"`
+ Label string `yaml:"label"`
+ Type string `yaml:"type"`
+ Description string `yaml:"description,omitempty"`
+ Required bool `yaml:"required,omitempty"`
+ Unique bool `yaml:"unique,omitempty"`
+ Index bool `yaml:"index,omitempty"`
+ Default yaml.Node `yaml:"default,omitempty"`
+ Check *Check `yaml:"check,omitempty"`
+ Fetch *Fetch `yaml:"fetch,omitempty"`
+ Options fieldtype.Options `yaml:"options,omitempty"`
}
// Check describes one single-field structured value check.
@@ -232,7 +235,13 @@ func DecodeWithOptions(data []byte, registry fieldtype.Registry, options DecodeO
return Entity{}, fmt.Errorf("decode entity schema: %w", err)
}
source.apply(&entity)
+ if source.hasFields && source.hasTabs {
+ return Entity{}, fmt.Errorf("entity cannot define both fields and tabs")
+ }
entity.IsCollection = options.IsCollection
+ if err := normalizeFormLayout(&entity); err != nil {
+ return Entity{}, fmt.Errorf("normalize entity form layout: %w", err)
+ }
if err := entity.Validate(registry); err != nil {
return Entity{}, err
}
@@ -860,6 +869,10 @@ func validateFetch(field Field, problems *[]string) {
type sourceMap struct {
entityLine int
+ hasFields bool
+ hasTabs bool
+ tabLines []int
+ tabFieldLines [][]int
routeLine int
namingLine int
fieldLines []int
@@ -877,6 +890,17 @@ func (m sourceMap) apply(entity *Entity) {
}
entity.Fields[i].Line = m.fieldLines[i]
}
+ for i := range entity.Tabs {
+ if i >= len(m.tabLines) {
+ break
+ }
+ entity.Tabs[i].Line = m.tabLines[i]
+ for j := range entity.Tabs[i].Fields {
+ if j < len(m.tabFieldLines[i]) {
+ entity.Tabs[i].Fields[j].Line = m.tabFieldLines[i][j]
+ }
+ }
+ }
for i := range entity.Indexes {
if i >= len(m.indexLines) {
break
@@ -942,7 +966,25 @@ func buildSourceMap(root *yaml.Node) sourceMap {
}
case "name":
source.namingLine = value.Line
+ case "tabs":
+ source.hasTabs = true
+ if value.Kind != yaml.SequenceNode {
+ continue
+ }
+ for _, tab := range value.Content {
+ source.tabLines = append(source.tabLines, tab.Line)
+ var lines []int
+ for j := 0; j+1 < len(tab.Content); j += 2 {
+ if tab.Content[j].Value == "fields" && tab.Content[j+1].Kind == yaml.SequenceNode {
+ for _, field := range tab.Content[j+1].Content {
+ lines = append(lines, field.Line)
+ }
+ }
+ }
+ source.tabFieldLines = append(source.tabFieldLines, lines)
+ }
case "fields":
+ source.hasFields = true
if value.Kind != yaml.SequenceNode {
continue
}
diff --git a/internal/frameworkapp/bundled/core/entities/entity/entity.entity.yml b/internal/frameworkapp/bundled/core/entities/entity/entity.entity.yml
index 1a86e7f..3cf9b5e 100644
--- a/internal/frameworkapp/bundled/core/entities/entity/entity.entity.yml
+++ b/internal/frameworkapp/bundled/core/entities/entity/entity.entity.yml
@@ -78,6 +78,10 @@ fields:
label: Naming
type: json
+ - name: form
+ label: Form
+ type: json
+
- name: retired
label: Retired
type: boolean
diff --git a/internal/generate/templates/entity.yml.tmpl b/internal/generate/templates/entity.yml.tmpl
index 6e6df03..60b5bfc 100644
--- a/internal/generate/templates/entity.yml.tmpl
+++ b/internal/generate/templates/entity.yml.tmpl
@@ -2,9 +2,12 @@
label: {{ .Label }}
name:
strategy: random
-fields:
- - name: title
- label: Title
- type: text
- required: true
- unique: true
+tabs:
+ - tab: {{ .Label }}
+ name: general
+ fields:
+ - name: title
+ label: Title
+ type: text
+ required: true
+ unique: true
diff --git a/internal/hooks/record_hooks_test.go b/internal/hooks/record_hooks_test.go
index 354f093..b8b9f0d 100644
--- a/internal/hooks/record_hooks_test.go
+++ b/internal/hooks/record_hooks_test.go
@@ -491,7 +491,7 @@ type recordDataMutationQueryer struct {
func newUserRecordDataMutationQueryer(recordRows ...[]any) *recordDataMutationQueryer {
queryer := &recordDataMutationQueryer{
- row: newRecordDataMutationRow(int64(10), "core.user", "user", "user", "User", "User identity", "user", false, false, false, false, "", []byte(`{"strategy":"format","format":"{email}"}`), "core", "Core", nil),
+ row: newRecordDataMutationRow(int64(10), "core.user", "user", "user", "User", "User identity", "user", false, false, false, false, "", []byte(`{"strategy":"format","format":"{email}"}`), "core", "Core", nil, nil),
rows: []pgx.Rows{
newRecordDataMutationRows([][]any{
{int64(101), "email", "Email", "email", true, true, false, nil, nil, nil, 1, nil},
@@ -511,7 +511,7 @@ func newUserRecordDataMutationQueryer(recordRows ...[]any) *recordDataMutationQu
func newLogRecordDataMutationQueryer() *recordDataMutationQueryer {
return &recordDataMutationQueryer{
- row: newRecordDataMutationRow(int64(2), "core.log", "log", "log", "Log", "Diagnostic log", "file-text", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil),
+ row: newRecordDataMutationRow(int64(2), "core.log", "log", "log", "Log", "Diagnostic log", "file-text", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil, nil),
rows: []pgx.Rows{
newRecordDataMutationRows(hookLogFieldRows()),
newRecordDataMutationRows(nil),
@@ -556,10 +556,10 @@ func (q *recordDataMutationQueryer) QueryRow(_ context.Context, sql string, args
q.rowSQL = append(q.rowSQL, sql)
q.rowArgs = append(q.rowArgs, args)
if isHookActivityMetadataQuery(sql, args...) {
- return newRecordDataMutationRow(int64(1), "core.activity", "activity", "activity", "Activity", "Timeline entry", "activity", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil)
+ return newRecordDataMutationRow(int64(1), "core.activity", "activity", "activity", "Activity", "Timeline entry", "activity", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil, nil)
}
if isHookLogMetadataQuery(sql, args...) {
- return newRecordDataMutationRow(int64(2), "core.log", "log", "log", "Log", "Diagnostic log", "file-text", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil)
+ return newRecordDataMutationRow(int64(2), "core.log", "log", "log", "Log", "Diagnostic log", "file-text", false, false, false, false, "", []byte(`{"strategy":"random","length":16}`), "core", "Core", nil, nil)
}
if strings.Contains(sql, `SELECT "id" FROM "app"`) && len(args) == 1 && args[0] == "sales" {
return newRecordDataMutationRow(int64(20))
diff --git a/schemas/entity.schema.json b/schemas/entity.schema.json
index 088b3e2..f0f4310 100644
--- a/schemas/entity.schema.json
+++ b/schemas/entity.schema.json
@@ -5,7 +5,8 @@
"description": "Schema for dygo Entity YAML files. Runtime validation in Go remains authoritative.",
"type": "object",
"additionalProperties": false,
- "required": ["label", "fields"],
+ "required": ["label"],
+ "oneOf": [{ "required": ["fields"] }, { "required": ["tabs"] }],
"properties": {
"tree": {
"type": "object",
@@ -50,6 +51,26 @@
"minItems": 1,
"items": { "$ref": "#/$defs/field" }
},
+ "tabs": {
+ "type": "array",
+ "minItems": 1,
+ "description": "Optional form tabs. When set, storage fields are authored under each tab instead of top-level fields.",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["tab", "name", "fields"],
+ "properties": {
+ "tab": { "type": "string", "minLength": 1 },
+ "name": { "$ref": "#/$defs/kebabName" },
+ "icon": { "$ref": "#/$defs/icon" },
+ "fields": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "#/$defs/formItem" }
+ }
+ }
+ }
+ },
"indexes": {
"type": "array",
"items": { "$ref": "#/$defs/index" }
@@ -60,6 +81,33 @@
}
},
"$defs": {
+ "formItem": {
+ "oneOf": [
+ { "$ref": "#/$defs/field" },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type"],
+ "properties": {
+ "type": { "const": "column" },
+ "name": { "type": "string" },
+ "label": { "type": "string" },
+ "description": { "type": "string" }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "label"],
+ "properties": {
+ "type": { "const": "section" },
+ "name": { "type": "string" },
+ "label": { "type": "string", "minLength": 1 },
+ "description": { "type": "string" }
+ }
+ }
+ ]
+ },
"kebabName": {
"type": "string",
"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
@@ -177,6 +225,9 @@
"minLength": 1
},
"type": { "$ref": "#/$defs/fieldType" },
+ "description": {
+ "type": "string"
+ },
"required": {
"type": "boolean",
"default": false