@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,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
+ }