@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,180 @@
1
+ <script module lang="ts">
2
+ let lookupUid = 0
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * SvLookupInput - a searchable single-select for a foreign-key ("lookup")
8
+ * field. It searches the related entity through a `RelationLookup`, shows human
9
+ * labels, and stores the selected key. Used by SvGridEditPanel for `relation`
10
+ * fields, and usable on its own.
11
+ */
12
+ import type { RelationLookup, RelationOption } from './sources/relation-lookup'
13
+
14
+ type Props = {
15
+ value: unknown
16
+ lookup: RelationLookup
17
+ onSelect: (value: string) => void
18
+ id?: string
19
+ placeholder?: string
20
+ disabled?: boolean
21
+ required?: boolean
22
+ /** Debounce for the search query, ms. Default 200. */
23
+ debounceMs?: number
24
+ }
25
+
26
+ let {
27
+ value,
28
+ lookup,
29
+ onSelect,
30
+ id,
31
+ placeholder = 'Search…',
32
+ disabled = false,
33
+ required = false,
34
+ debounceMs = 200,
35
+ }: Props = $props()
36
+
37
+ // Stable listbox id so the combobox input can reference it via aria-controls.
38
+ const listboxId = `sv-lookup-list-${++lookupUid}`
39
+ let open = $state(false)
40
+ let query = $state('')
41
+ let options = $state<RelationOption[]>([])
42
+ let loading = $state(false)
43
+ let active = $state(-1)
44
+ let label = $state('')
45
+ let rootEl: HTMLDivElement | undefined = $state()
46
+
47
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
48
+ let labelToken = 0
49
+ let searchToken = 0
50
+
51
+ // Resolve the current value's label whenever it changes (and we're not typing).
52
+ $effect(() => {
53
+ const v = value
54
+ if (v == null || v === '') { label = ''; return }
55
+ const token = ++labelToken
56
+ lookup.labelFor(v).then((l) => {
57
+ if (token === labelToken) label = l ?? String(v)
58
+ })
59
+ })
60
+
61
+ async function runSearch(q: string) {
62
+ const token = ++searchToken
63
+ loading = true
64
+ try {
65
+ const res = await lookup.search(q)
66
+ if (token === searchToken) { options = res; active = res.length ? 0 : -1 }
67
+ } finally {
68
+ if (token === searchToken) loading = false
69
+ }
70
+ }
71
+
72
+ function openList() {
73
+ if (disabled) return
74
+ open = true
75
+ query = ''
76
+ runSearch('')
77
+ }
78
+ function closeList() {
79
+ open = false
80
+ query = ''
81
+ if (searchTimer) clearTimeout(searchTimer)
82
+ }
83
+ function onInput(e: Event) {
84
+ query = (e.currentTarget as HTMLInputElement).value
85
+ open = true
86
+ if (searchTimer) clearTimeout(searchTimer)
87
+ searchTimer = setTimeout(() => runSearch(query), debounceMs)
88
+ }
89
+ function choose(opt: RelationOption) {
90
+ label = opt.label
91
+ onSelect(opt.value)
92
+ closeList()
93
+ }
94
+ function clear() {
95
+ onSelect('')
96
+ label = ''
97
+ closeList()
98
+ }
99
+ function onKeydown(e: KeyboardEvent) {
100
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { openList(); return }
101
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, options.length - 1) }
102
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
103
+ else if (e.key === 'Enter') { if (open && options[active]) { e.preventDefault(); choose(options[active]) } }
104
+ else if (e.key === 'Escape') { if (open) { e.preventDefault(); closeList() } }
105
+ }
106
+ function onDocClick(e: MouseEvent) {
107
+ if (open && rootEl && !rootEl.contains(e.target as Node)) closeList()
108
+ }
109
+ </script>
110
+
111
+ <svelte:document onclick={onDocClick} />
112
+
113
+ <div class="sv-lookup" bind:this={rootEl}>
114
+ <div class="sv-lookup__control">
115
+ <input
116
+ {id}
117
+ class="sv-lookup__input"
118
+ type="text"
119
+ role="combobox"
120
+ aria-expanded={open}
121
+ aria-controls={listboxId}
122
+ aria-autocomplete="list"
123
+ autocomplete="off"
124
+ {placeholder}
125
+ {disabled}
126
+ {required}
127
+ value={open ? query : label}
128
+ onfocus={openList}
129
+ oninput={onInput}
130
+ onkeydown={onKeydown}
131
+ />
132
+ {#if value != null && value !== '' && !required && !disabled}
133
+ <button type="button" class="sv-lookup__clear" title="Clear" aria-label="Clear" onclick={clear}>&times;</button>
134
+ {/if}
135
+ </div>
136
+
137
+ <ul class="sv-lookup__menu" role="listbox" id={listboxId} hidden={!open}>
138
+ {#if loading}
139
+ <li class="sv-lookup__msg">Searching…</li>
140
+ {:else if options.length === 0}
141
+ <li class="sv-lookup__msg">No matches</li>
142
+ {:else}
143
+ {#each options as opt, i (opt.value)}
144
+ <li>
145
+ <button
146
+ type="button"
147
+ role="option"
148
+ aria-selected={i === active}
149
+ class="sv-lookup__opt"
150
+ class:is-active={i === active}
151
+ onmouseenter={() => (active = i)}
152
+ onclick={() => choose(opt)}
153
+ >{opt.label}</button>
154
+ </li>
155
+ {/each}
156
+ {/if}
157
+ </ul>
158
+ </div>
159
+
160
+ <style>
161
+ .sv-lookup { position: relative; }
162
+ .sv-lookup__control { position: relative; display: flex; align-items: center; }
163
+ .sv-lookup__input { width: 100%; }
164
+ .sv-lookup__clear {
165
+ position: absolute; right: 6px; border: none; background: none; cursor: pointer;
166
+ font-size: 16px; line-height: 1; color: var(--sg-muted, #888); padding: 2px 4px;
167
+ }
168
+ .sv-lookup__menu {
169
+ position: absolute; z-index: 30; top: calc(100% + 2px); left: 0; right: 0; margin: 0; padding: 4px;
170
+ list-style: none; max-height: 220px; overflow: auto;
171
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #ddd); border-radius: 8px;
172
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
173
+ }
174
+ .sv-lookup__opt {
175
+ display: block; width: 100%; text-align: left; padding: 7px 10px; border: none; border-radius: 6px;
176
+ background: none; cursor: pointer; font: inherit; color: var(--sg-fg, inherit);
177
+ }
178
+ .sv-lookup__opt.is-active { background: var(--sg-accent, #2563eb); color: #fff; }
179
+ .sv-lookup__msg { padding: 8px 10px; color: var(--sg-muted, #888); font-size: 13px; }
180
+ </style>
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DOM test: SvRecordDetail renders a record header (title + status pill + metric
3
+ * tiles), a tabbed Overview of field groups, and a related-collection timeline
4
+ * filtered by the foreign key. Also mounts against the real CRM deals + activities
5
+ * sample so the detail <-> sample path is covered.
6
+ */
7
+ import { describe, it, expect, afterEach } from 'vitest'
8
+ import { mount, unmount, flushSync } from 'svelte'
9
+ import SvRecordDetail from './SvRecordDetail.svelte'
10
+ import { getSampleApp } from './studio/samples/index'
11
+ import type { EntitySchema } from './schema'
12
+
13
+ const dealsSchema: EntitySchema = {
14
+ name: 'deals',
15
+ idField: 'id',
16
+ fields: [
17
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
18
+ { field: 'title', type: 'text' },
19
+ { field: 'owner', type: 'text', label: 'Owner' },
20
+ { field: 'value', type: 'number', label: 'Value ($)' },
21
+ { field: 'probability', type: 'number', label: 'Probability (%)' },
22
+ { field: 'stage', type: 'enum', options: [
23
+ { value: 'lead', label: 'Lead', color: '#94a3b8' },
24
+ { value: 'won', label: 'Won', color: '#10b981' },
25
+ ] },
26
+ ],
27
+ }
28
+ const actsSchema: EntitySchema = {
29
+ name: 'activities',
30
+ idField: 'id',
31
+ fields: [
32
+ { field: 'id', type: 'text', primaryKey: true },
33
+ { field: 'subject', type: 'text' },
34
+ { field: 'dealId', type: 'relation', relation: { entity: 'deals', foreignKey: 'dealId', labelField: 'title' } },
35
+ { field: 'dueDate', type: 'date' },
36
+ ],
37
+ }
38
+
39
+ let host: HTMLElement | null = null
40
+ let comp: ReturnType<typeof mount> | null = null
41
+ afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
42
+
43
+ function render(props: Record<string, unknown>): HTMLElement {
44
+ host = document.createElement('div')
45
+ document.body.appendChild(host)
46
+ comp = mount(SvRecordDetail, { target: host, props })
47
+ flushSync()
48
+ return host
49
+ }
50
+
51
+ describe('SvRecordDetail (DOM)', () => {
52
+ it('renders header title, status pill, and metric tiles', () => {
53
+ const rows = [{ id: 'd1', title: 'Alpha deal', owner: 'Sam', value: 1000, probability: 60, stage: 'won' }]
54
+ const el = render({ schema: dealsSchema, rows, titleField: 'title', subtitleField: 'owner', statusField: 'stage', metricFields: ['value', 'probability'] })
55
+ expect(el.querySelector('.sv-detail__title')?.textContent).toBe('Alpha deal')
56
+ expect(el.querySelector('.sv-detail__status')?.textContent).toBe('Won')
57
+ const metrics = [...el.querySelectorAll('.sv-detail__metric-value')].map((n) => n.textContent)
58
+ expect(metrics).toContain('$1,000') // money-formatted from the "Value ($)" label
59
+ expect(metrics).toContain('60')
60
+ })
61
+
62
+ it('auto-builds an Overview of the remaining fields when no sections given', () => {
63
+ const rows = [{ id: 'd1', title: 'Alpha', owner: 'Sam', value: 1000, probability: 60, stage: 'won' }]
64
+ const el = render({ schema: dealsSchema, rows, titleField: 'title', statusField: 'stage', metricFields: ['value'] })
65
+ const labels = [...el.querySelectorAll('.sv-detail__field dt')].map((n) => n.textContent)
66
+ // title/stage/value are surfaced in the header, so the auto group shows the rest.
67
+ expect(labels).toContain('Owner')
68
+ expect(labels).toContain('Probability (%)')
69
+ expect(labels).not.toContain('Value ($)') // in the header metrics, not the Overview
70
+ })
71
+
72
+ it('shows a related timeline filtered by the foreign key', () => {
73
+ const rows = [{ id: 'd1', title: 'Alpha', stage: 'won' }, { id: 'd2', title: 'Beta', stage: 'lead' }]
74
+ const acts = [
75
+ { id: 'a1', subject: 'Call', dealId: 'd1', dueDate: '2026-03-01' },
76
+ { id: 'a2', subject: 'Email', dealId: 'd1', dueDate: '2026-03-05' },
77
+ { id: 'a3', subject: 'Other', dealId: 'd2', dueDate: '2026-03-02' },
78
+ ]
79
+ const el = render({
80
+ schema: dealsSchema, rows, titleField: 'title', statusField: 'stage',
81
+ related: [{ label: 'Activities', schema: actsSchema, rows: acts, foreignKey: 'dealId', titleField: 'subject', dateField: 'dueDate' }],
82
+ })
83
+ // Two tabs: Overview + Activities.
84
+ const tabs = [...el.querySelectorAll('.sv-detail__tab')].map((n) => n.textContent)
85
+ expect(tabs).toEqual(['Overview', 'Activities'])
86
+ // Switch to the Activities tab.
87
+ const actTab = [...el.querySelectorAll<HTMLButtonElement>('.sv-detail__tab')].find((b) => b.textContent === 'Activities')!
88
+ actTab.click()
89
+ flushSync()
90
+ const events = [...el.querySelectorAll('.sv-detail__event-title')].map((n) => n.textContent)
91
+ expect(events).toEqual(['Email', 'Call']) // only d1's activities, newest date first
92
+ })
93
+
94
+ it('mounts against the real CRM deals + activities sample', () => {
95
+ const proj = getSampleApp('crm')!.build()
96
+ const deals = proj.entities.find((e) => e.name === 'deals')
97
+ const activities = proj.entities.find((e) => e.name === 'activities')
98
+ const dealRows = (proj.dataSources!.deals as { seed?: Record<string, unknown>[] }).seed!
99
+ const actRows = (proj.dataSources!.activities as { seed?: Record<string, unknown>[] }).seed!
100
+ const el = render({
101
+ schema: deals, rows: dealRows, titleField: 'title', subtitleField: 'companyId', statusField: 'stage', metricFields: ['value', 'probability'],
102
+ related: [{ label: 'Activities', schema: activities, rows: actRows, foreignKey: 'dealId', titleField: 'subject', dateField: 'dueDate', statusField: 'priority' }],
103
+ })
104
+ expect(el.querySelector('.sv-detail__title')?.textContent).toBeTruthy()
105
+ expect(el.querySelectorAll('.sv-detail__metric').length).toBe(2)
106
+ expect([...el.querySelectorAll('.sv-detail__tab')].map((n) => n.textContent)).toEqual(['Overview', 'Activities'])
107
+ })
108
+
109
+ it('matches a related timeline by parentField when the child has no FK id (denormalized view)', () => {
110
+ const productsSchema: EntitySchema = {
111
+ name: 'products', idField: 'id',
112
+ fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'name', type: 'text' }],
113
+ }
114
+ const linesSchema: EntitySchema = {
115
+ name: 'order_lines', idField: 'id',
116
+ fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'product', type: 'text' }, { field: 'customer', type: 'text' }, { field: 'line_total', type: 'number', label: 'Total ($)' }],
117
+ }
118
+ const rows = [{ id: 1, name: 'Chai' }, { id: 2, name: 'Chang' }]
119
+ // The view carries the product NAME (not its id), so the timeline matches name.
120
+ const lines = [
121
+ { id: 10, product: 'Chai', customer: 'Alfreds', line_total: 90 },
122
+ { id: 11, product: 'Chang', customer: 'Berglunds', line_total: 40 },
123
+ { id: 12, product: 'Chai', customer: "B's", line_total: 54 },
124
+ ]
125
+ const el = render({
126
+ schema: productsSchema, rows, titleField: 'name',
127
+ related: [{ label: 'Orders', schema: linesSchema, rows: lines, foreignKey: 'product', parentField: 'name', titleField: 'customer', subtitleField: 'line_total' }],
128
+ })
129
+ const ordersTab = [...el.querySelectorAll<HTMLButtonElement>('.sv-detail__tab')].find((b) => b.textContent === 'Orders')!
130
+ ordersTab.click()
131
+ flushSync()
132
+ // Opens on Chai -> its two lines (matched by name, not id), $ money-formatted.
133
+ const events = [...el.querySelectorAll('.sv-detail__event-title')].map((n) => n.textContent)
134
+ expect(events.sort()).toEqual(['Alfreds', "B's"])
135
+ expect(el.textContent).toContain('$90')
136
+ })
137
+ })
@@ -0,0 +1,288 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvRecordDetail - a full record "detail page" over an entity's rows: a header
4
+ * (title + subtitle + colored status pill + a few metric tiles + a record
5
+ * switcher), then tabbed sections - an Overview of grouped label/value pairs plus
6
+ * one tab per related child collection (Activity / Related), each a timeline of
7
+ * the child rows that point back at this record. The universal signature view for
8
+ * relation-heavy entities (a customer / deal / patient / trip page), where a board
9
+ * or calendar does not fit.
10
+ *
11
+ * Pure presentation over plain rows + an EntitySchema (same inputs as SvBoard /
12
+ * SvSchemaChart), so the generated app and the designer preview render it
13
+ * identically. When `row` is omitted it self-drives off `rows` (defaults to the
14
+ * first, switchable via the header dropdown).
15
+ */
16
+ import type { EntitySchema, EntityField } from './schema'
17
+
18
+ type Row = Record<string, unknown>
19
+ type Section = { label: string; fields: string[] }
20
+ type Related = {
21
+ label: string
22
+ schema: EntitySchema
23
+ rows?: ReadonlyArray<Row>
24
+ /** The child field pointing back at this record. */
25
+ foreignKey: string
26
+ /** The parent field the child's `foreignKey` matches (default: the id) - lets a
27
+ * denormalized view relate by name / company when it has no FK ids. */
28
+ parentField?: string
29
+ titleField: string
30
+ subtitleField?: string
31
+ dateField?: string
32
+ statusField?: string
33
+ }
34
+
35
+ let {
36
+ schema,
37
+ rows = [],
38
+ row,
39
+ titleField,
40
+ subtitleField,
41
+ statusField,
42
+ metricFields = [],
43
+ sections = [],
44
+ related = [],
45
+ selectedId,
46
+ loading = false,
47
+ height,
48
+ }: {
49
+ schema: EntitySchema
50
+ rows?: ReadonlyArray<Row>
51
+ /** The specific record to show. Omit to self-drive off `rows`. */
52
+ row?: Row
53
+ /** Initial record to open, by id (e.g. from a URL `?id=` param). Switchable. */
54
+ selectedId?: string
55
+ /** Show a skeleton during first load. */
56
+ loading?: boolean
57
+ titleField: string
58
+ subtitleField?: string
59
+ /** Enum field rendered as a colored status pill in the header. */
60
+ statusField?: string
61
+ /** A few (numeric) fields shown as header stat tiles. */
62
+ metricFields?: string[]
63
+ /** Overview field groups. Empty -> one auto "Details" group. */
64
+ sections?: Section[]
65
+ /** Related child collections, one tab each. */
66
+ related?: Related[]
67
+ height?: number
68
+ } = $props()
69
+
70
+ const idField = $derived(schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id')
71
+ const fieldOf = (name?: string): EntityField | undefined => (name ? schema.fields.find((f) => f.field === name) : undefined)
72
+ const titleCase = (s: string) => s.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ').replace(/^\w/, (c) => c.toUpperCase()).replace(/\bId\b/, '').trim()
73
+ const labelOf = (name: string) => fieldOf(name)?.label ?? titleCase(name)
74
+ const isMoneyName = (s: string) => /\$|price|amount|total|cost|value|revenue|fee|salary|balance|subtotal|budget/i.test(s)
75
+
76
+ /** Format a value against its field: enum -> option label, money numbers -> $,
77
+ * else localized. Works for any schema so parent + child rows format alike. */
78
+ function fmtField(f: EntityField | undefined, v: unknown): string {
79
+ if (v == null || v === '') return '-'
80
+ if (f?.options?.length) { const o = f.options.find((x) => String(x.value) === String(v)); if (o) return o.label ?? String(v) }
81
+ if (typeof v === 'number') return isMoneyName(f?.label ?? f?.field ?? '') ? '$' + v.toLocaleString(undefined, { maximumFractionDigits: 0 }) : v.toLocaleString()
82
+ if (typeof v === 'boolean') return v ? 'Yes' : 'No'
83
+ if (Array.isArray(v)) return v.join(', ')
84
+ return String(v)
85
+ }
86
+
87
+ let picked = $state<string | null>(null)
88
+ // A URL-driven `selectedId` seeds the selection (and follows navigation to a new
89
+ // id); a manual switch via the header dropdown then overrides it.
90
+ $effect(() => { picked = selectedId ?? null })
91
+ const current = $derived.by<Row | undefined>(() => {
92
+ if (row) return row
93
+ if (picked != null) { const r = rows.find((x) => String(x[idField]) === picked); if (r) return r }
94
+ return rows[0]
95
+ })
96
+ const currentId = $derived(current ? String(current[idField]) : '')
97
+
98
+ const fmt = (name: string, r: Row | undefined): string => fmtField(fieldOf(name), r?.[name])
99
+ const childField = (rel: Related, name?: string): EntityField | undefined => (name ? rel.schema.fields.find((f) => f.field === name) : undefined)
100
+ const fmtChild = (rel: Related, name: string, r: Row): string => fmtField(childField(rel, name), r[name])
101
+
102
+ function optOf(field: string | undefined, r: Row | undefined): { label: string; color: string } | undefined {
103
+ if (!field || !r) return undefined
104
+ const v = String(r[field] ?? '')
105
+ if (!v) return undefined
106
+ const o = fieldOf(field)?.options?.find((x) => String(x.value) === v)
107
+ return { label: o?.label ?? v, color: o?.color ?? 'var(--sg-muted, #64748b)' }
108
+ }
109
+ const status = $derived(optOf(statusField, current))
110
+
111
+ // Overview groups: explicit sections, else one auto "Details" of the remaining
112
+ // non-key fields (skipping ones already surfaced in the header).
113
+ const shown = $derived(new Set([titleField, subtitleField, statusField, ...metricFields].filter(Boolean) as string[]))
114
+ const groups = $derived.by<Section[]>(() => {
115
+ if (sections.length) return sections
116
+ const pk = idField
117
+ const fields = schema.fields.filter((f) => f.field !== pk && !shown.has(f.field) && !(typeof f.hidden === 'object' && f.hidden.form) && f.hidden !== true).map((f) => f.field)
118
+ return fields.length ? [{ label: 'Details', fields }] : []
119
+ })
120
+
121
+ const tabs = $derived(['Overview', ...related.map((r) => r.label)])
122
+ let tab = $state(0)
123
+ $effect(() => { if (tab >= tabs.length) tab = 0 })
124
+
125
+ function childRows(rel: Related): Row[] {
126
+ const key = String(current?.[rel.parentField ?? idField] ?? '')
127
+ const list = (rel.rows ?? []).filter((c) => String(c[rel.foreignKey] ?? '') === key)
128
+ if (rel.dateField) list.sort((a, b) => String(b[rel.dateField!] ?? '').localeCompare(String(a[rel.dateField!] ?? '')))
129
+ return list
130
+ }
131
+ function childOpt(rel: Related, r: Row): { label: string; color: string } | undefined {
132
+ if (!rel.statusField) return undefined
133
+ const v = String(r[rel.statusField] ?? '')
134
+ const o = rel.schema.fields.find((f) => f.field === rel.statusField)?.options?.find((x) => String(x.value) === v)
135
+ return v ? { label: o?.label ?? v, color: o?.color ?? 'var(--sg-accent, #6366f1)' } : undefined
136
+ }
137
+ function fmtDate(v: unknown): string {
138
+ if (v == null || v === '') return ''
139
+ const s = String(v)
140
+ const d = new Date(s)
141
+ return isNaN(d.getTime()) ? s : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
142
+ }
143
+ </script>
144
+
145
+ <div class="sv-detail" style:height={height != null ? `${height}px` : undefined}>
146
+ {#if loading && !current}
147
+ <div class="sv-detail__sk sv-detail__sk--title"></div>
148
+ <div class="sv-detail__sk sv-detail__sk--sub"></div>
149
+ <div class="sv-detail__metrics">
150
+ {#each [0, 1, 2] as m (m)}<div class="sv-detail__metric sv-detail__sk sv-detail__sk--metric"></div>{/each}
151
+ </div>
152
+ <div class="sv-detail__sk sv-detail__sk--row"></div>
153
+ <div class="sv-detail__sk sv-detail__sk--row"></div>
154
+ {:else if !current}
155
+ <p class="sv-detail__hint">No record to show.</p>
156
+ {:else}
157
+ <header class="sv-detail__head" style:--accent={status?.color ?? 'var(--sg-accent, #6366f1)'}>
158
+ <div class="sv-detail__head-main">
159
+ <div class="sv-detail__titlerow">
160
+ <h2 class="sv-detail__title">{fmt(titleField, current)}</h2>
161
+ {#if status}<span class="sv-detail__status" style:--pill={status.color}>{status.label}</span>{/if}
162
+ </div>
163
+ {#if subtitleField}<div class="sv-detail__subtitle">{fmt(subtitleField, current)}</div>{/if}
164
+ </div>
165
+ {#if !row && rows.length > 1}
166
+ <label class="sv-detail__switch">
167
+ <span>Record</span>
168
+ <select value={currentId} onchange={(e) => (picked = (e.currentTarget as HTMLSelectElement).value)}>
169
+ {#each rows as r (String(r[idField]))}
170
+ <option value={String(r[idField])}>{fmt(titleField, r)}</option>
171
+ {/each}
172
+ </select>
173
+ </label>
174
+ {/if}
175
+ </header>
176
+
177
+ {#if metricFields.length}
178
+ <div class="sv-detail__metrics">
179
+ {#each metricFields as m (m)}
180
+ <div class="sv-detail__metric">
181
+ <span class="sv-detail__metric-label">{labelOf(m)}</span>
182
+ <span class="sv-detail__metric-value">{fmt(m, current)}</span>
183
+ </div>
184
+ {/each}
185
+ </div>
186
+ {/if}
187
+
188
+ {#if tabs.length > 1}
189
+ <div class="sv-detail__tabs" role="tablist">
190
+ {#each tabs as t, i (t)}
191
+ <button class="sv-detail__tab" class:is-active={tab === i} role="tab" aria-selected={tab === i} onclick={() => (tab = i)}>{t}</button>
192
+ {/each}
193
+ </div>
194
+ {/if}
195
+
196
+ <div class="sv-detail__body">
197
+ {#if tab === 0}
198
+ {#each groups as g (g.label)}
199
+ <section class="sv-detail__section">
200
+ {#if groups.length > 1 || g.label !== 'Details'}<h3 class="sv-detail__section-head">{g.label}</h3>{/if}
201
+ <dl class="sv-detail__fields">
202
+ {#each g.fields as f (f)}
203
+ <div class="sv-detail__field">
204
+ <dt>{labelOf(f)}</dt>
205
+ <dd>{fmt(f, current)}</dd>
206
+ </div>
207
+ {/each}
208
+ </dl>
209
+ </section>
210
+ {/each}
211
+ {#if groups.length === 0}<p class="sv-detail__hint">No fields to show.</p>{/if}
212
+ {:else}
213
+ {@const rel = related[tab - 1]}
214
+ {@const list = childRows(rel)}
215
+ {#if list.length === 0}
216
+ <p class="sv-detail__hint">No {rel.label.toLowerCase()} yet.</p>
217
+ {:else}
218
+ <ul class="sv-detail__timeline">
219
+ {#each list as c (String(c[rel.schema.idField ?? 'id']))}
220
+ {@const co = childOpt(rel, c)}
221
+ <li class="sv-detail__event" style:--dot={co?.color ?? 'var(--sg-accent, #6366f1)'}>
222
+ <span class="sv-detail__event-dot"></span>
223
+ <div class="sv-detail__event-main">
224
+ <div class="sv-detail__event-title">{fmtChild(rel, rel.titleField, c) || '(untitled)'}</div>
225
+ {#if rel.subtitleField && c[rel.subtitleField] != null && c[rel.subtitleField] !== ''}
226
+ <div class="sv-detail__event-sub">{fmtChild(rel, rel.subtitleField, c)}</div>
227
+ {/if}
228
+ </div>
229
+ {#if co}<span class="sv-detail__event-status" style:--pill={co.color}>{co.label}</span>{/if}
230
+ {#if rel.dateField}<time class="sv-detail__event-date">{fmtDate(c[rel.dateField])}</time>{/if}
231
+ </li>
232
+ {/each}
233
+ </ul>
234
+ {/if}
235
+ {/if}
236
+ </div>
237
+ {/if}
238
+ </div>
239
+
240
+ <style>
241
+ .sv-detail {
242
+ display: flex; flex-direction: column; gap: 14px; box-sizing: border-box;
243
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
244
+ padding: 18px 20px; overflow: auto; min-height: 220px;
245
+ }
246
+ .sv-detail__head { display: flex; align-items: flex-start; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); position: relative; }
247
+ .sv-detail__head::before { content: ''; position: absolute; left: -20px; top: -18px; bottom: 0; width: 4px; background: var(--accent, var(--sg-accent, #6366f1)); border-radius: 0 3px 3px 0; }
248
+ .sv-detail__head-main { flex: 1; min-width: 0; }
249
+ .sv-detail__titlerow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
250
+ .sv-detail__title { margin: 0; font-size: 20px; font-weight: 700; color: var(--sg-fg, #0f172a); line-height: 1.2; }
251
+ .sv-detail__subtitle { margin-top: 4px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
252
+ .sv-detail__status { padding: 3px 11px; font-size: 12px; font-weight: 700; border-radius: 999px; color: var(--pill, #64748b); background: color-mix(in srgb, var(--pill, #64748b) 14%, transparent); white-space: nowrap; }
253
+ .sv-detail__switch { display: flex; flex-direction: column; gap: 3px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #94a3b8); }
254
+ .sv-detail__switch select { font: inherit; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; padding: 6px 10px; max-width: 220px; }
255
+ .sv-detail__metrics { display: flex; flex-wrap: wrap; gap: 10px; }
256
+ .sv-detail__metric { flex: 1 1 120px; min-width: 110px; display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; background: var(--sg-header-bg, #f6f7f9); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; }
257
+ .sv-detail__metric-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #94a3b8); }
258
+ .sv-detail__metric-value { font-size: 19px; font-weight: 700; color: var(--sg-fg, #0f172a); }
259
+ .sv-detail__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
260
+ .sv-detail__tab { appearance: none; border: none; background: none; font: inherit; font-size: 13px; font-weight: 600; color: var(--sg-muted, #64748b); padding: 8px 12px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
261
+ .sv-detail__tab:hover { color: var(--sg-fg, #0f172a); }
262
+ .sv-detail__tab.is-active { color: var(--sg-accent, #6366f1); border-bottom-color: var(--sg-accent, #6366f1); }
263
+ .sv-detail__body { min-height: 60px; }
264
+ .sv-detail__section + .sv-detail__section { margin-top: 18px; }
265
+ .sv-detail__section-head { margin: 0 0 10px; font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
266
+ .sv-detail__fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 20px; margin: 0; }
267
+ .sv-detail__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
268
+ .sv-detail__field dt { font-size: 11.5px; font-weight: 600; color: var(--sg-muted, #94a3b8); }
269
+ .sv-detail__field dd { margin: 0; font-size: 14px; color: var(--sg-fg, #0f172a); overflow: hidden; text-overflow: ellipsis; }
270
+ .sv-detail__timeline { list-style: none; margin: 0; padding: 4px 0 0; display: flex; flex-direction: column; }
271
+ .sv-detail__event { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--sg-border, #eef0f3); position: relative; }
272
+ .sv-detail__event:last-child { border-bottom: none; }
273
+ .sv-detail__event-dot { flex: none; width: 9px; height: 9px; margin-top: 5px; border-radius: 50%; background: var(--dot, var(--sg-accent, #6366f1)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot, #6366f1) 18%, transparent); }
274
+ .sv-detail__event-main { flex: 1; min-width: 0; }
275
+ .sv-detail__event-title { font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); }
276
+ .sv-detail__event-sub { margin-top: 2px; font-size: 12px; color: var(--sg-muted, #64748b); }
277
+ .sv-detail__event-status { flex: none; padding: 2px 8px; font-size: 11px; font-weight: 700; border-radius: 999px; color: var(--pill, #64748b); background: color-mix(in srgb, var(--pill, #64748b) 14%, transparent); }
278
+ .sv-detail__event-date { flex: none; font-size: 12px; color: var(--sg-muted, #94a3b8); white-space: nowrap; }
279
+ .sv-detail__hint { color: var(--sg-muted, #64748b); font-size: 13px; padding: 18px 4px; }
280
+ /* First-load skeleton. */
281
+ .sv-detail__sk { position: relative; overflow: hidden; border-radius: 8px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); }
282
+ .sv-detail__sk::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--sg-bg, #fff) 55%, transparent), transparent); animation: sv-detail-sheen 1.2s ease-in-out infinite; }
283
+ .sv-detail__sk--title { width: 240px; max-width: 60%; height: 22px; }
284
+ .sv-detail__sk--sub { width: 160px; max-width: 40%; height: 13px; margin-top: 2px; }
285
+ .sv-detail__sk--metric { height: 58px; }
286
+ .sv-detail__sk--row { height: 46px; margin-top: 4px; }
287
+ @keyframes sv-detail-sheen { 100% { transform: translateX(100%); } }
288
+ </style>
@@ -0,0 +1,57 @@
1
+ /**
2
+ * DOM test: SvSchedule renders a month grid anchored to the earliest event, places
3
+ * each dated row as an event chip on its day, and paginates months.
4
+ */
5
+ import { describe, it, expect, afterEach } from 'vitest'
6
+ import { mount, unmount, flushSync } from 'svelte'
7
+ import SvSchedule from './SvSchedule.svelte'
8
+ import type { EntitySchema } from './schema'
9
+
10
+ const schema: EntitySchema = {
11
+ name: 'events',
12
+ idField: 'id',
13
+ fields: [
14
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
15
+ { field: 'name', type: 'text' },
16
+ { field: 'startAt', type: 'datetime' },
17
+ { field: 'category', type: 'enum', options: [
18
+ { value: 'conf', label: 'Conference', color: '#6366f1' },
19
+ { value: 'workshop', label: 'Workshop', color: '#10b981' },
20
+ ] },
21
+ ],
22
+ }
23
+
24
+ let host: HTMLElement | null = null
25
+ let comp: ReturnType<typeof mount> | null = null
26
+ afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
27
+ function render(props: Record<string, unknown>): HTMLElement {
28
+ host = document.createElement('div')
29
+ document.body.appendChild(host)
30
+ comp = mount(SvSchedule, { target: host, props })
31
+ flushSync()
32
+ return host
33
+ }
34
+
35
+ describe('SvSchedule (DOM)', () => {
36
+ const rows = [
37
+ { id: 'e1', name: 'Svelte Summit', startAt: '2026-09-12T09:00:00Z', category: 'conf' },
38
+ { id: 'e2', name: 'A11y Workshop', startAt: '2026-09-20T10:00:00Z', category: 'workshop' },
39
+ ]
40
+
41
+ it('anchors to the earliest event month and renders its events', () => {
42
+ const el = render({ schema, rows, dateField: 'startAt', titleField: 'name', colorField: 'category' })
43
+ expect(el.querySelector('.sv-sched__month')?.textContent).toContain('September 2026')
44
+ expect(el.querySelectorAll('.sv-sched__cell').length).toBe(42) // 6 weeks
45
+ const evs = [...el.querySelectorAll('.sv-sched__ev')].map((n) => n.textContent?.trim())
46
+ expect(evs).toContain('Svelte Summit')
47
+ expect(evs).toContain('A11y Workshop')
48
+ })
49
+
50
+ it('paginates to the next month (events drop off)', () => {
51
+ const el = render({ schema, rows, dateField: 'startAt', titleField: 'name' })
52
+ el.querySelector<HTMLButtonElement>('[aria-label="Next month"]')!.click()
53
+ flushSync()
54
+ expect(el.querySelector('.sv-sched__month')?.textContent).toContain('October 2026')
55
+ expect(el.querySelectorAll('.sv-sched__ev').length).toBe(0)
56
+ })
57
+ })