@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,156 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvSchedule - a month event-calendar over an entity's rows. Each row with a
4
+ * `dateField` value is placed as an event chip on its day; `titleField` is the
5
+ * chip label and an optional enum `colorField` tints it. A real scheduling view
6
+ * (appointments / events / bookings / shifts) - the signature calendar that makes
7
+ * a Studio app read like a product, not a report.
8
+ *
9
+ * Pure presentation over plain rows + an EntitySchema (same inputs as SvBoard /
10
+ * SvSchemaChart), so the generated app and the designer preview match.
11
+ */
12
+ import type { EntitySchema } from './schema'
13
+
14
+ type Row = Record<string, unknown>
15
+
16
+ let {
17
+ schema,
18
+ rows = [],
19
+ dateField,
20
+ titleField,
21
+ colorField,
22
+ onSelect,
23
+ loading = false,
24
+ height,
25
+ }: {
26
+ schema: EntitySchema
27
+ rows?: ReadonlyArray<Row>
28
+ /** The date / datetime field that places a row on the calendar. */
29
+ dateField: string
30
+ /** Field shown as the event chip label. */
31
+ titleField: string
32
+ /** Optional enum field whose option color tints the chip. */
33
+ colorField?: string
34
+ onSelect?: (id: string) => void
35
+ /** Dim the grid + show a loading note during first load. */
36
+ loading?: boolean
37
+ height?: number
38
+ } = $props()
39
+
40
+ const idField = $derived(schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id')
41
+ const colorMap = $derived.by(() => {
42
+ const m = new Map<string, string>()
43
+ schema.fields.find((f) => f.field === colorField)?.options?.forEach((o) => { if (o.color) m.set(String(o.value), o.color) })
44
+ return m
45
+ })
46
+
47
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
48
+ const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1)
49
+ const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1)
50
+ const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
51
+ function parse(v: unknown): Date | null {
52
+ if (v == null || v === '') return null
53
+ const d = new Date(String(v))
54
+ return Number.isNaN(d.getTime()) ? null : d
55
+ }
56
+
57
+ const events = $derived(rows.map((r) => ({ r, d: parse(r[dateField]) })).filter((e): e is { r: Row; d: Date } => e.d != null))
58
+ // Anchor the view to the BUSIEST month (most events) so the calendar always opens
59
+ // on data, not an empty month; ties break to the earliest. Falls back to today.
60
+ const anchor = $derived.by(() => {
61
+ if (events.length === 0) return startOfMonth(new Date())
62
+ const buckets = new Map<string, { start: Date; n: number }>()
63
+ for (const e of events) {
64
+ const key = `${e.d.getFullYear()}-${e.d.getMonth()}`
65
+ const cur = buckets.get(key)
66
+ if (cur) cur.n += 1
67
+ else buckets.set(key, { start: startOfMonth(e.d), n: 1 })
68
+ }
69
+ return [...buckets.values()].sort((a, b) => b.n - a.n || a.start.getTime() - b.start.getTime())[0]!.start
70
+ })
71
+ let offset = $state(0)
72
+ const viewMonth = $derived(addMonths(anchor, offset))
73
+ const monthLabel = $derived(viewMonth.toLocaleString(undefined, { month: 'long', year: 'numeric' }))
74
+
75
+ const days = $derived.by(() => {
76
+ const first = viewMonth
77
+ const start = new Date(first)
78
+ start.setDate(1 - first.getDay()) // back to the Sunday on/before the 1st
79
+ return Array.from({ length: 42 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return d })
80
+ })
81
+ const eventsOn = (d: Date) => events.filter((e) => sameDay(e.d, d))
82
+ const today = new Date()
83
+ </script>
84
+
85
+ <div class="sv-sched" style:height={height != null ? `${height}px` : undefined}>
86
+ <header class="sv-sched__bar">
87
+ <strong class="sv-sched__month">{monthLabel}</strong>
88
+ <div class="sv-sched__nav">
89
+ <button type="button" class="sv-sched__btn" aria-label="Previous month" onclick={() => (offset -= 1)}>&lsaquo;</button>
90
+ <button type="button" class="sv-sched__btn sv-sched__today" onclick={() => (offset = 0)}>Today</button>
91
+ <button type="button" class="sv-sched__btn" aria-label="Next month" onclick={() => (offset += 1)}>&rsaquo;</button>
92
+ </div>
93
+ </header>
94
+ <div class="sv-sched__weekdays">
95
+ {#each WEEKDAYS as w (w)}<span class="sv-sched__wd">{w}</span>{/each}
96
+ </div>
97
+ <div class="sv-sched__grid">
98
+ {#each days as d (d.getTime())}
99
+ {@const evs = eventsOn(d)}
100
+ {@const outside = d.getMonth() !== viewMonth.getMonth()}
101
+ <div class="sv-sched__cell" class:is-outside={outside} class:is-today={sameDay(d, today)}>
102
+ <span class="sv-sched__daynum">{d.getDate()}</span>
103
+ <div class="sv-sched__evs">
104
+ {#each evs.slice(0, 3) as e (String(e.r[idField]))}
105
+ {@const c = colorMap.get(String(e.r[colorField ?? ''])) ?? 'var(--sg-accent, #6366f1)'}
106
+ {#if onSelect}
107
+ <button type="button" class="sv-sched__ev sv-sched__ev--btn" style:--c={c} title={String(e.r[titleField] ?? '')} onclick={() => onSelect(String(e.r[idField]))}>
108
+ <span class="sv-sched__ev-dot"></span>{String(e.r[titleField] ?? '(untitled)')}
109
+ </button>
110
+ {:else}
111
+ <span class="sv-sched__ev" style:--c={c} title={String(e.r[titleField] ?? '')}>
112
+ <span class="sv-sched__ev-dot"></span>{String(e.r[titleField] ?? '(untitled)')}
113
+ </span>
114
+ {/if}
115
+ {/each}
116
+ {#if evs.length > 3}<span class="sv-sched__more">+{evs.length - 3} more</span>{/if}
117
+ </div>
118
+ </div>
119
+ {/each}
120
+ </div>
121
+ {#if loading}
122
+ <div class="sv-sched__overlay"><span class="sv-sched__spinner" aria-hidden="true"></span>Loading…</div>
123
+ {:else if rows.length === 0}
124
+ <div class="sv-sched__overlay">No events scheduled yet.</div>
125
+ {/if}
126
+ </div>
127
+
128
+ <style>
129
+ .sv-sched {
130
+ position: relative;
131
+ display: flex; flex-direction: column; min-height: 420px; box-sizing: border-box;
132
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; overflow: hidden;
133
+ }
134
+ .sv-sched__overlay { position: absolute; inset: 46px 0 0; display: flex; align-items: center; justify-content: center; gap: 10px; background: color-mix(in srgb, var(--sg-bg, #fff) 72%, transparent); backdrop-filter: blur(1px); color: var(--sg-muted, #64748b); font-size: 13.5px; }
135
+ .sv-sched__spinner { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--sg-border, #e6e8ec); border-top-color: var(--sg-accent, #6366f1); animation: sv-sched-spin 0.7s linear infinite; }
136
+ @keyframes sv-sched-spin { to { transform: rotate(360deg); } }
137
+ .sv-sched__ev--btn { appearance: none; width: 100%; text-align: left; border: none; font: inherit; }
138
+ .sv-sched__bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
139
+ .sv-sched__month { font-size: 15px; font-weight: 750; color: var(--sg-fg, #0f172a); }
140
+ .sv-sched__nav { display: flex; gap: 4px; }
141
+ .sv-sched__btn { min-width: 30px; height: 30px; padding: 0 10px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-header-bg, #f6f7f9); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; cursor: pointer; }
142
+ .sv-sched__btn:hover { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, var(--sg-header-bg, #f6f7f9)); }
143
+ .sv-sched__weekdays { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--sg-border, #e6e8ec); }
144
+ .sv-sched__wd { padding: 7px 8px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--sg-muted, #94a3b8); text-align: right; }
145
+ .sv-sched__grid { flex: 1; display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 1fr; min-height: 0; }
146
+ .sv-sched__cell { border-right: 1px solid var(--sg-border, #eef0f3); border-bottom: 1px solid var(--sg-border, #eef0f3); padding: 4px 5px; min-height: 62px; display: flex; flex-direction: column; gap: 3px; overflow: hidden; }
147
+ .sv-sched__cell:nth-child(7n) { border-right: 0; }
148
+ .sv-sched__cell.is-outside { background: color-mix(in srgb, var(--sg-header-bg, #f6f7f9) 55%, transparent); }
149
+ .sv-sched__daynum { align-self: flex-end; font-size: 11.5px; font-weight: 600; color: var(--sg-muted, #64748b); padding: 1px 3px; }
150
+ .sv-sched__cell.is-outside .sv-sched__daynum { color: var(--sg-muted, #b6bdc7); }
151
+ .sv-sched__cell.is-today .sv-sched__daynum { background: var(--sg-accent, #6366f1); color: #fff; border-radius: 999px; min-width: 18px; text-align: center; }
152
+ .sv-sched__evs { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
153
+ .sv-sched__ev { display: flex; align-items: center; gap: 5px; padding: 2px 6px; font-size: 11px; font-weight: 600; border-radius: 5px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--c, var(--sg-accent, #6366f1)); background: color-mix(in srgb, var(--c, #6366f1) 13%, transparent); }
154
+ .sv-sched__ev-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--c, var(--sg-accent, #6366f1)); flex: none; }
155
+ .sv-sched__more { font-size: 10.5px; font-weight: 600; color: var(--sg-muted, #94a3b8); padding: 0 4px; }
156
+ </style>
@@ -0,0 +1,233 @@
1
+ <script lang="ts" module>
2
+ type ChartRow = Record<string, unknown>
3
+ </script>
4
+
5
+ <script lang="ts" generics="TData extends ChartRow">
6
+ /**
7
+ * SvSchemaChart - a reusable chart bound to an `EntitySchema`. It picks
8
+ * chart-able fields from the schema (dimensions to group by, measures to
9
+ * reduce), renders built-in controls, and draws with `SvGridChart`.
10
+ *
11
+ * Aggregation is either client-side (pass `rows`) or server-side (pass
12
+ * `getAggregate`, e.g. a SQL / Supabase source's `GROUP BY`), so it scales
13
+ * past what a page holds. Click a category to drill (`onDrill`).
14
+ */
15
+ import { SvGridChart, SvGridDropdown, type ChartSpec, type ChartType, type ChartSelection } from '@svgrid/grid'
16
+ import { titleCase, type EntitySchema } from './schema'
17
+ import {
18
+ aggregateRows,
19
+ chartFieldsFromSchema,
20
+ type AggregateBucket,
21
+ type AggregateReduce,
22
+ type AggregateRequest,
23
+ } from './sources/aggregate'
24
+
25
+ type FilterModel = AggregateRequest['filterModel']
26
+
27
+ type Props = {
28
+ schema: EntitySchema<TData>
29
+ /** Client-side data to aggregate. Ignored when `getAggregate` is set. */
30
+ rows?: ReadonlyArray<TData>
31
+ /** Server-side aggregation (a data source's `getAggregate`). Takes precedence. */
32
+ getAggregate?: (request: AggregateRequest) => Promise<AggregateBucket[]>
33
+ /** Filter passed to `getAggregate`. */
34
+ filterModel?: FilterModel
35
+ dimension?: string
36
+ measure?: string
37
+ reduce?: AggregateReduce
38
+ type?: ChartType
39
+ /** Show the built-in dimension / measure / type controls. Default true. */
40
+ controls?: boolean
41
+ height?: number
42
+ width?: number
43
+ dataLabels?: boolean
44
+ formatValue?: (v: number) => string
45
+ onDrill?: (category: string, dimension: string) => void
46
+ /** Bump to re-fetch server aggregates (e.g. after a mutation). */
47
+ refreshKey?: unknown
48
+ /** Brand color. Bars / lines use it; pie slices grouped by an enum use the
49
+ * enum's own option colors (matching status badges). */
50
+ accent?: string
51
+ }
52
+
53
+ let {
54
+ schema,
55
+ rows,
56
+ getAggregate,
57
+ filterModel,
58
+ dimension,
59
+ measure,
60
+ reduce,
61
+ type,
62
+ controls = true,
63
+ height = 300,
64
+ width = 460,
65
+ dataLabels = true,
66
+ formatValue,
67
+ onDrill,
68
+ refreshKey,
69
+ accent,
70
+ }: Props = $props()
71
+
72
+ const fields = $derived(chartFieldsFromSchema(schema as EntitySchema))
73
+
74
+ // Render the chart at its ACTUAL pixel width so the SVG viewBox maps 1:1 to
75
+ // screen pixels (scale factor = 1). Otherwise the SVG stretches to fill a wide
76
+ // block and its text (font-size in user units) scales up with it - "fonts that
77
+ // grow with the chart". Measured width keeps labels at a fixed px size, like
78
+ // the grid. Falls back to the `width` prop until the first measurement.
79
+ let measuredW = $state(0)
80
+ let plotEl: HTMLDivElement | undefined = $state()
81
+ $effect(() => {
82
+ const el = plotEl
83
+ if (!el || typeof ResizeObserver === 'undefined') return
84
+ const ro = new ResizeObserver((entries) => {
85
+ const w = Math.round(entries[0]?.contentRect.width ?? 0)
86
+ if (w > 0 && w !== measuredW) measuredW = w
87
+ })
88
+ ro.observe(el)
89
+ return () => ro.disconnect()
90
+ })
91
+
92
+ let dim = $state('')
93
+ let msr = $state('')
94
+ let red = $state<AggregateReduce>('sum')
95
+ let ctype = $state<ChartType>('bar')
96
+ let seeded = false
97
+
98
+ // Seed once from the props, then keep the dimension/measure valid for the schema.
99
+ $effect(() => {
100
+ if (!seeded) {
101
+ seeded = true
102
+ if (dimension) dim = dimension
103
+ if (measure) msr = measure
104
+ if (reduce) red = reduce
105
+ if (type) ctype = type
106
+ }
107
+ if (!dim || !fields.dimensions.includes(dim)) dim = fields.defaultDimension ?? fields.dimensions[0] ?? ''
108
+ if ((!msr || !fields.measures.includes(msr)) && fields.measures.length) msr = fields.defaultMeasure ?? fields.measures[0] ?? ''
109
+ })
110
+
111
+ const labelOf = (field: string) => schema.fields.find((f) => f.field === field)?.label ?? titleCase(field)
112
+ const measureLabel = $derived(
113
+ red === 'count' ? 'Count' : `${red[0]!.toUpperCase() + red.slice(1)} ${labelOf(msr)}`,
114
+ )
115
+
116
+ let serverBuckets = $state<AggregateBucket[]>([])
117
+ let fetchToken = 0
118
+ $effect(() => {
119
+ if (!getAggregate || !dim) return
120
+ void refreshKey // re-run after a mutation
121
+ const req: AggregateRequest = { dimension: dim, measure: msr || undefined, reduce: red, filterModel }
122
+ const token = ++fetchToken
123
+ getAggregate(req).then((bs) => {
124
+ if (token === fetchToken) serverBuckets = bs
125
+ })
126
+ })
127
+
128
+ const buckets = $derived(
129
+ getAggregate
130
+ ? serverBuckets
131
+ : dim
132
+ ? aggregateRows((rows ?? []) as ReadonlyArray<ChartRow>, { dimension: dim, measure: msr || undefined, reduce: red })
133
+ : [],
134
+ )
135
+
136
+ // Options for the custom dropdown controls.
137
+ const dimOptions = $derived(fields.dimensions.map((d) => ({ value: d, label: labelOf(d) })))
138
+ const measureOptions = $derived(fields.measures.map((m) => ({ value: m, label: labelOf(m) })))
139
+ const reduceOptions = [
140
+ { value: 'count', label: 'Count' }, { value: 'sum', label: 'Sum' }, { value: 'avg', label: 'Average' },
141
+ { value: 'min', label: 'Min' }, { value: 'max', label: 'Max' },
142
+ ]
143
+ const typeOptions = [
144
+ { value: 'bar', label: 'Bar' }, { value: 'pie', label: 'Pie' }, { value: 'line', label: 'Line' }, { value: 'area', label: 'Area' },
145
+ { value: 'radar', label: 'Radar' }, { value: 'funnel', label: 'Funnel' }, { value: 'waterfall', label: 'Waterfall' }, { value: 'treemap', label: 'Treemap' },
146
+ ]
147
+ // Cartesian types get x/y axis titles; radial / area-partition types don't.
148
+ const CARTESIAN_TYPES = new Set<ChartType>(['bar', 'line', 'area', 'waterfall'])
149
+
150
+ // Per-category colors from the dimension's enum options (status/stage/etc), in
151
+ // bucket order, so a pie's slices match the grid's badge colors.
152
+ const categoryColors = $derived.by<string[] | undefined>(() => {
153
+ const opts = schema.fields.find((f) => f.field === dim)?.options
154
+ if (!opts?.length) return undefined
155
+ return buckets.map((b) => {
156
+ const opt = opts.find((o) => String(o.value) === String(b.category))
157
+ return opt?.color || accent || '#6366f1'
158
+ })
159
+ })
160
+
161
+ const spec = $derived.by<ChartSpec>(() => {
162
+ const s: ChartSpec = {
163
+ type: ctype,
164
+ categories: buckets.map((b) => b.category),
165
+ // Bars / lines / areas take the brand accent; pie ignores series color and
166
+ // uses the palette (semantic enum colors when available).
167
+ series: [{ label: measureLabel, values: buckets.map((b) => b.value), ...(accent ? { color: accent } : {}) }],
168
+ width: measuredW || width,
169
+ height,
170
+ ...(categoryColors ? { palette: categoryColors } : {}),
171
+ }
172
+ if (ctype === 'pie') s.innerRadius = 0.6
173
+ // Treemap reads a tree, not a category/series pair - build a flat one from
174
+ // the same buckets (each dimension value is a leaf sized by the measure).
175
+ if (ctype === 'treemap') s.treemap = { name: measureLabel, children: buckets.map((b) => ({ name: b.category, value: b.value })) }
176
+ if (CARTESIAN_TYPES.has(ctype)) {
177
+ s.xAxisTitle = labelOf(dim)
178
+ s.yAxisTitle = measureLabel
179
+ }
180
+ return s
181
+ })
182
+
183
+ function onSelect(sel: ChartSelection) {
184
+ onDrill?.(String(sel.category), dim)
185
+ }
186
+ </script>
187
+
188
+ <div class="sv-chart">
189
+ {#if controls}
190
+ <div class="sv-chart__controls">
191
+ <div class="sv-chart__ctrl">
192
+ <span>Group by</span>
193
+ <div class="sv-chart__dd"><SvGridDropdown options={dimOptions} value={dim} autoOpen={false} onChange={(v) => (dim = String(v))} /></div>
194
+ </div>
195
+ <div class="sv-chart__ctrl">
196
+ <span>Measure</span>
197
+ <div class="sv-chart__dd"><SvGridDropdown options={reduceOptions} value={red} autoOpen={false} onChange={(v) => (red = v as AggregateReduce)} /></div>
198
+ </div>
199
+ {#if red !== 'count' && fields.measures.length}
200
+ <div class="sv-chart__ctrl">
201
+ <span>of</span>
202
+ <div class="sv-chart__dd"><SvGridDropdown options={measureOptions} value={msr} autoOpen={false} onChange={(v) => (msr = String(v))} /></div>
203
+ </div>
204
+ {/if}
205
+ <div class="sv-chart__ctrl">
206
+ <span>Type</span>
207
+ <div class="sv-chart__dd"><SvGridDropdown options={typeOptions} value={ctype} autoOpen={false} onChange={(v) => (ctype = v as ChartType)} /></div>
208
+ </div>
209
+ </div>
210
+ {/if}
211
+
212
+ {#if dim}
213
+ <div class="sv-chart__plot" bind:this={plotEl}>
214
+ <SvGridChart {spec} {dataLabels} {formatValue} {onSelect} />
215
+ </div>
216
+ {:else}
217
+ <p class="sv-chart__empty">No chart-able fields in this schema.</p>
218
+ {/if}
219
+ </div>
220
+
221
+ <style>
222
+ .sv-chart { display: flex; flex-direction: column; gap: 10px; }
223
+ .sv-chart__plot { width: 100%; min-width: 0; }
224
+ .sv-chart__controls { display: flex; flex-wrap: wrap; gap: 10px; }
225
+ .sv-chart__ctrl { display: flex; flex-direction: column; gap: 3px; font-size: 11px; font-weight: 600; color: var(--sg-muted, #666); }
226
+ .sv-chart__dd {
227
+ position: relative; height: 32px; min-width: 130px;
228
+ border: 1px solid var(--sg-border, #ccc); border-radius: 8px; background: var(--sg-input-bg, #fff);
229
+ }
230
+ .sv-chart__dd :global(.sv-grid-dropdown),
231
+ .sv-chart__dd :global(.sv-grid-dropdown-trigger) { background: transparent; border-radius: 8px; }
232
+ .sv-chart__empty { color: var(--sg-muted, #888); font-size: 13px; padding: 24px; text-align: center; }
233
+ </style>
@@ -0,0 +1,130 @@
1
+ <script lang="ts" module>
2
+ type DashRow = Record<string, unknown>
3
+ </script>
4
+
5
+ <script lang="ts" generics="TData extends DashRow">
6
+ /**
7
+ * SvSchemaDashboard - a schema-driven dashboard: KPI tiles + charts composed
8
+ * from a declarative `DashboardSpec` (defaults to `dashboardFromSchema`). It's
9
+ * a data *view*, not a page builder - the spec is plain data and every chart is
10
+ * an `SvSchemaChart` with its controls off. KPI tiles reduce over the passed
11
+ * `rows`; charts prefer a server `getAggregate` when given, else `rows`.
12
+ */
13
+ import SvSchemaChart from './SvSchemaChart.svelte'
14
+ import type { EntitySchema } from './schema'
15
+ import { dashboardFromSchema, reduceValue, type DashboardSpec } from './sources/dashboard'
16
+ import type { AggregateBucket, AggregateRequest, AggregateReduce } from './sources/aggregate'
17
+
18
+ type Props = {
19
+ schema: EntitySchema<TData>
20
+ /** Rows for KPI tiles (and charts when there's no `getAggregate`). */
21
+ rows?: ReadonlyArray<TData>
22
+ /**
23
+ * Server-side aggregation (a data source's `getAggregate`). When set, KPI
24
+ * tiles AND charts are computed server-side (KPIs via a dimension-less
25
+ * request), so they're correct over the whole table, not just a page.
26
+ */
27
+ getAggregate?: (request: AggregateRequest) => Promise<AggregateBucket[]>
28
+ /** Filter applied to every widget (KPIs + charts), same shape the grid emits. */
29
+ filterModel?: AggregateRequest['filterModel']
30
+ /** Widget layout. Defaults to a sensible set from the schema. */
31
+ spec?: DashboardSpec
32
+ /** Bump to re-fetch server aggregates after a mutation. */
33
+ refreshKey?: unknown
34
+ onDrill?: (category: string, dimension: string) => void
35
+ title?: string
36
+ }
37
+
38
+ let { schema, rows, getAggregate, filterModel, spec, refreshKey, onDrill, title }: Props = $props()
39
+
40
+ const widgets = $derived((spec ?? dashboardFromSchema(schema as EntitySchema)).widgets)
41
+ const data = $derived((rows ?? []) as ReadonlyArray<TData>)
42
+
43
+ const compact = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
44
+ const fmt = (v: number, f?: (v: number) => string) => (f ? f(v) : compact.format(v))
45
+
46
+ // Server KPIs: when `getAggregate` is set, fetch each KPI as a dimension-less
47
+ // aggregate (a grand total over the whole table), re-running on refresh/filter.
48
+ let serverKpis = $state<number[]>([])
49
+ let kpiToken = 0
50
+ $effect(() => {
51
+ if (!getAggregate) return
52
+ void refreshKey
53
+ const reqs = widgets.map((w) =>
54
+ w.kind === 'kpi' ? getAggregate({ measure: w.measure, reduce: w.reduce, filterModel }) : null,
55
+ )
56
+ const token = ++kpiToken
57
+ Promise.all(reqs.map((r) => (r ? r.then((bs) => bs[0]?.value ?? 0) : Promise.resolve(0)))).then((vals) => {
58
+ if (token === kpiToken) serverKpis = vals
59
+ })
60
+ })
61
+ const kpiValue = (w: { measure?: string; reduce: AggregateReduce }, i: number) =>
62
+ getAggregate ? (serverKpis[i] ?? 0) : reduceValue(data, { measure: w.measure, reduce: w.reduce })
63
+ </script>
64
+
65
+ <section class="dash">
66
+ {#if title}<h3 class="dash__title">{title}</h3>{/if}
67
+ <div class="dash__grid">
68
+ {#each widgets as w, i (i)}
69
+ {#if w.kind === 'kpi'}
70
+ <div class="dash__kpi">
71
+ <span class="dash__kpi-label">{w.label}</span>
72
+ <span class="dash__kpi-value">{fmt(kpiValue(w, i), w.format)}</span>
73
+ </div>
74
+ {:else}
75
+ <div class="dash__chart" style="grid-column: span {Math.min(w.span ?? 2, 3)}">
76
+ {#if w.label}<span class="dash__chart-label">{w.label}</span>{/if}
77
+ <SvSchemaChart
78
+ schema={schema as EntitySchema}
79
+ rows={getAggregate ? undefined : (data as ReadonlyArray<DashRow>)}
80
+ {getAggregate}
81
+ {filterModel}
82
+ dimension={w.dimension}
83
+ measure={w.measure}
84
+ reduce={w.reduce}
85
+ type={w.type}
86
+ controls={false}
87
+ height={230}
88
+ width={420}
89
+ {refreshKey}
90
+ onDrill={(category, dimension) => onDrill?.(category, dimension)}
91
+ />
92
+ </div>
93
+ {/if}
94
+ {/each}
95
+ </div>
96
+ </section>
97
+
98
+ <style>
99
+ .dash { display: flex; flex-direction: column; gap: 12px; }
100
+ .dash__title { margin: 0; font-size: 15px; font-weight: 650; color: var(--sg-fg, #0f172a); }
101
+ .dash__grid {
102
+ display: grid;
103
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
104
+ /* Size rows to content; otherwise implicit auto rows stretch to fill a
105
+ bounded-height container and shrink below each widget, overlapping them. */
106
+ grid-auto-rows: max-content;
107
+ gap: 12px;
108
+ align-items: start;
109
+ }
110
+ .dash__kpi {
111
+ display: flex; flex-direction: column; gap: 6px;
112
+ padding: 16px 16px 18px;
113
+ border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
114
+ background: var(--sg-bg, #fff);
115
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
116
+ }
117
+ .dash__kpi-label { font-size: 12.5px; font-weight: 550; color: var(--sg-muted, #64748b); }
118
+ .dash__kpi-value { font-size: 26px; font-weight: 720; letter-spacing: -0.02em; color: var(--sg-fg, #0f172a); }
119
+ .dash__chart {
120
+ display: flex; flex-direction: column; gap: 8px;
121
+ padding: 14px 14px 12px; min-width: 0;
122
+ border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
123
+ background: var(--sg-bg, #fff);
124
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
125
+ }
126
+ .dash__chart-label { font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); }
127
+ @media (max-width: 620px) {
128
+ .dash__chart { grid-column: span 1 !important; }
129
+ }
130
+ </style>
@@ -0,0 +1,74 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Capture exactly what exportGrid receives (so we don't need pdfmake / the DOM).
4
+ const calls: Array<{ format: string; rows?: unknown[]; groupBy?: string[] }> = []
5
+ vi.mock('./export', () => ({
6
+ exportGrid: async (_api: unknown, opts: { format: string; rows?: unknown[]; groupBy?: string[] }) => {
7
+ calls.push({ format: opts.format, rows: opts.rows as unknown[], groupBy: opts.groupBy })
8
+ },
9
+ }))
10
+
11
+ import { aiExport, setAIProvider, mockAIProvider } from './ai'
12
+ import { setLicenseKey } from './license'
13
+
14
+ type Order = { country: string; company: string; product: string; quantity: number; price: number }
15
+ const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
16
+ country: ['USA', 'UK', 'Germany'][i % 3]!,
17
+ company: `Co${i}`,
18
+ product: 'Widget',
19
+ quantity: 5 + (i % 50),
20
+ price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
21
+ }))
22
+
23
+ function makeApi() {
24
+ return {
25
+ getData: () => rows,
26
+ // Simulate the grid returning EVERYTHING (the reactive-filter bug we route around).
27
+ getDisplayedRows: () => rows,
28
+ getSelectedRows: () => [],
29
+ getColumns: () => [
30
+ { id: 'country', field: 'country', header: 'Country', visible: true },
31
+ { id: 'company', field: 'company', header: 'Company', visible: true },
32
+ { id: 'product', field: 'product', header: 'Product', visible: true },
33
+ { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
34
+ { id: 'price', field: 'price', header: 'Price', visible: true },
35
+ ],
36
+ getState: () => ({ grouping: [] }),
37
+ clearAllFilters: vi.fn(),
38
+ clearSort: vi.fn(),
39
+ setFilter: vi.fn(),
40
+ setSort: vi.fn(),
41
+ setGroupBy: vi.fn(),
42
+ } as any
43
+ }
44
+
45
+ beforeEach(() => {
46
+ calls.length = 0
47
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
48
+ setAIProvider(mockAIProvider)
49
+ })
50
+
51
+ describe('aiExport - the demo 203 "grouped PDF" query', () => {
52
+ it('parses the plan and exports non-empty, filtered, grouped rows', async () => {
53
+ const plan = await aiExport(makeApi(), 'export orders over $300 as a grouped PDF by country')
54
+
55
+ expect(plan.format).toBe('pdf')
56
+ expect(plan.groupBy).toEqual(['country'])
57
+ expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
58
+
59
+ // exportGrid was called with real, filtered rows (not the empty grid view).
60
+ expect(calls).toHaveLength(1)
61
+ expect(calls[0]!.format).toBe('pdf')
62
+ expect(calls[0]!.groupBy).toEqual(['country'])
63
+ const exported = calls[0]!.rows as Order[]
64
+ expect(exported.length).toBeGreaterThan(0)
65
+ expect(exported.every((r) => r.price > 300)).toBe(true)
66
+ // Rows are contiguous by country (group order preserved).
67
+ const countries = exported.map((r) => r.country)
68
+ const firstSeen = [...new Set(countries)]
69
+ const contiguous = countries.join('|')
70
+ for (const c of firstSeen) {
71
+ expect(contiguous).toMatch(new RegExp(`(?:^|\\|)${c}(?:\\|${c})*(?:\\||$)`))
72
+ }
73
+ })
74
+ })
@@ -0,0 +1,87 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Real exportGrid runs (NOT mocked). Capture the xlsx parts / avoid the DOM.
4
+ const captured: { parts: Record<string, string> } = { parts: {} }
5
+ vi.mock('jszip', () => ({
6
+ default: class {
7
+ file(p: string, d: string) {
8
+ captured.parts[p] = d
9
+ }
10
+ async generateAsync() {
11
+ return new Blob([])
12
+ }
13
+ },
14
+ }))
15
+ vi.mock('./export-serialize', async (importOriginal) => {
16
+ const actual = (await importOriginal()) as Record<string, unknown>
17
+ return { ...actual, downloadBlobFile: () => {}, downloadTextFile: () => {} }
18
+ })
19
+
20
+ import { aiExport, setAIProvider, mockAIProvider } from './ai'
21
+ import { setLicenseKey } from './license'
22
+
23
+ // Mirrors the real makeOrders shape - crucially includes `inStock` (boolean)
24
+ // and `quantity`/`index` (numbers that never exceed 300), so the mock must
25
+ // target `price` for "$300", not a boolean or a count.
26
+ type Order = {
27
+ id: string
28
+ index: number
29
+ country: string
30
+ company: string
31
+ product: string
32
+ inStock: boolean
33
+ quantity: number
34
+ price: number
35
+ }
36
+ const rows: Order[] = Array.from({ length: 120 }, (_, i) => ({
37
+ id: `o${i}`,
38
+ index: i + 1,
39
+ country: ['USA', 'UK', 'Germany'][i % 3]!,
40
+ company: `Co${i}`,
41
+ product: 'Widget',
42
+ inStock: i % 3 !== 0,
43
+ quantity: 5 + (i % 50), // max 54 - never > 300
44
+ price: Math.round((10 + ((i * 41) % 480)) * 100) / 100, // spread 10..490
45
+ }))
46
+
47
+ function makeApi() {
48
+ return {
49
+ getData: () => rows,
50
+ getDisplayedRows: () => rows,
51
+ getSelectedRows: () => [],
52
+ getColumns: () => [
53
+ { id: 'country', field: 'country', header: 'Country', visible: true },
54
+ { id: 'company', field: 'company', header: 'Company', visible: true },
55
+ { id: 'product', field: 'product', header: 'Product', visible: true },
56
+ { id: 'quantity', field: 'quantity', header: 'Qty', visible: true },
57
+ { id: 'price', field: 'price', header: 'Price', visible: true, format: { type: 'currency', currency: 'USD' } },
58
+ ],
59
+ getState: () => ({ grouping: [] }),
60
+ getColumnPinning: () => ({ left: [], right: [] }),
61
+ clearAllFilters: vi.fn(),
62
+ clearSort: vi.fn(),
63
+ setFilter: vi.fn(),
64
+ setSort: vi.fn(),
65
+ setGroupBy: vi.fn(),
66
+ } as any
67
+ }
68
+
69
+ beforeEach(() => {
70
+ captured.parts = {}
71
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
72
+ setAIProvider(mockAIProvider)
73
+ })
74
+
75
+ describe('aiExport - "export orders over $300" (xlsx default)', () => {
76
+ it('produces a non-empty xlsx via the real export path', async () => {
77
+ const plan = await aiExport(makeApi(), 'export orders over $300')
78
+ expect(plan.format).toBe('xlsx')
79
+ expect(plan.filters).toContainEqual({ field: 'price', operator: 'greaterThan', value: '300' })
80
+
81
+ const sheet = captured.parts['xl/worksheets/sheet1.xml']
82
+ expect(sheet).toBeTruthy()
83
+ // header row + at least one data row
84
+ const rowCount = (sheet!.match(/<row /g) ?? []).length
85
+ expect(rowCount).toBeGreaterThan(1)
86
+ })
87
+ })