@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,213 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema, StandardSchemaV1 } from './schema'
|
|
3
|
+
import {
|
|
4
|
+
buildInitialValues,
|
|
5
|
+
controlKind,
|
|
6
|
+
editMode,
|
|
7
|
+
rowId,
|
|
8
|
+
toSubmitValues,
|
|
9
|
+
validateAll,
|
|
10
|
+
toDateString,
|
|
11
|
+
toDateTimeString,
|
|
12
|
+
toNumberValue,
|
|
13
|
+
fromNumberValue,
|
|
14
|
+
toSliderValue,
|
|
15
|
+
toTags,
|
|
16
|
+
} from './edit-panel'
|
|
17
|
+
|
|
18
|
+
type Customer = {
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
age: number
|
|
22
|
+
active: boolean
|
|
23
|
+
tier: string
|
|
24
|
+
bio: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const email: StandardSchemaV1<string, string> = {
|
|
28
|
+
'~standard': {
|
|
29
|
+
version: 1,
|
|
30
|
+
vendor: 'test',
|
|
31
|
+
validate: (v) =>
|
|
32
|
+
typeof v === 'string' && v.includes('@') ? { value: v } : { issues: [{ message: 'Invalid email' }] },
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const schema: EntitySchema<Customer> = {
|
|
37
|
+
name: 'customers',
|
|
38
|
+
label: 'Customer',
|
|
39
|
+
fields: [
|
|
40
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
41
|
+
{ field: 'name', type: 'text', required: true, validate: email },
|
|
42
|
+
{ field: 'age', type: 'number', defaultValue: 18 },
|
|
43
|
+
{ field: 'active', type: 'boolean' },
|
|
44
|
+
{ field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
|
|
45
|
+
{ field: 'bio', type: 'json', input: { span: 2 } },
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('editMode', () => {
|
|
50
|
+
it('is create without a row, edit with one', () => {
|
|
51
|
+
expect(editMode(null)).toBe('create')
|
|
52
|
+
expect(editMode(undefined)).toBe('create')
|
|
53
|
+
expect(editMode({ id: '1' } as Customer)).toBe('edit')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('controlKind', () => {
|
|
58
|
+
it('maps editor types to controls', () => {
|
|
59
|
+
expect(controlKind({ editorType: 'checkbox' })).toBe('checkbox')
|
|
60
|
+
expect(controlKind({ editorType: 'number' })).toBe('number')
|
|
61
|
+
expect(controlKind({ editorType: 'date' })).toBe('date')
|
|
62
|
+
expect(controlKind({ editorType: 'datetime' })).toBe('datetime')
|
|
63
|
+
expect(controlKind({ editorType: 'select' })).toBe('select')
|
|
64
|
+
expect(controlKind({ editorType: 'list' })).toBe('select')
|
|
65
|
+
expect(controlKind({ editorType: 'textarea' })).toBe('textarea')
|
|
66
|
+
expect(controlKind({ editorType: 'text' })).toBe('text')
|
|
67
|
+
expect(controlKind({ editorType: 'autocomplete' })).toBe('text')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('buildInitialValues', () => {
|
|
72
|
+
it('seeds from defaults and type-appropriate empties in create mode', () => {
|
|
73
|
+
const v = buildInitialValues(schema, null)
|
|
74
|
+
expect(v).toEqual({ id: '', name: '', age: 18, active: false, tier: '', bio: '' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('seeds from the row in edit mode', () => {
|
|
78
|
+
const row: Customer = { id: '7', name: 'a@b.com', age: 40, active: true, tier: 'pro', bio: 'hi' }
|
|
79
|
+
expect(buildInitialValues(schema, row)).toEqual(row)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('validateAll', () => {
|
|
84
|
+
it('flags a required-but-empty field with a friendly message', async () => {
|
|
85
|
+
const errors = await validateAll(schema, buildInitialValues(schema, null))
|
|
86
|
+
expect(errors.name).toBe('Name is required')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('runs the Standard Schema validator when a value is present', async () => {
|
|
90
|
+
const values = { ...buildInitialValues(schema, null), name: 'not-an-email' }
|
|
91
|
+
const errors = await validateAll(schema, values)
|
|
92
|
+
expect(errors.name).toBe('Invalid email')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('passes a valid form (no readonly checks, no false positives)', async () => {
|
|
96
|
+
const values = { ...buildInitialValues(schema, null), name: 'a@b.com' }
|
|
97
|
+
expect(await validateAll(schema, values)).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('never validates readonly fields', async () => {
|
|
101
|
+
// id is readonly + empty; must not produce an error even though it looks empty
|
|
102
|
+
const errors = await validateAll(schema, { ...buildInitialValues(schema, null), name: 'a@b.com' })
|
|
103
|
+
expect(errors.id).toBeUndefined()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('merges schema-level cross-field errors', async () => {
|
|
107
|
+
const withCross: EntitySchema<Customer> = {
|
|
108
|
+
...schema,
|
|
109
|
+
hooks: { validate: () => ({ name: 'blocked by rule', region: 'required together' }) },
|
|
110
|
+
}
|
|
111
|
+
// A per-field failure (missing name) is NOT overwritten by the cross-field rule,
|
|
112
|
+
// but a cross-field-only error (region) is surfaced.
|
|
113
|
+
const errors = await validateAll(withCross, buildInitialValues(withCross, null))
|
|
114
|
+
expect(errors.name).toBe('Name is required') // per-field wins
|
|
115
|
+
expect(errors.region).toBe('required together') // cross-field added
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('built-in validation (no external lib)', () => {
|
|
120
|
+
type Row = { id: string; name: string; age: number; site: string; contact: string }
|
|
121
|
+
const s: EntitySchema<Row> = {
|
|
122
|
+
name: 'people',
|
|
123
|
+
idField: 'id',
|
|
124
|
+
fields: [
|
|
125
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
126
|
+
{ field: 'name', type: 'text', minLength: 2, maxLength: 10 },
|
|
127
|
+
{ field: 'age', type: 'number', min: 0, max: 120 },
|
|
128
|
+
{ field: 'site', type: 'text', format: 'url' },
|
|
129
|
+
{ field: 'contact', type: 'text', format: 'email' },
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
const base = () => buildInitialValues(s, null)
|
|
133
|
+
|
|
134
|
+
it('rejects a non-numeric number and enforces min/max', async () => {
|
|
135
|
+
expect((await validateAll(s, { ...base(), age: 'abc' })).age).toMatch(/must be a number/)
|
|
136
|
+
expect((await validateAll(s, { ...base(), age: '-5' })).age).toMatch(/at least 0/)
|
|
137
|
+
expect((await validateAll(s, { ...base(), age: '200' })).age).toMatch(/at most 120/)
|
|
138
|
+
expect((await validateAll(s, { ...base(), age: '30' })).age).toBeUndefined()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('enforces string length', async () => {
|
|
142
|
+
expect((await validateAll(s, { ...base(), name: 'x' })).name).toMatch(/at least 2/)
|
|
143
|
+
expect((await validateAll(s, { ...base(), name: 'x'.repeat(20) })).name).toMatch(/at most 10/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('validates email and url formats', async () => {
|
|
147
|
+
expect((await validateAll(s, { ...base(), contact: 'nope' })).contact).toMatch(/valid email/)
|
|
148
|
+
expect((await validateAll(s, { ...base(), contact: 'a@b.co' })).contact).toBeUndefined()
|
|
149
|
+
expect((await validateAll(s, { ...base(), site: 'ftp://x' })).site).toMatch(/valid URL/)
|
|
150
|
+
expect((await validateAll(s, { ...base(), site: 'https://x.io' })).site).toBeUndefined()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('skips built-in checks when the value is empty (that is what required is for)', async () => {
|
|
154
|
+
expect((await validateAll(s, base())).age).toBeUndefined()
|
|
155
|
+
expect((await validateAll(s, base())).contact).toBeUndefined()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('toSubmitValues', () => {
|
|
160
|
+
it('omits readonly fields and coerces number + boolean', () => {
|
|
161
|
+
const values = { id: '7', name: 'a@b.com', age: '42', active: 'true', tier: 'pro', bio: 'x' }
|
|
162
|
+
const out = toSubmitValues(schema, values)
|
|
163
|
+
expect(out).toEqual({ name: 'a@b.com', age: 42, active: true, tier: 'pro', bio: 'x' })
|
|
164
|
+
expect('id' in out).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('turns an empty number into null', () => {
|
|
168
|
+
const out = toSubmitValues(schema, { ...buildInitialValues(schema, null), age: '' })
|
|
169
|
+
expect(out.age).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('rowId', () => {
|
|
174
|
+
it('resolves the primary key as a string', () => {
|
|
175
|
+
expect(rowId(schema, { id: 99 } as unknown as Customer)).toBe('99')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('editor value coercion', () => {
|
|
180
|
+
it('formats a Date to a local yyyy-MM-dd (no UTC shift)', () => {
|
|
181
|
+
// Local midnight - toISOString would shift the day in negative-offset zones; local formatting must not.
|
|
182
|
+
const d = new Date(2024, 0, 2, 0, 0) // 2 Jan 2024, local
|
|
183
|
+
expect(toDateString(d)).toBe('2024-01-02')
|
|
184
|
+
expect(toDateString(null)).toBe('')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('formats a Date to a local yyyy-MM-ddTHH:mm', () => {
|
|
188
|
+
expect(toDateTimeString(new Date(2024, 2, 4, 9, 5))).toBe('2024-03-04T09:05')
|
|
189
|
+
expect(toDateTimeString(null)).toBe('')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('coerces numbers in/out with the empty-string convention', () => {
|
|
193
|
+
expect(toNumberValue(1200)).toBe(1200)
|
|
194
|
+
expect(toNumberValue('1200')).toBeNull() // strings are not numbers
|
|
195
|
+
expect(toNumberValue(NaN)).toBeNull()
|
|
196
|
+
expect(fromNumberValue(null)).toBe('')
|
|
197
|
+
expect(fromNumberValue(0)).toBe(0) // 0 is a real value, not empty
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('slider falls back to min when unset', () => {
|
|
201
|
+
expect(toSliderValue(42, 0)).toBe(42)
|
|
202
|
+
expect(toSliderValue('', 10)).toBe(10)
|
|
203
|
+
expect(toSliderValue(undefined, 5)).toBe(5)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('coerces tags from arrays, comma strings, or empty', () => {
|
|
207
|
+
expect(toTags(['a', 'b'])).toEqual(['a', 'b'])
|
|
208
|
+
expect(toTags('a, b ,c')).toEqual(['a', 'b', 'c'])
|
|
209
|
+
expect(toTags('')).toEqual([])
|
|
210
|
+
expect(toTags(null)).toEqual([])
|
|
211
|
+
expect(toTags([1, 2])).toEqual(['1', '2'])
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit-panel logic. The testable core behind `SvGridEditPanel.svelte`: build a
|
|
3
|
+
* form's initial values from a schema + row, resolve which control renders each
|
|
4
|
+
* field, validate the whole form, and produce the payload to submit through a
|
|
5
|
+
* `ServerController`'s `createRow` / `updateRow`. Keeping this pure (no Svelte,
|
|
6
|
+
* no DOM) mirrors the pivot designer split and lets it be unit-tested.
|
|
7
|
+
*/
|
|
8
|
+
import type { RowData } from '@svgrid/grid'
|
|
9
|
+
import {
|
|
10
|
+
resolveIdField,
|
|
11
|
+
schemaToFormFields,
|
|
12
|
+
validateEntity,
|
|
13
|
+
validateField,
|
|
14
|
+
type EntitySchema,
|
|
15
|
+
type FormFieldDescriptor,
|
|
16
|
+
} from './schema'
|
|
17
|
+
|
|
18
|
+
export type EditMode = 'create' | 'edit'
|
|
19
|
+
|
|
20
|
+
/** The native control a field renders as. */
|
|
21
|
+
export type ControlKind =
|
|
22
|
+
| 'text'
|
|
23
|
+
| 'number'
|
|
24
|
+
| 'date'
|
|
25
|
+
| 'datetime'
|
|
26
|
+
| 'time'
|
|
27
|
+
| 'checkbox'
|
|
28
|
+
| 'select'
|
|
29
|
+
| 'textarea'
|
|
30
|
+
|
|
31
|
+
/** Create mode when there's no row to edit, edit mode otherwise. */
|
|
32
|
+
export function editMode<TData extends RowData>(row: TData | null | undefined): EditMode {
|
|
33
|
+
return row == null ? 'create' : 'edit'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map a field's editor type to the control the panel renders. */
|
|
37
|
+
export function controlKind(field: Pick<FormFieldDescriptor, 'editorType'>): ControlKind {
|
|
38
|
+
switch (field.editorType) {
|
|
39
|
+
case 'checkbox':
|
|
40
|
+
return 'checkbox'
|
|
41
|
+
case 'number':
|
|
42
|
+
return 'number'
|
|
43
|
+
case 'date':
|
|
44
|
+
return 'date'
|
|
45
|
+
case 'datetime':
|
|
46
|
+
return 'datetime'
|
|
47
|
+
case 'time':
|
|
48
|
+
return 'time'
|
|
49
|
+
case 'textarea':
|
|
50
|
+
return 'textarea'
|
|
51
|
+
case 'select':
|
|
52
|
+
case 'rich-select':
|
|
53
|
+
case 'list':
|
|
54
|
+
case 'chips':
|
|
55
|
+
return 'select'
|
|
56
|
+
default:
|
|
57
|
+
return 'text'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emptyFor(field: FormFieldDescriptor): unknown {
|
|
62
|
+
return field.type === 'boolean' ? false : ''
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Editor value coercion --------------------------------------------------
|
|
66
|
+
// Pure conversions between the form's stored value and the shape each rich
|
|
67
|
+
// editor's `value` / `onChange` expects. Kept here (not inline in the .svelte)
|
|
68
|
+
// so the tricky bits - local date formatting, number <-> '' , tag arrays - are
|
|
69
|
+
// unit-testable without a DOM.
|
|
70
|
+
|
|
71
|
+
const _p2 = (n: number) => String(n).padStart(2, '0')
|
|
72
|
+
|
|
73
|
+
/** A `Date` -> a **local** `yyyy-MM-dd` string (matches the native date input; no UTC shift). */
|
|
74
|
+
export function toDateString(d: Date | null | undefined): string {
|
|
75
|
+
return d ? `${d.getFullYear()}-${_p2(d.getMonth() + 1)}-${_p2(d.getDate())}` : ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A `Date` -> a **local** `yyyy-MM-ddTHH:mm` string (matches `datetime-local`; no UTC shift). */
|
|
79
|
+
export function toDateTimeString(d: Date | null | undefined): string {
|
|
80
|
+
return d ? `${toDateString(d)}T${_p2(d.getHours())}:${_p2(d.getMinutes())}` : ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stored value -> `SvNumberInput.value` (number | null); non-numbers become null. */
|
|
84
|
+
export function toNumberValue(raw: unknown): number | null {
|
|
85
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `SvNumberInput.onChange` payload -> stored value; null becomes '' (the empty convention). */
|
|
89
|
+
export function fromNumberValue(v: number | null): number | '' {
|
|
90
|
+
return v == null ? '' : v
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stored value -> `SvSlider.value`; falls back to `min` when unset. */
|
|
94
|
+
export function toSliderValue(raw: unknown, min: number): number {
|
|
95
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : min
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Stored value -> `SvTagsInput.value` (string[]); splits a comma string, else wraps. */
|
|
99
|
+
export function toTags(raw: unknown): string[] {
|
|
100
|
+
if (Array.isArray(raw)) return raw.map((t) => String(t))
|
|
101
|
+
if (raw == null || raw === '') return []
|
|
102
|
+
return String(raw).split(',').map((s) => s.trim()).filter(Boolean)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isEmpty(value: unknown): boolean {
|
|
106
|
+
return value == null || value === ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
110
|
+
const URL_RE = /^https?:\/\/.+/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Built-in validation from the field's own constraints - number validity,
|
|
114
|
+
* min/max, length, email/url format, regex - so forms validate out of the box
|
|
115
|
+
* without any external library. Returns the first violation, or null.
|
|
116
|
+
*/
|
|
117
|
+
export function builtInError(field: FormFieldDescriptor, value: unknown): string | null {
|
|
118
|
+
if (isEmpty(value)) return null
|
|
119
|
+
if (field.type === 'number') {
|
|
120
|
+
const n = Number(value)
|
|
121
|
+
if (!Number.isFinite(n)) return `${field.label} must be a number`
|
|
122
|
+
if (field.min != null && n < field.min) return `${field.label} must be at least ${field.min}`
|
|
123
|
+
if (field.max != null && n > field.max) return `${field.label} must be at most ${field.max}`
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
const s = String(value)
|
|
127
|
+
if (field.minLength != null && s.length < field.minLength) {
|
|
128
|
+
return `${field.label} must be at least ${field.minLength} characters`
|
|
129
|
+
}
|
|
130
|
+
if (field.maxLength != null && s.length > field.maxLength) {
|
|
131
|
+
return `${field.label} must be at most ${field.maxLength} characters`
|
|
132
|
+
}
|
|
133
|
+
if (field.format === 'email' && !EMAIL_RE.test(s)) return `${field.label} must be a valid email`
|
|
134
|
+
if (field.format === 'url' && !URL_RE.test(s)) return `${field.label} must be a valid URL`
|
|
135
|
+
if (field.pattern && !new RegExp(field.pattern).test(s)) return `${field.label} is not in the right format`
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Seed the form's editable state. Uses the row's value when editing, else the
|
|
141
|
+
* field's `defaultValue`, else a type-appropriate empty. Covers every field the
|
|
142
|
+
* form shows (`schemaToFormFields`), including the read-only primary key so it
|
|
143
|
+
* can be displayed.
|
|
144
|
+
*/
|
|
145
|
+
export function buildInitialValues<TData extends RowData>(
|
|
146
|
+
schema: EntitySchema<TData>,
|
|
147
|
+
row: TData | null | undefined,
|
|
148
|
+
): Record<string, unknown> {
|
|
149
|
+
const values: Record<string, unknown> = {}
|
|
150
|
+
const source = (row ?? null) as RowData | null
|
|
151
|
+
for (const f of schemaToFormFields(schema)) {
|
|
152
|
+
if (source && f.field in source) values[f.field] = source[f.field]
|
|
153
|
+
else if (f.defaultValue !== undefined) values[f.field] = f.defaultValue
|
|
154
|
+
else values[f.field] = emptyFor(f)
|
|
155
|
+
}
|
|
156
|
+
return values
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate every editable field: required-but-empty first, then the field's
|
|
161
|
+
* Standard Schema validator (if any), then the schema-level cross-field
|
|
162
|
+
* `hooks.validate` (e.g. "end date must be after start date"). Returns a
|
|
163
|
+
* `{ field: message }` map with only the failing fields, so an empty object
|
|
164
|
+
* means the form is valid. Per-field errors take precedence over cross-field.
|
|
165
|
+
*/
|
|
166
|
+
export async function validateAll<TData extends RowData>(
|
|
167
|
+
schema: EntitySchema<TData>,
|
|
168
|
+
values: Record<string, unknown>,
|
|
169
|
+
): Promise<Record<string, string>> {
|
|
170
|
+
const errors: Record<string, string> = {}
|
|
171
|
+
for (const f of schemaToFormFields(schema)) {
|
|
172
|
+
if (f.readonly) continue
|
|
173
|
+
const value = values[f.field]
|
|
174
|
+
if (f.required && f.type !== 'boolean' && isEmpty(value)) {
|
|
175
|
+
errors[f.field] = `${f.label} is required`
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const builtIn = builtInError(f, value)
|
|
179
|
+
if (builtIn) {
|
|
180
|
+
errors[f.field] = builtIn
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (!isEmpty(value)) {
|
|
184
|
+
const msg = await validateField(f, value)
|
|
185
|
+
if (msg) errors[f.field] = msg
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Cross-field rules run last, and never overwrite a per-field error.
|
|
189
|
+
const crossField = await validateEntity(schema, values as Partial<TData>)
|
|
190
|
+
for (const [field, message] of Object.entries(crossField)) {
|
|
191
|
+
if (!errors[field]) errors[field] = message
|
|
192
|
+
}
|
|
193
|
+
return errors
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function coerceOut(field: FormFieldDescriptor, value: unknown): unknown {
|
|
197
|
+
if (field.type === 'number') {
|
|
198
|
+
if (isEmpty(value)) return null
|
|
199
|
+
const n = Number(value)
|
|
200
|
+
return Number.isFinite(n) ? n : null
|
|
201
|
+
}
|
|
202
|
+
if (field.type === 'boolean') return value === true || value === 'true'
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the payload to send to `createRow` / `updateRow`. Read-only fields
|
|
208
|
+
* (including the primary key) are never submitted; the id for an update is
|
|
209
|
+
* resolved separately via `rowId`. Values are coerced back to their runtime
|
|
210
|
+
* type (number strings to numbers, checkbox state to booleans).
|
|
211
|
+
*/
|
|
212
|
+
export function toSubmitValues<TData extends RowData>(
|
|
213
|
+
schema: EntitySchema<TData>,
|
|
214
|
+
values: Record<string, unknown>,
|
|
215
|
+
): Partial<TData> {
|
|
216
|
+
const out: Record<string, unknown> = {}
|
|
217
|
+
for (const f of schemaToFormFields(schema)) {
|
|
218
|
+
if (f.readonly) continue
|
|
219
|
+
out[f.field] = coerceOut(f, values[f.field])
|
|
220
|
+
}
|
|
221
|
+
return out as Partial<TData>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The stable id of the row being edited, as a string (for `updateRow`). */
|
|
225
|
+
export function rowId<TData extends RowData>(schema: EntitySchema<TData>, row: TData): string {
|
|
226
|
+
const idField = resolveIdField(schema)
|
|
227
|
+
return String((row as RowData)[idField])
|
|
228
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildConditionalResolver } from './export-conditional'
|
|
3
|
+
import type { ConditionalFormat } from '@svgrid/grid/format'
|
|
4
|
+
|
|
5
|
+
type Row = { name: string; score: number }
|
|
6
|
+
const cols = [{ field: 'name' }, { field: 'score' }]
|
|
7
|
+
const rows: Row[] = [
|
|
8
|
+
{ name: 'A', score: 10 },
|
|
9
|
+
{ name: 'B', score: 90 },
|
|
10
|
+
{ name: 'C', score: 50 },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('buildConditionalResolver', () => {
|
|
14
|
+
it('is a no-op with no formats', () => {
|
|
15
|
+
const r = buildConditionalResolver(cols, rows, undefined)
|
|
16
|
+
expect(r(0, 1)).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('resolves a color scale (with per-column stats) only on its columns', () => {
|
|
20
|
+
const formats: ConditionalFormat<Row>[] = [
|
|
21
|
+
{ type: 'colorScale', columns: ['score'], min: '#ff0000', max: '#00ff00' },
|
|
22
|
+
]
|
|
23
|
+
const r = buildConditionalResolver(cols, rows, formats)
|
|
24
|
+
expect(r(1, 1)?.fill).toBeTruthy() // high score -> greenish
|
|
25
|
+
expect(r(1, 1)?.color).toBeTruthy() // contrast text auto-picked
|
|
26
|
+
expect(r(0, 0)).toBeUndefined() // name column untouched
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('applies a predicate rule with background + bold', () => {
|
|
30
|
+
const formats: ConditionalFormat<Row>[] = [
|
|
31
|
+
{
|
|
32
|
+
type: 'rule',
|
|
33
|
+
columns: ['score'],
|
|
34
|
+
when: ({ value }) => Number(value) >= 90,
|
|
35
|
+
background: '#dcfce7',
|
|
36
|
+
fontWeight: 'bold',
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
const r = buildConditionalResolver(cols, rows, formats)
|
|
40
|
+
expect(r(1, 1)).toMatchObject({ fill: '#dcfce7', bold: true })
|
|
41
|
+
expect(r(0, 1)).toBeUndefined() // score 10 doesn't match
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('emits an icon-set glyph', () => {
|
|
45
|
+
const formats: ConditionalFormat<Row>[] = [
|
|
46
|
+
{ type: 'iconSet', columns: ['score'], thresholds: [33, 66] },
|
|
47
|
+
]
|
|
48
|
+
const r = buildConditionalResolver(cols, rows, formats)
|
|
49
|
+
expect(r(1, 1)?.icon).toBeTruthy() // high bucket
|
|
50
|
+
expect(r(0, 1)?.icon).toBeTruthy() // low bucket
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('degrades a data bar to a light fill', () => {
|
|
54
|
+
const formats: ConditionalFormat<Row>[] = [
|
|
55
|
+
{ type: 'dataBar', columns: ['score'], color: '#3b82f6' },
|
|
56
|
+
]
|
|
57
|
+
const r = buildConditionalResolver(cols, rows, formats)
|
|
58
|
+
expect(r(1, 1)?.fill).toMatch(/^#/) // a lightened fill
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carry the grid's conditional formatting (color scales, data bars, icon sets,
|
|
3
|
+
* predicate rules) into the styled export formats - PDF, xlsx, HTML, and the
|
|
4
|
+
* print view. The grid's engine (`resolveCellFormat`) is pure and reused here
|
|
5
|
+
* via the Svelte-free `@svgrid/grid/format` subpath; this module turns a
|
|
6
|
+
* `ResolvedCellFormat` into the primitives the exporters can render:
|
|
7
|
+
* `{ fill, color, bold, icon }`.
|
|
8
|
+
*
|
|
9
|
+
* Data formats (csv / tsv / json / xml / md) are unstyled, so conditional
|
|
10
|
+
* formatting is ignored there. Data bars can't render as partial-width bars in
|
|
11
|
+
* a spreadsheet cell / PDF cell, so they degrade to a light fill of the bar
|
|
12
|
+
* color; icon sets prepend the icon glyph to the cell text.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
computeColumnStat,
|
|
16
|
+
formatsNeedingStats,
|
|
17
|
+
resolveCellFormat,
|
|
18
|
+
type ColumnStat,
|
|
19
|
+
type ConditionalFormat,
|
|
20
|
+
type ResolvedCellFormat,
|
|
21
|
+
} from '@svgrid/grid/format'
|
|
22
|
+
import type { RowData } from '@svgrid/grid'
|
|
23
|
+
import type { ExportColumn } from './export'
|
|
24
|
+
|
|
25
|
+
/** The visual primitives an exporter can apply to one cell. */
|
|
26
|
+
export type ExportCellVisual = {
|
|
27
|
+
fill?: string
|
|
28
|
+
color?: string
|
|
29
|
+
bold?: boolean
|
|
30
|
+
/** Icon-set glyph to prepend to the cell text. */
|
|
31
|
+
icon?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ExportCellVisualFn = (rowIdx: number, colIdx: number) => ExportCellVisual | undefined
|
|
35
|
+
|
|
36
|
+
/** Lighten a hex color toward white by `amt` (0..1). Returns input if unparseable. */
|
|
37
|
+
function mixWhite(hex: string, amt: number): string {
|
|
38
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
|
39
|
+
if (!m) return hex
|
|
40
|
+
const int = parseInt(m[1]!, 16)
|
|
41
|
+
const ch = [(int >> 16) & 255, (int >> 8) & 255, int & 255]
|
|
42
|
+
return (
|
|
43
|
+
'#' +
|
|
44
|
+
ch.map((c) => Math.round(c + (255 - c) * amt).toString(16).padStart(2, '0')).join('')
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toVisual(resolved: ResolvedCellFormat): ExportCellVisual | undefined {
|
|
49
|
+
const v: ExportCellVisual = {}
|
|
50
|
+
if (resolved.background) v.fill = resolved.background
|
|
51
|
+
// Data bar -> a light fill of the bar color (no partial-width bars in cells).
|
|
52
|
+
if (!v.fill && resolved.dataBar) v.fill = mixWhite(resolved.dataBar.color, 0.72)
|
|
53
|
+
if (resolved.color && resolved.color !== 'transparent') v.color = resolved.color
|
|
54
|
+
if (
|
|
55
|
+
resolved.fontWeight === 'bold' ||
|
|
56
|
+
(typeof resolved.fontWeight === 'number' && resolved.fontWeight >= 600)
|
|
57
|
+
) {
|
|
58
|
+
v.bold = true
|
|
59
|
+
}
|
|
60
|
+
if (resolved.icon) v.icon = resolved.icon
|
|
61
|
+
return Object.keys(v).length > 0 ? v : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Precompute per-column stats (for color scales / data bars) and return a
|
|
66
|
+
* `(rowIdx, colIdx) => ExportCellVisual | undefined` lookup over the given
|
|
67
|
+
* rows + columns. Returns a no-op when there are no formats.
|
|
68
|
+
*/
|
|
69
|
+
export function buildConditionalResolver<TData extends RowData>(
|
|
70
|
+
cols: ReadonlyArray<ExportColumn<TData>>,
|
|
71
|
+
rows: ReadonlyArray<TData>,
|
|
72
|
+
formats: ReadonlyArray<ConditionalFormat<TData>> | undefined,
|
|
73
|
+
): ExportCellVisualFn {
|
|
74
|
+
if (!formats || formats.length === 0) return () => undefined
|
|
75
|
+
|
|
76
|
+
const stats = new Map<string, ColumnStat | null>()
|
|
77
|
+
if (formatsNeedingStats(formats)) {
|
|
78
|
+
for (const c of cols) {
|
|
79
|
+
stats.set(
|
|
80
|
+
c.field,
|
|
81
|
+
computeColumnStat(rows.map((r) => (r as Record<string, unknown>)[c.field])),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (rowIdx, colIdx) => {
|
|
87
|
+
const col = cols[colIdx]
|
|
88
|
+
const row = rows[rowIdx]
|
|
89
|
+
if (!col || !row) return undefined
|
|
90
|
+
const raw = (row as Record<string, unknown>)[col.field]
|
|
91
|
+
const resolved = resolveCellFormat(raw, row, col.field, formats, stats.get(col.field) ?? null)
|
|
92
|
+
return toVisual(resolved)
|
|
93
|
+
}
|
|
94
|
+
}
|