@svgrid/enterprise 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { EntitySchema } from './schema'
3
+ import {
4
+ addField,
5
+ blankField,
6
+ isSchemaValid,
7
+ moveField,
8
+ removeField,
9
+ sampleRows,
10
+ updateField,
11
+ validateSchema,
12
+ } from './schema-designer'
13
+
14
+ function base(): EntitySchema {
15
+ return {
16
+ name: 'customers',
17
+ idField: 'id',
18
+ fields: [
19
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
20
+ { field: 'name', type: 'text' },
21
+ { field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }] },
22
+ ],
23
+ }
24
+ }
25
+
26
+ describe('blankField / addField', () => {
27
+ it('adds a uniquely named blank field', () => {
28
+ const s = addField(base())
29
+ expect(s.fields).toHaveLength(4)
30
+ const added = s.fields[3]!
31
+ expect(added.type).toBe('text')
32
+ expect(base().fields.map((f) => f.field)).not.toContain(added.field)
33
+ })
34
+
35
+ it('avoids name collisions', () => {
36
+ const s: EntitySchema = { name: 't', fields: [{ field: 'field1', type: 'text' }] }
37
+ expect(blankField(s).field).not.toBe('field1')
38
+ })
39
+ })
40
+
41
+ describe('removeField', () => {
42
+ it('removes and clears idField if it pointed there', () => {
43
+ const s = removeField(base(), 'id')
44
+ expect(s.fields.map((f) => f.field)).toEqual(['name', 'tier'])
45
+ expect(s.idField).toBeUndefined()
46
+ })
47
+ })
48
+
49
+ describe('updateField', () => {
50
+ it('patches a field', () => {
51
+ const s = updateField(base(), 'name', { label: 'Full Name', required: true })
52
+ const f = s.fields.find((x) => x.field === 'name')!
53
+ expect(f.label).toBe('Full Name')
54
+ expect(f.required).toBe(true)
55
+ })
56
+
57
+ it('enforces a single primary key', () => {
58
+ const s = updateField(base(), 'name', { primaryKey: true })
59
+ expect(s.fields.find((f) => f.field === 'id')!.primaryKey).toBe(false)
60
+ expect(s.fields.find((f) => f.field === 'name')!.primaryKey).toBe(true)
61
+ })
62
+
63
+ it('drops options when a field leaves the enum type', () => {
64
+ const s = updateField(base(), 'tier', { type: 'text' })
65
+ expect(s.fields.find((f) => f.field === 'tier')!.options).toBeUndefined()
66
+ })
67
+ })
68
+
69
+ describe('moveField', () => {
70
+ it('reorders and is a no-op at the ends', () => {
71
+ expect(moveField(base(), 'name', -1).fields.map((f) => f.field)).toEqual(['name', 'id', 'tier'])
72
+ expect(moveField(base(), 'id', -1).fields.map((f) => f.field)).toEqual(['id', 'name', 'tier'])
73
+ })
74
+ })
75
+
76
+ describe('validateSchema', () => {
77
+ it('is clean for a well-formed schema', () => {
78
+ expect(validateSchema(base())).toEqual([])
79
+ expect(isSchemaValid(base())).toBe(true)
80
+ })
81
+
82
+ it('errors on duplicate names', () => {
83
+ const s: EntitySchema = {
84
+ name: 't',
85
+ idField: 'id',
86
+ fields: [{ field: 'id', type: 'text' }, { field: 'id', type: 'number' }],
87
+ }
88
+ expect(validateSchema(s).some((i) => i.level === 'error' && /Duplicate/.test(i.message))).toBe(true)
89
+ })
90
+
91
+ it('errors when no primary key resolves', () => {
92
+ const s: EntitySchema = { name: 't', fields: [{ field: 'x', type: 'text' }] }
93
+ expect(isSchemaValid(s)).toBe(false)
94
+ expect(validateSchema(s).some((i) => /primary key/.test(i.message))).toBe(true)
95
+ })
96
+
97
+ it('warns on an enum with no options and an incomplete relation', () => {
98
+ const s: EntitySchema = {
99
+ name: 't',
100
+ idField: 'id',
101
+ fields: [
102
+ { field: 'id', type: 'text', primaryKey: true },
103
+ { field: 'k', type: 'enum' },
104
+ { field: 'r', type: 'relation' },
105
+ ],
106
+ }
107
+ const warnings = validateSchema(s).filter((i) => i.level === 'warning')
108
+ expect(warnings.map((w) => w.field).sort()).toEqual(['k', 'r'])
109
+ })
110
+ })
111
+
112
+ describe('sampleRows', () => {
113
+ it('produces deterministic, type-appropriate sample data', () => {
114
+ const rows = sampleRows(base(), 2)
115
+ expect(rows).toHaveLength(2)
116
+ expect(rows[0]!.tier).toBe('free') // first enum option
117
+ expect(typeof rows[0]!.name).toBe('string')
118
+ // deterministic: same input, same output
119
+ expect(sampleRows(base(), 2)).toEqual(rows)
120
+ })
121
+ })
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Schema-designer logic. The testable core behind `SvSchemaDesigner.svelte`:
3
+ * immutable operations that edit an `EntitySchema` (add / remove / reorder /
4
+ * retype fields, set the primary key), validation of the schema being designed,
5
+ * and sample-row generation for the live grid preview.
6
+ *
7
+ * Pure (no Svelte, no DOM), mirroring the pivot-designer / edit-panel split.
8
+ * Every mutation returns a NEW schema so Svelte reactivity and undo stay simple.
9
+ */
10
+ import type { EntityField, EntityFieldType, EntitySchema } from './schema'
11
+ import { resolveIdField } from './schema'
12
+ import { generateRows } from './studio/sample-data'
13
+
14
+ /** All field types the designer offers, in menu order. */
15
+ export const FIELD_TYPES: ReadonlyArray<EntityFieldType> = [
16
+ 'text',
17
+ 'number',
18
+ 'boolean',
19
+ 'date',
20
+ 'dateString',
21
+ 'datetime',
22
+ 'enum',
23
+ 'relation',
24
+ 'json',
25
+ ]
26
+
27
+ /** A fresh field with a name unique within the schema. */
28
+ export function blankField(schema: EntitySchema): EntityField {
29
+ const taken = new Set(schema.fields.map((f) => f.field))
30
+ let n = schema.fields.length + 1
31
+ let name = `field${n}`
32
+ while (taken.has(name)) name = `field${++n}`
33
+ return { field: name, type: 'text' }
34
+ }
35
+
36
+ /** Append a field (a blank one when none is given). */
37
+ export function addField(schema: EntitySchema, field?: EntityField): EntitySchema {
38
+ return { ...schema, fields: [...schema.fields, field ?? blankField(schema)] }
39
+ }
40
+
41
+ /** Remove a field by name. Clears `idField` if it pointed at the removed field. */
42
+ export function removeField(schema: EntitySchema, fieldName: string): EntitySchema {
43
+ const fields = schema.fields.filter((f) => f.field !== fieldName)
44
+ const idField = schema.idField === fieldName ? undefined : schema.idField
45
+ return { ...schema, fields, idField }
46
+ }
47
+
48
+ /**
49
+ * Patch a field by name. Setting `primaryKey: true` clears it on every other
50
+ * field (a schema has at most one). Switching `type` drops now-irrelevant
51
+ * `options` (leaving enum) / `relation` (leaving relation).
52
+ */
53
+ export function updateField(
54
+ schema: EntitySchema,
55
+ fieldName: string,
56
+ patch: Partial<EntityField>,
57
+ ): EntitySchema {
58
+ const makesPrimary = patch.primaryKey === true
59
+ const fields = schema.fields.map((f) => {
60
+ if (f.field !== fieldName) {
61
+ return makesPrimary && f.primaryKey ? { ...f, primaryKey: false } : f
62
+ }
63
+ let next: EntityField = { ...f, ...patch }
64
+ if (patch.type && patch.type !== f.type) {
65
+ if (patch.type !== 'enum') delete next.options
66
+ if (patch.type !== 'relation') delete next.relation
67
+ }
68
+ return next
69
+ })
70
+ return { ...schema, fields }
71
+ }
72
+
73
+ /** Move a field one slot up (`-1`) or down (`+1`). No-op at the ends. */
74
+ export function moveField(schema: EntitySchema, fieldName: string, dir: -1 | 1): EntitySchema {
75
+ const i = schema.fields.findIndex((f) => f.field === fieldName)
76
+ const j = i + dir
77
+ if (i < 0 || j < 0 || j >= schema.fields.length) return schema
78
+ const fields = [...schema.fields]
79
+ const a = fields[i]!
80
+ const b = fields[j]!
81
+ fields[i] = b
82
+ fields[j] = a
83
+ return { ...schema, fields }
84
+ }
85
+
86
+ export type SchemaIssueLevel = 'error' | 'warning'
87
+ export type SchemaIssue = { level: SchemaIssueLevel; message: string; field?: string }
88
+
89
+ /**
90
+ * Validate the schema under construction. Errors block code generation;
91
+ * warnings are advisory. Covers: at least one field, unique field names, a
92
+ * resolvable primary key, enums with options, and complete relations.
93
+ */
94
+ export function validateSchema(schema: EntitySchema): SchemaIssue[] {
95
+ const issues: SchemaIssue[] = []
96
+
97
+ if (schema.fields.length === 0) {
98
+ issues.push({ level: 'error', message: 'Add at least one field.' })
99
+ }
100
+
101
+ const seen = new Set<string>()
102
+ for (const f of schema.fields) {
103
+ if (!f.field.trim()) {
104
+ issues.push({ level: 'error', message: 'A field has an empty name.' })
105
+ } else if (seen.has(f.field)) {
106
+ issues.push({ level: 'error', message: `Duplicate field name "${f.field}".`, field: f.field })
107
+ }
108
+ seen.add(f.field)
109
+
110
+ if (f.type === 'enum' && (!f.options || f.options.length === 0)) {
111
+ issues.push({ level: 'warning', message: `Enum "${f.field}" has no options.`, field: f.field })
112
+ }
113
+ if (f.type === 'relation' && (!f.relation?.entity || !f.relation?.labelField)) {
114
+ issues.push({
115
+ level: 'warning',
116
+ message: `Relation "${f.field}" needs a related entity and label field.`,
117
+ field: f.field,
118
+ })
119
+ }
120
+ }
121
+
122
+ try {
123
+ resolveIdField(schema)
124
+ } catch {
125
+ issues.push({
126
+ level: 'error',
127
+ message: 'No primary key: flag a field as primary key, set idField, or name a field "id".',
128
+ })
129
+ }
130
+
131
+ return issues
132
+ }
133
+
134
+ /** True when the schema has no error-level issues (warnings are allowed). */
135
+ export function isSchemaValid(schema: EntitySchema): boolean {
136
+ return !validateSchema(schema).some((i) => i.level === 'error')
137
+ }
138
+
139
+ /** Deterministic, realistic sample rows for the designer's grid preview. */
140
+ export function sampleRows(schema: EntitySchema, count = 3): Array<Record<string, unknown>> {
141
+ return generateRows(schema.fields, count)
142
+ }
@@ -0,0 +1,268 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ applyComputed,
4
+ resolveIdField,
5
+ schemaToColumns,
6
+ schemaToFormFields,
7
+ titleCase,
8
+ validateEntity,
9
+ validateField,
10
+ type EntitySchema,
11
+ type StandardSchemaV1,
12
+ } from './schema'
13
+
14
+ /** Index a list by `field`, asserting presence (keeps tests strict-null clean). */
15
+ function byField<T extends { field?: string }>(arr: readonly T[]): (f: string) => T {
16
+ const m = new Map(arr.map((x) => [x.field, x]))
17
+ return (f) => {
18
+ const v = m.get(f)
19
+ if (!v) throw new Error(`no field "${f}"`)
20
+ return v
21
+ }
22
+ }
23
+
24
+ type Customer = {
25
+ id: string
26
+ firstName: string
27
+ age: number
28
+ active: boolean
29
+ tier: string
30
+ secret: string
31
+ companyId: string
32
+ }
33
+
34
+ const schema: EntitySchema<Customer> = {
35
+ name: 'customers',
36
+ fields: [
37
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
38
+ { field: 'firstName', type: 'text', required: true },
39
+ { field: 'age', type: 'number' },
40
+ { field: 'active', type: 'boolean' },
41
+ { field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
42
+ { field: 'secret', type: 'text', hidden: { grid: true } },
43
+ { field: 'companyId', type: 'relation', relation: { entity: 'companies', labelField: 'name' } },
44
+ ],
45
+ }
46
+
47
+ describe('titleCase', () => {
48
+ it('humanizes snake, kebab, and camel case', () => {
49
+ expect(titleCase('first_name')).toBe('First Name')
50
+ expect(titleCase('first-name')).toBe('First Name')
51
+ expect(titleCase('firstName')).toBe('First Name')
52
+ expect(titleCase('companyId')).toBe('Company Id')
53
+ })
54
+ })
55
+
56
+ describe('resolveIdField', () => {
57
+ it('prefers explicit idField', () => {
58
+ expect(resolveIdField({ ...schema, idField: 'firstName' })).toBe('firstName')
59
+ })
60
+ it('falls back to the single primaryKey flag', () => {
61
+ expect(resolveIdField(schema)).toBe('id')
62
+ })
63
+ it('falls back to a field named id', () => {
64
+ const s: EntitySchema = { name: 't', fields: [{ field: 'id', type: 'text' }, { field: 'x', type: 'text' }] }
65
+ expect(resolveIdField(s)).toBe('id')
66
+ })
67
+ it('throws on multiple primary keys', () => {
68
+ const s: EntitySchema = {
69
+ name: 't',
70
+ fields: [{ field: 'a', type: 'text', primaryKey: true }, { field: 'b', type: 'text', primaryKey: true }],
71
+ }
72
+ expect(() => resolveIdField(s)).toThrow(/multiple fields set primaryKey/)
73
+ })
74
+ it('throws when no key resolves', () => {
75
+ const s: EntitySchema = { name: 't', fields: [{ field: 'x', type: 'text' }] }
76
+ expect(() => resolveIdField(s)).toThrow(/no primary key/)
77
+ })
78
+ })
79
+
80
+ describe('schemaToColumns', () => {
81
+ const cols = schemaToColumns(schema)
82
+ const col = byField(cols)
83
+
84
+ it('drops grid-hidden fields', () => {
85
+ expect(cols.find((c) => c.field === 'secret')).toBeUndefined()
86
+ expect(cols).toHaveLength(6)
87
+ })
88
+
89
+ it('maps types to cellDataType / editorType', () => {
90
+ expect(col('age').cellDataType).toBe('number')
91
+ expect(col('active').cellDataType).toBe('boolean')
92
+ expect(col('age').editorType).toBe('number')
93
+ expect(col('tier').editorType).toBe('select')
94
+ })
95
+
96
+ it('marks readonly fields non-editable and carries enum options through', () => {
97
+ expect(col('id').editable).toBe(false)
98
+ expect(col('firstName').editable).toBe(true)
99
+ expect(col('tier').editorOptions).toEqual([
100
+ { value: 'free', label: 'Free' },
101
+ { value: 'pro', label: 'Pro' },
102
+ ])
103
+ })
104
+
105
+ it('derives a title-cased header from the field', () => {
106
+ expect(col('firstName').header).toBe('First Name')
107
+ })
108
+
109
+ it('merges the column escape hatch last (explicit wins)', () => {
110
+ const s: EntitySchema<Customer> = {
111
+ name: 'c',
112
+ idField: 'id',
113
+ fields: [{ field: 'age', type: 'number', column: { header: 'Years', editable: false } }],
114
+ }
115
+ const only = schemaToColumns(s)[0]
116
+ expect(only?.header).toBe('Years')
117
+ expect(only?.editable).toBe(false)
118
+ })
119
+ })
120
+
121
+ describe('schemaToFormFields', () => {
122
+ const fields = schemaToFormFields(schema)
123
+ const ff = byField(fields)
124
+
125
+ it('keeps fields that are only hidden from the grid', () => {
126
+ // `secret` is hidden:{grid:true} so it stays in the form
127
+ expect(fields.find((f) => f.field === 'secret')).toBeDefined()
128
+ })
129
+
130
+ it('forces the primary key read-only and non-required', () => {
131
+ expect(ff('id').readonly).toBe(true)
132
+ expect(ff('id').required).toBe(false)
133
+ })
134
+
135
+ it('carries required, options, and relation through', () => {
136
+ expect(ff('firstName').required).toBe(true)
137
+ expect(ff('tier').options).toHaveLength(2)
138
+ expect(ff('companyId').relation).toEqual({ entity: 'companies', labelField: 'name' })
139
+ })
140
+
141
+ it('carries a form-only editor override (phone/mask) into the descriptor', () => {
142
+ const s: EntitySchema = {
143
+ name: 'm', idField: 'id',
144
+ fields: [
145
+ { field: 'id', type: 'text', primaryKey: true },
146
+ { field: 'phone', type: 'text', input: { editorType: 'phone' } },
147
+ { field: 'code', type: 'text', input: { editorType: 'mask', mask: '(999) 000-0000' } },
148
+ { field: 'score', type: 'number', input: { editorType: 'slider' } },
149
+ ],
150
+ }
151
+ const f = byField(schemaToFormFields(s))
152
+ expect(f('phone').editorType).toBe('phone')
153
+ expect(f('code').editorType).toBe('mask')
154
+ expect(f('code').mask).toBe('(999) 000-0000')
155
+ expect(f('score').editorType).toBe('slider')
156
+ })
157
+
158
+ it('degrades form-only editors to a safe grid cell editor (columns stay valid)', () => {
159
+ const s: EntitySchema = {
160
+ name: 'm', idField: 'id',
161
+ fields: [
162
+ { field: 'id', type: 'text', primaryKey: true },
163
+ { field: 'phone', type: 'text', input: { editorType: 'phone' } },
164
+ { field: 'score', type: 'number', input: { editorType: 'slider' } },
165
+ ],
166
+ }
167
+ const col = (name: string) => schemaToColumns(s).find((c) => c.field === name)!
168
+ expect(col('phone').editorType).toBe('text') // phone/country/mask -> text in the grid
169
+ expect(col('score').editorType).toBe('number') // slider -> number in the grid
170
+ })
171
+
172
+ it('threads an upload config through to the form descriptor', () => {
173
+ const s: EntitySchema = {
174
+ name: 'm', idField: 'id',
175
+ fields: [
176
+ { field: 'id', type: 'text', primaryKey: true },
177
+ { field: 'avatar', type: 'text', upload: { image: true, accept: 'image/*' } },
178
+ ],
179
+ }
180
+ expect(byField(schemaToFormFields(s))('avatar').upload).toEqual({ image: true, accept: 'image/*' })
181
+ })
182
+ })
183
+
184
+ describe('validateField', () => {
185
+ // Hand-rolled Standard Schema validator (no Zod dependency in the test).
186
+ const nonEmpty: StandardSchemaV1<string, string> = {
187
+ '~standard': {
188
+ version: 1,
189
+ vendor: 'test',
190
+ validate: (v) =>
191
+ typeof v === 'string' && v.length > 0
192
+ ? { value: v }
193
+ : { issues: [{ message: 'Required' }] },
194
+ },
195
+ }
196
+
197
+ it('returns null when there is no validator', async () => {
198
+ expect(await validateField({}, 'anything')).toBeNull()
199
+ })
200
+ it('returns null on a valid value', async () => {
201
+ expect(await validateField({ validate: nonEmpty }, 'ok')).toBeNull()
202
+ })
203
+ it('returns the first issue message on an invalid value', async () => {
204
+ expect(await validateField({ validate: nonEmpty }, '')).toBe('Required')
205
+ })
206
+ })
207
+
208
+ describe('computed fields', () => {
209
+ type Line = { id: string; qty: number; price: number; total: number }
210
+ const lineSchema: EntitySchema<Line> = {
211
+ name: 'lines',
212
+ fields: [
213
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
214
+ { field: 'qty', type: 'number', required: true },
215
+ { field: 'price', type: 'number', required: true },
216
+ { field: 'total', type: 'number', computed: (r) => Number(r.qty) * Number(r.price) },
217
+ ],
218
+ }
219
+
220
+ it('materializes computed values onto a row', () => {
221
+ const out = applyComputed(lineSchema, { id: '1', qty: 3, price: 10, total: 0 })
222
+ expect(out.total).toBe(30)
223
+ })
224
+
225
+ it('returns the same row object when there are no computed fields', () => {
226
+ const row = { id: '1', qty: 3, price: 10, total: 0 }
227
+ expect(applyComputed({ ...lineSchema, fields: lineSchema.fields.slice(0, 3) }, row)).toBe(row)
228
+ })
229
+
230
+ it('renders a computed field read-only in the grid and form', () => {
231
+ const col = schemaToColumns(lineSchema).find((c) => c.field === 'total')!
232
+ expect(col.editable).toBe(false)
233
+ const ff = schemaToFormFields(lineSchema).find((f) => f.field === 'total')!
234
+ expect(ff.readonly).toBe(true)
235
+ expect(typeof ff.computed).toBe('function')
236
+ })
237
+
238
+ it('wires a computed field to the grid value accessor (fieldFn), so it sorts/filters client-side', () => {
239
+ const col = schemaToColumns(lineSchema).find((c) => c.field === 'total')!
240
+ expect(typeof col.fieldFn).toBe('function')
241
+ expect(col.fieldFn!({ id: '1', qty: 4, price: 25, total: 0 })).toBe(100)
242
+ })
243
+ })
244
+
245
+ describe('validateEntity (schema-level cross-field)', () => {
246
+ type Range = { id: string; start: number; end: number }
247
+ const rangeSchema: EntitySchema<Range> = {
248
+ name: 'ranges',
249
+ fields: [
250
+ { field: 'id', type: 'text', primaryKey: true },
251
+ { field: 'start', type: 'number' },
252
+ { field: 'end', type: 'number' },
253
+ ],
254
+ hooks: {
255
+ validate: (v) => (Number(v.end) <= Number(v.start) ? { end: 'End must be after start' } : null),
256
+ },
257
+ }
258
+
259
+ it('returns {} when there is no validate hook', async () => {
260
+ expect(await validateEntity({ ...rangeSchema, hooks: undefined }, {})).toEqual({})
261
+ })
262
+ it('returns errors from the hook', async () => {
263
+ expect(await validateEntity(rangeSchema, { start: 5, end: 2 })).toEqual({ end: 'End must be after start' })
264
+ })
265
+ it('returns {} when valid', async () => {
266
+ expect(await validateEntity(rangeSchema, { start: 1, end: 9 })).toEqual({})
267
+ })
268
+ })