@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- 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
|
+
})
|