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