From f1b352a212641b00916ae993b32e36ce7b5a8a72 Mon Sep 17 00:00:00 2001 From: Saad Ali Khan Date: Fri, 18 Sep 2026 20:21:20 +0500 Subject: [PATCH 1/6] feat(studio): add Entity form tabs layout metadata Allow Entity YAML to author form tabs with column/section markers, persist the layout on entity.form, and render multi-tab forms in Studio while keeping single-tab and flat Entities backward compatible. Co-authored-by: Cursor --- .../entities/currency/currency.entity.yml | 83 +-- apps/core/entities/entity/entity.entity.yml | 4 + .../ui/src/features/metadata/metadata.api.ts | 19 + apps/studio/ui/src/pages/RecordFormPage.vue | 1 + .../renderers/records/RecordFormRenderer.vue | 515 +++++++++++++----- internal/db/metadata_reader.go | 17 +- internal/db/metadata_reader_test.go | 8 +- internal/db/metadata_records.go | 16 +- internal/db/patch_apply_test.go | 2 +- internal/db/patch_ledger_test.go | 2 +- internal/db/records_test.go | 7 +- internal/entity/schema/form.go | 136 +++++ internal/entity/schema/form_test.go | 103 ++++ internal/entity/schema/schema.go | 30 +- .../core/entities/entity/entity.entity.yml | 4 + internal/generate/templates/entity.yml.tmpl | 15 +- schemas/entity.schema.json | 30 +- 17 files changed, 779 insertions(+), 213 deletions(-) create mode 100644 internal/entity/schema/form.go create mode 100644 internal/entity/schema/form_test.go diff --git a/apps/core/entities/currency/currency.entity.yml b/apps/core/entities/currency/currency.entity.yml index b20b7b9..c671dfd 100644 --- a/apps/core/entities/currency/currency.entity.yml +++ b/apps/core/entities/currency/currency.entity.yml @@ -4,36 +4,53 @@ icon: circle-dollar-sign name: strategy: format format: "{code}" -fields: - - name: code - label: Code - type: text - required: true - unique: true - - - name: numeric-code - label: Numeric Code - type: text - - - name: display-name - label: Display Name - type: text - - - name: symbol - label: Symbol - type: text - - - name: minor-unit-digits - label: Minor Unit Digits - type: int - default: 2 - - - name: cash-rounding-increment - label: Cash Rounding Increment - type: decimal - - - name: enabled - label: Enabled - type: boolean - index: true - default: true +tabs: + - tab: Identity + name: identity + icon: shield-check + fields: + - name: code + label: Code + type: text + required: true + unique: true + + - name: split + label: Split + type: column + + - name: display-name + label: Display Name + type: text + + - name: symbol + label: Symbol + type: text + + - tab: Formatting + name: formatting + icon: settings-2 + fields: + - name: numeric-code + label: Numeric Code + type: text + + - name: minor-unit-digits + label: Minor Unit Digits + type: int + default: 2 + + - name: rounding + label: Rounding + type: section + description: Cash rounding defaults for this currency. + + - name: cash-rounding-increment + label: Cash Rounding Increment + type: decimal + + - name: enabled + label: Enabled + type: boolean + index: true + default: true 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/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..b3b6798 100644 --- a/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue +++ b/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue @@ -1,5 +1,5 @@ @@ -250,7 +382,100 @@ function isReadonlyField(field: MetadataField): boolean { .record-form-renderer { display: grid; width: min(100%, 680px); - gap: 14px; + gap: 16px; padding: 16px 0 24px; } + +.record-form-renderer--wide { + width: min(100%, 960px); +} + +.record-form-renderer__tabs { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + border-bottom: 1px solid var(--studio-border); +} + +.record-form-renderer__tab { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + border: 0; + border-bottom: 2px solid transparent; + appearance: none; + background: transparent; + color: var(--studio-text-muted); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1.2; + padding: 8px 2px 10px; +} + +.record-form-renderer__tab-icon { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.record-form-renderer__tab:hover { + color: var(--studio-text); +} + +.record-form-renderer__tab--active { + border-bottom-color: var(--studio-text); + color: var(--studio-text); + font-weight: 700; +} + +.record-form-renderer__tab--error { + color: var(--studio-danger, #b42318); +} + +.record-form-renderer__panel { + min-width: 0; +} + +.record-form-renderer__columns { + display: grid; + grid-template-columns: repeat(var(--form-columns, 1), minmax(0, 1fr)); + gap: 16px 24px; +} + +.record-form-renderer__column { + display: grid; + gap: 14px; + align-content: start; + min-width: 0; +} + +.record-form-renderer__section { + display: grid; + gap: 4px; + padding-top: 4px; +} + +.record-form-renderer__section-title { + margin: 0; + color: var(--studio-text); + font-size: 14px; + font-weight: 700; + line-height: 1.3; +} + +.record-form-renderer__section-description { + margin: 0; + color: var(--studio-text-muted); + font-size: 12px; + line-height: 1.4; +} + +@media (max-width: 720px) { + .record-form-renderer__columns { + grid-template-columns: 1fr; + } +} 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..65109e2 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, nil), rows: []pgx.Rows{ newFakeRows([][]any{ {int64(1), "email", "Email", "email", true, true, true, nil, nil, []byte(`{"from":"profile.email"}`), 1, []byte(`{"entity":"user"}`)}, @@ -124,9 +124,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 +161,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 0fea273..d334732 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 { @@ -186,8 +187,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, @@ -203,9 +204,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 @@ -549,6 +551,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, @@ -564,6 +573,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/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..8f6203a --- /dev/null +++ b/internal/entity/schema/form.go @@ -0,0 +1,136 @@ +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("tab label is required") + } + key := strings.TrimSpace(tab.Name) + if key == "" { + return fmt.Errorf("tab %q requires name", label) + } + if !fieldtype.IsName(key) { + return fmt.Errorf("tab name %q must be kebab-case", key) + } + if _, exists := seenTabs[key]; exists { + return fmt.Errorf("duplicate tab name %q", key) + } + seenTabs[key] = struct{}{} + + items := make([]FormItem, 0, len(tab.Fields)) + for _, field := range tab.Fields { + 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("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("tab %q field is missing name", label) + } + if _, exists := seenFields[name]; exists { + return fmt.Errorf("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..5ac22ac --- /dev/null +++ b/internal/entity/schema/form_test.go @@ -0,0 +1,103 @@ +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) + } +} diff --git a/internal/entity/schema/schema.go b/internal/entity/schema/schema.go index dbecf79..e0d592b 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. @@ -233,6 +236,9 @@ func DecodeWithOptions(data []byte, registry fieldtype.Registry, options DecodeO } source.apply(&entity) 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 } 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/schemas/entity.schema.json b/schemas/entity.schema.json index 088b3e2..abf7f2e 100644 --- a/schemas/entity.schema.json +++ b/schemas/entity.schema.json @@ -5,7 +5,7 @@ "description": "Schema for dygo Entity YAML files. Runtime validation in Go remains authoritative.", "type": "object", "additionalProperties": false, - "required": ["label", "fields"], + "required": ["label"], "properties": { "tree": { "type": "object", @@ -50,6 +50,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/field" } + } + } + } + }, "indexes": { "type": "array", "items": { "$ref": "#/$defs/index" } @@ -91,7 +111,9 @@ "link", "collection", "attachment", - "json" + "json", + "column", + "section" ] }, "route": { @@ -177,6 +199,10 @@ "minLength": 1 }, "type": { "$ref": "#/$defs/fieldType" }, + "description": { + "type": "string", + "description": "Optional helper text. Used by form section layout markers." + }, "required": { "type": "boolean", "default": false From e686f681b6e245a29cb6d8c834583f57fe383545 Mon Sep 17 00:00:00 2001 From: Saad Ali Khan Date: Fri, 18 Sep 2026 20:21:46 +0500 Subject: [PATCH 2/6] fix(core): sync bundled currency entity tabs layout Keep framework-bundled currency metadata aligned with the app-owned tabs dogfood Entity used by Studio form rendering. Co-authored-by: Cursor --- .../entities/currency/currency.entity.yml | 83 +++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml b/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml index b20b7b9..c671dfd 100644 --- a/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml +++ b/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml @@ -4,36 +4,53 @@ icon: circle-dollar-sign name: strategy: format format: "{code}" -fields: - - name: code - label: Code - type: text - required: true - unique: true - - - name: numeric-code - label: Numeric Code - type: text - - - name: display-name - label: Display Name - type: text - - - name: symbol - label: Symbol - type: text - - - name: minor-unit-digits - label: Minor Unit Digits - type: int - default: 2 - - - name: cash-rounding-increment - label: Cash Rounding Increment - type: decimal - - - name: enabled - label: Enabled - type: boolean - index: true - default: true +tabs: + - tab: Identity + name: identity + icon: shield-check + fields: + - name: code + label: Code + type: text + required: true + unique: true + + - name: split + label: Split + type: column + + - name: display-name + label: Display Name + type: text + + - name: symbol + label: Symbol + type: text + + - tab: Formatting + name: formatting + icon: settings-2 + fields: + - name: numeric-code + label: Numeric Code + type: text + + - name: minor-unit-digits + label: Minor Unit Digits + type: int + default: 2 + + - name: rounding + label: Rounding + type: section + description: Cash rounding defaults for this currency. + + - name: cash-rounding-increment + label: Cash Rounding Increment + type: decimal + + - name: enabled + label: Enabled + type: boolean + index: true + default: true From 050c8b5777801184c07b8f16669afbe3f73b68b1 Mon Sep 17 00:00:00 2001 From: Saad Ali Khan Date: Fri, 18 Sep 2026 20:22:30 +0500 Subject: [PATCH 3/6] revert(core): restore flat currency entity after form-tabs dogfood Remove the temporary multi-tab Currency layout used only to exercise Studio form tabs; keep the shared form-tabs feature intact. Co-authored-by: Cursor --- .../entities/currency/currency.entity.yml | 83 ++++++++----------- .../entities/currency/currency.entity.yml | 83 ++++++++----------- 2 files changed, 66 insertions(+), 100 deletions(-) diff --git a/apps/core/entities/currency/currency.entity.yml b/apps/core/entities/currency/currency.entity.yml index c671dfd..b20b7b9 100644 --- a/apps/core/entities/currency/currency.entity.yml +++ b/apps/core/entities/currency/currency.entity.yml @@ -4,53 +4,36 @@ icon: circle-dollar-sign name: strategy: format format: "{code}" -tabs: - - tab: Identity - name: identity - icon: shield-check - fields: - - name: code - label: Code - type: text - required: true - unique: true - - - name: split - label: Split - type: column - - - name: display-name - label: Display Name - type: text - - - name: symbol - label: Symbol - type: text - - - tab: Formatting - name: formatting - icon: settings-2 - fields: - - name: numeric-code - label: Numeric Code - type: text - - - name: minor-unit-digits - label: Minor Unit Digits - type: int - default: 2 - - - name: rounding - label: Rounding - type: section - description: Cash rounding defaults for this currency. - - - name: cash-rounding-increment - label: Cash Rounding Increment - type: decimal - - - name: enabled - label: Enabled - type: boolean - index: true - default: true +fields: + - name: code + label: Code + type: text + required: true + unique: true + + - name: numeric-code + label: Numeric Code + type: text + + - name: display-name + label: Display Name + type: text + + - name: symbol + label: Symbol + type: text + + - name: minor-unit-digits + label: Minor Unit Digits + type: int + default: 2 + + - name: cash-rounding-increment + label: Cash Rounding Increment + type: decimal + + - name: enabled + label: Enabled + type: boolean + index: true + default: true diff --git a/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml b/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml index c671dfd..b20b7b9 100644 --- a/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml +++ b/internal/frameworkapp/bundled/core/entities/currency/currency.entity.yml @@ -4,53 +4,36 @@ icon: circle-dollar-sign name: strategy: format format: "{code}" -tabs: - - tab: Identity - name: identity - icon: shield-check - fields: - - name: code - label: Code - type: text - required: true - unique: true - - - name: split - label: Split - type: column - - - name: display-name - label: Display Name - type: text - - - name: symbol - label: Symbol - type: text - - - tab: Formatting - name: formatting - icon: settings-2 - fields: - - name: numeric-code - label: Numeric Code - type: text - - - name: minor-unit-digits - label: Minor Unit Digits - type: int - default: 2 - - - name: rounding - label: Rounding - type: section - description: Cash rounding defaults for this currency. - - - name: cash-rounding-increment - label: Cash Rounding Increment - type: decimal - - - name: enabled - label: Enabled - type: boolean - index: true - default: true +fields: + - name: code + label: Code + type: text + required: true + unique: true + + - name: numeric-code + label: Numeric Code + type: text + + - name: display-name + label: Display Name + type: text + + - name: symbol + label: Symbol + type: text + + - name: minor-unit-digits + label: Minor Unit Digits + type: int + default: 2 + + - name: cash-rounding-increment + label: Cash Rounding Increment + type: decimal + + - name: enabled + label: Enabled + type: boolean + index: true + default: true From 3c0d7e7a378c6874d97f3c4fd83bf7d76cc93678 Mon Sep 17 00:00:00 2001 From: Saad Ali Khan Date: Fri, 18 Sep 2026 20:27:17 +0500 Subject: [PATCH 4/6] fix: align form metadata test fakes and entity schema enums Keep layout markers out of storage fieldType and pad hooks entity rows for the form column so CI matches the new scan shape. Co-authored-by: Cursor --- internal/hooks/record_hooks_test.go | 8 +++---- schemas/entity.schema.json | 36 ++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) 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 abf7f2e..cc99163 100644 --- a/schemas/entity.schema.json +++ b/schemas/entity.schema.json @@ -65,7 +65,7 @@ "fields": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/field" } + "items": { "$ref": "#/$defs/tabField" } } } } @@ -111,9 +111,7 @@ "link", "collection", "attachment", - "json", - "column", - "section" + "json" ] }, "route": { @@ -200,8 +198,7 @@ }, "type": { "$ref": "#/$defs/fieldType" }, "description": { - "type": "string", - "description": "Optional helper text. Used by form section layout markers." + "type": "string" }, "required": { "type": "boolean", @@ -278,6 +275,33 @@ } ] }, + "tabField": { + "oneOf": [ + { "$ref": "#/$defs/field" }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "label", "type"], + "properties": { + "name": { "$ref": "#/$defs/kebabName" }, + "label": { "type": "string", "minLength": 1 }, + "type": { "const": "column" }, + "description": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "label", "type"], + "properties": { + "name": { "$ref": "#/$defs/kebabName" }, + "label": { "type": "string", "minLength": 1 }, + "type": { "const": "section" }, + "description": { "type": "string" } + } + } + ] + }, "fetch": { "type": "object", "additionalProperties": false, From 2c77be025eec720de8ad2706b0189611431d7555 Mon Sep 17 00:00:00 2001 From: Saad Ali Khan Date: Fri, 18 Sep 2026 20:28:08 +0500 Subject: [PATCH 5/6] fix: drop invented tabField schema def Point tab fields back at the shared field def; layout markers stay runtime-validated only so fieldType stays aligned with the storage registry. Co-authored-by: Cursor --- schemas/entity.schema.json | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/schemas/entity.schema.json b/schemas/entity.schema.json index cc99163..ae79f8d 100644 --- a/schemas/entity.schema.json +++ b/schemas/entity.schema.json @@ -65,7 +65,7 @@ "fields": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/tabField" } + "items": { "$ref": "#/$defs/field" } } } } @@ -275,33 +275,6 @@ } ] }, - "tabField": { - "oneOf": [ - { "$ref": "#/$defs/field" }, - { - "type": "object", - "additionalProperties": false, - "required": ["name", "label", "type"], - "properties": { - "name": { "$ref": "#/$defs/kebabName" }, - "label": { "type": "string", "minLength": 1 }, - "type": { "const": "column" }, - "description": { "type": "string" } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["name", "label", "type"], - "properties": { - "name": { "$ref": "#/$defs/kebabName" }, - "label": { "type": "string", "minLength": 1 }, - "type": { "const": "section" }, - "description": { "type": "string" } - } - } - ] - }, "fetch": { "type": "object", "additionalProperties": false, From 54b58c0a544753640a263b4faf847f723e764183 Mon Sep 17 00:00:00 2001 From: "Tahseen K. Kakar" Date: Tue, 22 Sep 2026 13:34:44 +0500 Subject: [PATCH 6/6] fix: preserve form fields and validate tab layouts --- apps/studio/ui/src/design/index.ts | 2 + .../renderers/records/RecordFormRenderer.vue | 57 ++++++++----------- .../records/record-form-layout.test.ts | 27 +++++++++ .../renderers/records/record-form-layout.ts | 28 +++++++++ docs/entity-metadata.md | 31 ++++++++++ internal/db/metadata_reader_test.go | 9 ++- internal/db/metadata_records_test.go | 43 ++++++++++++++ internal/entity/schema/form.go | 20 ++++--- internal/entity/schema/form_test.go | 45 +++++++++++++++ internal/entity/schema/schema.go | 36 ++++++++++++ schemas/entity.schema.json | 30 +++++++++- 11 files changed, 285 insertions(+), 43 deletions(-) create mode 100644 apps/studio/ui/src/renderers/records/record-form-layout.test.ts create mode 100644 apps/studio/ui/src/renderers/records/record-form-layout.ts 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/renderers/records/RecordFormRenderer.vue b/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue index b3b6798..d6738fa 100644 --- a/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue +++ b/apps/studio/ui/src/renderers/records/RecordFormRenderer.vue @@ -3,12 +3,16 @@ import { computed, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, PasswordField, SelectField, SwitchField, TextareaField, TextField, -} from '@/design' +} from '@dygo/ui' import { linkOptions, type MetadataEntityMeta, @@ -21,6 +25,7 @@ import { useMetadataEntitiesQuery } from '@/features/metadata/metadata.query' import { iconForEntity } from '@/features/metadata/entity-icons' import { uploadRecordFile, type RecordData } from '@/features/records/records.api' import { isHiddenRecordFormField, recordFieldLabel } from '@/features/records/system-fields' +import { resolveFormLayout } from './record-form-layout' import SecretEditor from './SecretEditor.vue' import type { SecretStatus } from '@/features/records/records.api' import RecordCollectionTable from './RecordCollectionTable.vue' @@ -69,21 +74,9 @@ const fieldsByName = computed(() => { return map }) -const formLayout = computed(() => { - const tabs = props.form?.tabs?.filter((tab) => tab.items?.length) ?? [] - if (tabs.length > 0) { - return { tabs } - } - return { - tabs: [{ - key: 'default', - label: props.entityLabel, - items: visibleFields.value.map((field) => ({ kind: 'field' as const, name: field.name })), - }], - } -}) - -const showTabStrip = computed(() => formLayout.value.tabs.length > 1) +const layout = computed(() => resolveFormLayout(props.form, visibleFields.value, props.entityLabel)) +const formLayout = computed(() => layout.value.form) +const showTabStrip = computed(() => layout.value.showTabStrip) watch( formLayout, @@ -165,31 +158,27 @@ function isReadonlyField(field: MetadataField): boolean {