@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,473 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema'
|
|
3
|
+
import {
|
|
4
|
+
addBlock,
|
|
5
|
+
duplicateBlock,
|
|
6
|
+
blockColumns,
|
|
7
|
+
addBlockAt,
|
|
8
|
+
addEntity,
|
|
9
|
+
addScreen,
|
|
10
|
+
addScreenFromTemplate,
|
|
11
|
+
parseProject,
|
|
12
|
+
screenFromTemplate,
|
|
13
|
+
serializeProject,
|
|
14
|
+
setTheme,
|
|
15
|
+
blockPalette,
|
|
16
|
+
createProject,
|
|
17
|
+
defaultBlockConfig,
|
|
18
|
+
defaultScreenFor,
|
|
19
|
+
gridColumns,
|
|
20
|
+
moveBlock,
|
|
21
|
+
removeBlock,
|
|
22
|
+
removeEntity,
|
|
23
|
+
reorderBlock,
|
|
24
|
+
sanitizeProject,
|
|
25
|
+
setEntityDataSource,
|
|
26
|
+
entityDataSource,
|
|
27
|
+
defaultEntitySource,
|
|
28
|
+
setShell,
|
|
29
|
+
updateBlock,
|
|
30
|
+
updateEntity,
|
|
31
|
+
updateScreen,
|
|
32
|
+
duplicateScreen,
|
|
33
|
+
reorderScreen,
|
|
34
|
+
insertBlock,
|
|
35
|
+
validateProject,
|
|
36
|
+
addTab,
|
|
37
|
+
removeTab,
|
|
38
|
+
renameTab,
|
|
39
|
+
addTabBlock,
|
|
40
|
+
removeTabBlock,
|
|
41
|
+
flattenBlocks,
|
|
42
|
+
TAB_CHILD_KINDS,
|
|
43
|
+
type MasterDetailConfig,
|
|
44
|
+
type TabsConfig,
|
|
45
|
+
type StudioProject,
|
|
46
|
+
} from './project'
|
|
47
|
+
|
|
48
|
+
const customers: EntitySchema = {
|
|
49
|
+
name: 'customers', label: 'Customer', idField: 'id',
|
|
50
|
+
fields: [
|
|
51
|
+
{ field: 'id', type: 'text', primaryKey: true, hidden: { form: true } },
|
|
52
|
+
{ field: 'name', type: 'text' },
|
|
53
|
+
{ field: 'secret', type: 'text', hidden: { grid: true } },
|
|
54
|
+
{ field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
|
|
55
|
+
{ field: 'mrr', type: 'number' },
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
const orders: EntitySchema = {
|
|
59
|
+
name: 'orders', label: 'Order', idField: 'id',
|
|
60
|
+
fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'total', type: 'number' }],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Tabs container ops', () => {
|
|
64
|
+
const base = () => defaultBlockConfig('tabs', customers) as TabsConfig
|
|
65
|
+
|
|
66
|
+
it('defaults to two empty tabs', () => {
|
|
67
|
+
const cfg = base()
|
|
68
|
+
expect(cfg.tabs.map((t) => t.label)).toEqual(['Overview', 'Details'])
|
|
69
|
+
expect(cfg.tabs.every((t) => t.blocks.length === 0)).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('adds, renames, and removes tabs (keeps at least one)', () => {
|
|
73
|
+
let cfg = addTab(base(), 'Extra')
|
|
74
|
+
expect(cfg.tabs).toHaveLength(3)
|
|
75
|
+
cfg = renameTab(cfg, 0, 'Home')
|
|
76
|
+
expect(cfg.tabs[0]!.label).toBe('Home')
|
|
77
|
+
cfg = removeTab(cfg, 2)
|
|
78
|
+
expect(cfg.tabs).toHaveLength(2)
|
|
79
|
+
const one = removeTab(removeTab(cfg, 1), 0)
|
|
80
|
+
expect(one.tabs).toHaveLength(1) // never drops the last tab
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('adds only allowed child kinds, with unique ids, and removes them', () => {
|
|
84
|
+
let cfg = addTabBlock(base(), 0, 'chart', customers)
|
|
85
|
+
cfg = addTabBlock(cfg, 0, 'kpi', customers)
|
|
86
|
+
expect(cfg.tabs[0]!.blocks.map((b) => b.config.kind)).toEqual(['chart', 'kpi'])
|
|
87
|
+
const ids = cfg.tabs[0]!.blocks.map((b) => b.id)
|
|
88
|
+
expect(new Set(ids).size).toBe(2) // unique ids
|
|
89
|
+
// grid is controller-bound -> rejected inside tabs
|
|
90
|
+
expect(addTabBlock(cfg, 0, 'grid', customers)).toBe(cfg)
|
|
91
|
+
expect(TAB_CHILD_KINDS).not.toContain('grid')
|
|
92
|
+
const removed = removeTabBlock(cfg, 0, ids[0]!)
|
|
93
|
+
expect(removed.tabs[0]!.blocks.map((b) => b.config.kind)).toEqual(['kpi'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('flattenBlocks surfaces blocks nested in tabs', () => {
|
|
97
|
+
const cfg = addTabBlock(addTabBlock(base(), 0, 'chart', customers), 1, 'gauge', customers)
|
|
98
|
+
const block = { id: 'tabs-1', span: 3 as const, config: cfg }
|
|
99
|
+
const kinds = flattenBlocks([block]).map((b) => b.config.kind)
|
|
100
|
+
expect(kinds).toEqual(['tabs', 'chart', 'gauge'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('updateBlock / removeBlock reach a block nested inside a tab', () => {
|
|
104
|
+
// A screen whose only block is a Tabs container with a chart in tab 0.
|
|
105
|
+
let p = createProject([customers])
|
|
106
|
+
const sid = p.screens[0]!.id
|
|
107
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [] } : s)) }
|
|
108
|
+
p = addBlock(p, sid, 'tabs')
|
|
109
|
+
const tb = p.screens.find((s) => s.id === sid)!.blocks[0]!
|
|
110
|
+
p = updateBlock(p, sid, tb.id, { config: addTabBlock(tb.config as TabsConfig, 0, 'chart', customers) })
|
|
111
|
+
const childId = (p.screens.find((s) => s.id === sid)!.blocks[0]!.config as TabsConfig).tabs[0]!.blocks[0]!.id
|
|
112
|
+
|
|
113
|
+
// Update the nested chart's config through the normal updateBlock path.
|
|
114
|
+
p = updateBlock(p, sid, childId, { config: { measure: 'mrr', reduce: 'avg' } as Partial<import('./project').ChartConfig> })
|
|
115
|
+
const child = (p.screens.find((s) => s.id === sid)!.blocks[0]!.config as TabsConfig).tabs[0]!.blocks[0]!
|
|
116
|
+
expect(child.config).toMatchObject({ kind: 'chart', measure: 'mrr', reduce: 'avg' })
|
|
117
|
+
|
|
118
|
+
// Remove the nested chart; the tab is left empty, the container survives.
|
|
119
|
+
p = removeBlock(p, sid, childId)
|
|
120
|
+
expect((p.screens.find((s) => s.id === sid)!.blocks[0]!.config as TabsConfig).tabs[0]!.blocks).toHaveLength(0)
|
|
121
|
+
expect(p.screens.find((s) => s.id === sid)!.blocks).toHaveLength(1) // container intact
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('createProject / defaultScreenFor', () => {
|
|
126
|
+
it('makes one default screen per entity, each with a grid (editing = form)', () => {
|
|
127
|
+
const p = createProject([customers, orders])
|
|
128
|
+
expect(p.screens.map((s) => s.entity)).toEqual(['customers', 'orders'])
|
|
129
|
+
const screen = p.screens[0]!
|
|
130
|
+
expect(screen.blocks.map((b) => b.config.kind)).toEqual(['grid']) // editing is a grid property now
|
|
131
|
+
expect((screen.blocks[0]!.config as import('./project').GridConfig).editing).toBe('form')
|
|
132
|
+
expect(p.dataSource).toBe('memory')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('grid columns include non-grid-hidden fields, shown', () => {
|
|
136
|
+
const cols = gridColumns(customers)
|
|
137
|
+
expect(cols.map((c) => c.field)).toEqual(['id', 'name', 'tier', 'mrr']) // secret (hidden.grid) excluded
|
|
138
|
+
expect(cols.find((c) => c.field === 'id')!.show).toBe(false) // raw id column hidden by default
|
|
139
|
+
expect(cols.filter((c) => c.field !== 'id').every((c) => c.show)).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('a chart block defaults to a low-cardinality dimension + numeric measure', () => {
|
|
143
|
+
const cfg = defaultBlockConfig('chart', customers)
|
|
144
|
+
expect(cfg).toMatchObject({ kind: 'chart', dimension: 'tier', measure: 'mrr', reduce: 'sum', type: 'bar' })
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('block ops', () => {
|
|
149
|
+
const base = createProject([customers])
|
|
150
|
+
const sid = base.screens[0]!.id
|
|
151
|
+
|
|
152
|
+
it('adds, removes, moves, reorders, and updates blocks immutably', () => {
|
|
153
|
+
const added = addBlock(base, sid, 'chart')
|
|
154
|
+
expect(added).not.toBe(base)
|
|
155
|
+
expect(added.screens[0]!.blocks.map((b) => b.config.kind)).toEqual(['grid', 'chart'])
|
|
156
|
+
|
|
157
|
+
const chartId = added.screens[0]!.blocks[1]!.id
|
|
158
|
+
const removed = removeBlock(added, sid, chartId)
|
|
159
|
+
expect(removed.screens[0]!.blocks).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
const moved = moveBlock(added, sid, chartId, -1)
|
|
162
|
+
expect(moved.screens[0]!.blocks.map((b) => b.config.kind)).toEqual(['chart', 'grid'])
|
|
163
|
+
|
|
164
|
+
const reordered = reorderBlock(added, sid, chartId, 0)
|
|
165
|
+
expect(reordered.screens[0]!.blocks[0]!.id).toBe(chartId)
|
|
166
|
+
|
|
167
|
+
const gridId = base.screens[0]!.blocks[0]!.id
|
|
168
|
+
const patched = updateBlock(base, sid, gridId, { span: 2, config: { pageSize: 25 } as Partial<import('./project').GridConfig> })
|
|
169
|
+
const grid = patched.screens[0]!.blocks[0]!
|
|
170
|
+
expect(grid.span).toBe(2)
|
|
171
|
+
expect((grid.config as import('./project').GridConfig).pageSize).toBe(25)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('duplicateBlock clones config with a fresh id, right after the original', () => {
|
|
175
|
+
const base = createProject([customers])
|
|
176
|
+
const sid = base.screens[0]!.id
|
|
177
|
+
const gridId = base.screens[0]!.blocks[0]!.id
|
|
178
|
+
const dup = duplicateBlock(base, sid, gridId)
|
|
179
|
+
const blocks = dup.screens[0]!.blocks
|
|
180
|
+
expect(blocks).toHaveLength(2)
|
|
181
|
+
expect(blocks[1]!.id).not.toBe(gridId) // fresh id
|
|
182
|
+
expect(blocks[1]!.config.kind).toBe('grid') // same kind
|
|
183
|
+
expect(blocks[1]!.config).not.toBe(blocks[0]!.config) // deep-cloned config
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('blockColumns maps legacy span to 12-col and honors colSpan', () => {
|
|
187
|
+
expect(blockColumns({ span: 1 })).toBe(4)
|
|
188
|
+
expect(blockColumns({ span: 3 })).toBe(12)
|
|
189
|
+
expect(blockColumns({ span: 1, colSpan: 5 })).toBe(5) // colSpan overrides
|
|
190
|
+
expect(blockColumns({ span: 2, colSpan: 99 })).toBe(12) // clamped
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('addBlockAt inserts at the given index (palette drop)', () => {
|
|
194
|
+
const p = addBlockAt(base, sid, 'chart', 0) // before the grid
|
|
195
|
+
expect(p.screens[0]!.blocks.map((b) => b.config.kind)).toEqual(['chart', 'grid'])
|
|
196
|
+
const end = addBlockAt(base, sid, 'kpi', 99) // clamps to end
|
|
197
|
+
expect(end.screens[0]!.blocks.at(-1)!.config.kind).toBe('kpi')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('gives every added block a unique id', () => {
|
|
201
|
+
let p = createProject([customers])
|
|
202
|
+
p = addBlock(p, sid, 'chart')
|
|
203
|
+
p = addBlock(p, sid, 'chart')
|
|
204
|
+
const ids = p.screens[0]!.blocks.map((b) => b.id)
|
|
205
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('entity + screen ops', () => {
|
|
210
|
+
it('adds/removes an entity with its screen', () => {
|
|
211
|
+
let p = createProject([customers])
|
|
212
|
+
p = addEntity(p, orders)
|
|
213
|
+
expect(p.entities).toHaveLength(2)
|
|
214
|
+
expect(p.screens.some((s) => s.entity === 'orders')).toBe(true)
|
|
215
|
+
p = removeEntity(p, 'orders')
|
|
216
|
+
expect(p.entities).toHaveLength(1)
|
|
217
|
+
expect(p.screens.some((s) => s.entity === 'orders')).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('updateEntity retargets screens when the entity is renamed', () => {
|
|
221
|
+
const p = createProject([customers])
|
|
222
|
+
const renamed = updateEntity(p, 'customers', { ...customers, name: 'clients' })
|
|
223
|
+
expect(renamed.entities[0]!.name).toBe('clients')
|
|
224
|
+
expect(renamed.screens[0]!.entity).toBe('clients')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('updateEntity retargets a master-detail childEntity when the child is renamed', () => {
|
|
228
|
+
let p = createProject([customers, orders])
|
|
229
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
230
|
+
p = addBlock(p, sid, 'master-detail')
|
|
231
|
+
const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
|
|
232
|
+
p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'id' } as Partial<MasterDetailConfig> })
|
|
233
|
+
const renamed = updateEntity(p, 'orders', { ...orders, name: 'sales' })
|
|
234
|
+
const md = renamed.screens.find((s) => s.id === sid)!.blocks.find((b) => b.id === mdId)!
|
|
235
|
+
expect((md.config as MasterDetailConfig).childEntity).toBe('sales')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('a second screen for an entity gets a unique id', () => {
|
|
239
|
+
const p = addScreen(createProject([customers]), 'customers')
|
|
240
|
+
const ids = p.screens.map((s) => s.id)
|
|
241
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('updateScreen patches title/route', () => {
|
|
245
|
+
const p = createProject([customers])
|
|
246
|
+
const up = updateScreen(p, p.screens[0]!.id, { title: 'People', route: 'people' })
|
|
247
|
+
expect(up.screens[0]).toMatchObject({ title: 'People', route: 'people' })
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('validateProject', () => {
|
|
252
|
+
it('addScreen dedupes the route so two screens for one entity do not collide', () => {
|
|
253
|
+
const p = addScreen(createProject([customers]), 'customers')
|
|
254
|
+
expect(new Set(p.screens.map((s) => s.route)).size).toBe(2) // customers, customers-1
|
|
255
|
+
expect(validateProject(p).some((i) => /Duplicate route/.test(i.message))).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('flags a genuine duplicate route and a missing-entity screen', () => {
|
|
259
|
+
const p = createProject([customers])
|
|
260
|
+
const twoSameRoute = { ...p, screens: [p.screens[0]!, { ...p.screens[0]!, id: 'dup' }] }
|
|
261
|
+
const dup = validateProject(twoSameRoute).find((i) => /Duplicate route/.test(i.message))
|
|
262
|
+
expect(dup?.level).toBe('error')
|
|
263
|
+
|
|
264
|
+
const broken = { ...p, screens: [{ ...p.screens[0]!, entity: 'ghosts' }] }
|
|
265
|
+
expect(validateProject(broken).some((i) => /missing entity/.test(i.message))).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('the palette lists every block kind', () => {
|
|
269
|
+
expect(blockPalette.map((p) => p.kind)).toContain('grid')
|
|
270
|
+
expect(blockPalette.map((p) => p.kind)).toContain('dashboard')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('warns when a master-detail points at a missing child entity', () => {
|
|
274
|
+
let p = createProject([customers])
|
|
275
|
+
const sid = p.screens[0]!.id
|
|
276
|
+
p = addBlock(p, sid, 'master-detail')
|
|
277
|
+
const mdId = p.screens[0]!.blocks.at(-1)!.id
|
|
278
|
+
p = updateBlock(p, sid, mdId, { config: { childEntity: 'ghosts', foreignKey: 'id' } as Partial<MasterDetailConfig> })
|
|
279
|
+
expect(validateProject(p).some((i) => /missing child entity "ghosts"/.test(i.message))).toBe(true)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('attributes a block issue to its screen + block id (for inline badges + jump)', () => {
|
|
283
|
+
let p = createProject([customers])
|
|
284
|
+
const sid = p.screens[0]!.id
|
|
285
|
+
p = addBlock(p, sid, 'master-detail') // unconfigured -> warns
|
|
286
|
+
const mdId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
|
|
287
|
+
const issue = validateProject(p).find((i) => i.block === mdId)
|
|
288
|
+
expect(issue).toBeDefined()
|
|
289
|
+
expect(issue!.screen).toBe(sid)
|
|
290
|
+
expect(issue!.message).toMatch(/child entity/)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('flags an empty filter panel and a tree without its fields', () => {
|
|
294
|
+
let p = createProject([customers])
|
|
295
|
+
const sid = p.screens[0]!.id
|
|
296
|
+
// A tree with no label/parent field warns.
|
|
297
|
+
p = addBlock(p, sid, 'tree')
|
|
298
|
+
const treeId = p.screens.find((s) => s.id === sid)!.blocks.at(-1)!.id
|
|
299
|
+
p = updateBlock(p, sid, treeId, { config: { labelField: '', parentField: '' } as Partial<import('./project').TreeConfig> })
|
|
300
|
+
const treeIssue = validateProject(p).find((i) => i.block === treeId)
|
|
301
|
+
expect(treeIssue?.message).toMatch(/label field/)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('block/screen efficiency ops', () => {
|
|
306
|
+
it('insertBlock pastes a block into a screen with a fresh id', () => {
|
|
307
|
+
let p = createProject([customers, orders])
|
|
308
|
+
const src = p.screens.find((s) => s.entity === 'customers')!.blocks[0]! // the default grid
|
|
309
|
+
const target = p.screens.find((s) => s.entity === 'orders')!.id
|
|
310
|
+
const before = p.screens.find((s) => s.id === target)!.blocks.length
|
|
311
|
+
p = insertBlock(p, target, src)
|
|
312
|
+
const blocks = p.screens.find((s) => s.id === target)!.blocks
|
|
313
|
+
expect(blocks).toHaveLength(before + 1)
|
|
314
|
+
expect(blocks.at(-1)!.id).not.toBe(src.id) // fresh id, no collision
|
|
315
|
+
expect(blocks.at(-1)!.config.kind).toBe(src.config.kind)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('duplicateScreen clones a screen (fresh id/route/title + fresh block ids), inserted after', () => {
|
|
319
|
+
let p = createProject([customers])
|
|
320
|
+
const sid = p.screens[0]!.id
|
|
321
|
+
p = duplicateScreen(p, sid)
|
|
322
|
+
expect(p.screens).toHaveLength(2)
|
|
323
|
+
const [a, b] = p.screens
|
|
324
|
+
expect(b!.id).not.toBe(a!.id)
|
|
325
|
+
expect(b!.route).not.toBe(a!.route)
|
|
326
|
+
expect(b!.title).toMatch(/copy/)
|
|
327
|
+
// Block ids in the clone are unique vs the source.
|
|
328
|
+
const aIds = new Set(a!.blocks.map((x) => x.id))
|
|
329
|
+
expect(b!.blocks.every((x) => !aIds.has(x.id))).toBe(true)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('reorderScreen moves a screen to a new index', () => {
|
|
333
|
+
let p = createProject([customers, orders])
|
|
334
|
+
const [first, second] = p.screens
|
|
335
|
+
p = reorderScreen(p, first!.id, 1)
|
|
336
|
+
expect(p.screens.map((s) => s.id)).toEqual([second!.id, first!.id])
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('sanitizeProject (loaded-config hardening)', () => {
|
|
341
|
+
it('is idempotent on a clean project', () => {
|
|
342
|
+
const p = createProject([customers, orders])
|
|
343
|
+
expect(sanitizeProject(p)).toEqual(p)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('dedupes duplicate field names, screen ids, routes, and block ids', () => {
|
|
347
|
+
const bad: StudioProject = {
|
|
348
|
+
title: 'x', dataSource: 'memory',
|
|
349
|
+
entities: [{ name: 'a', idField: 'id', fields: [{ field: 'id', type: 'text' }, { field: 'id', type: 'text' }] }],
|
|
350
|
+
screens: [
|
|
351
|
+
{ id: 's', entity: 'a', title: 'S1', route: 'r', blocks: [
|
|
352
|
+
{ id: 'b', span: 1, config: { kind: 'form', presentation: 'modal' } },
|
|
353
|
+
{ id: 'b', span: 1, config: { kind: 'form', presentation: 'modal' } },
|
|
354
|
+
] },
|
|
355
|
+
{ id: 's', entity: 'a', title: 'S2', route: 'r', blocks: [] },
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
const clean = sanitizeProject(bad)
|
|
359
|
+
expect(new Set(clean.entities[0]!.fields.map((f) => f.field)).size).toBe(2)
|
|
360
|
+
expect(new Set(clean.screens.map((s) => s.id)).size).toBe(2)
|
|
361
|
+
expect(new Set(clean.screens.map((s) => s.route)).size).toBe(2)
|
|
362
|
+
expect(new Set(clean.screens[0]!.blocks.map((b) => b.id)).size).toBe(2)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('drops a duplicate-named entity (the first survives, keeping the name)', () => {
|
|
366
|
+
const clean = sanitizeProject({ ...createProject([customers]), entities: [customers, { ...customers, label: 'Dup' }] })
|
|
367
|
+
expect(clean.entities).toHaveLength(1)
|
|
368
|
+
expect(clean.entities[0]!.label).toBe('Customer')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('parseProject sanitizes a dup-id config so keyed lists cannot collide', () => {
|
|
372
|
+
const raw = JSON.stringify({
|
|
373
|
+
entities: [customers],
|
|
374
|
+
screens: [
|
|
375
|
+
{ id: 'x', entity: 'customers', title: 'A', route: 'c', blocks: [] },
|
|
376
|
+
{ id: 'x', entity: 'customers', title: 'B', route: 'c', blocks: [] },
|
|
377
|
+
],
|
|
378
|
+
})
|
|
379
|
+
const p = parseProject(raw)
|
|
380
|
+
expect(new Set(p.screens.map((s) => s.id)).size).toBe(2)
|
|
381
|
+
expect(new Set(p.screens.map((s) => s.route)).size).toBe(2)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
describe('per-entity data sources', () => {
|
|
386
|
+
it('defaults to in-memory and resolves an explicit binding', () => {
|
|
387
|
+
const p0 = createProject([customers])
|
|
388
|
+
expect(entityDataSource(p0, 'customers')).toEqual({ kind: 'memory' })
|
|
389
|
+
const p1 = setEntityDataSource(p0, 'customers', { kind: 'rest', baseUrl: 'https://api', path: 'customers', method: 'GET', params: [] })
|
|
390
|
+
expect(entityDataSource(p1, 'customers').kind).toBe('rest')
|
|
391
|
+
expect(p1.dataSources).toMatchObject({ customers: { kind: 'rest' } })
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('defaultEntitySource seeds a skeleton per kind', () => {
|
|
395
|
+
expect(defaultEntitySource('rest', 'customers')).toMatchObject({ kind: 'rest', path: 'customers', method: 'GET', params: [] })
|
|
396
|
+
expect(defaultEntitySource('sql', 'orders')).toEqual({ kind: 'sql', table: 'orders' })
|
|
397
|
+
expect(defaultEntitySource('supabase', 'orders')).toEqual({ kind: 'supabase', table: 'orders' })
|
|
398
|
+
expect(defaultEntitySource('memory', 'x')).toEqual({ kind: 'memory' })
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('round-trips dataSources through serialize / parse', () => {
|
|
402
|
+
const p = setEntityDataSource(createProject([customers, orders]), 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
403
|
+
expect(parseProject(serializeProject(p)).dataSources).toEqual(p.dataSources)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('sanitize prunes bindings for removed entities', () => {
|
|
407
|
+
let p = setEntityDataSource(createProject([customers, orders]), 'orders', { kind: 'supabase', table: 'orders' })
|
|
408
|
+
p = removeEntity(p, 'orders')
|
|
409
|
+
expect(sanitizeProject(p).dataSources).toBeUndefined()
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
describe('pages (nav) + shell', () => {
|
|
414
|
+
it('updateScreen sets nav show / label / order', () => {
|
|
415
|
+
const p = createProject([customers])
|
|
416
|
+
const up = updateScreen(p, p.screens[0]!.id, { nav: { show: false, label: 'People', order: 3 } })
|
|
417
|
+
expect(up.screens[0]!.nav).toEqual({ show: false, label: 'People', order: 3 })
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('the empty template makes a blank screen', () => {
|
|
421
|
+
expect(screenFromTemplate(customers, 'empty').blocks).toEqual([])
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('setShell merges shell config into the theme', () => {
|
|
425
|
+
const p = setShell(setShell(createProject([customers]), { style: 'top-nav' }), { brand: 'Acme' })
|
|
426
|
+
expect(p.theme?.shell).toEqual({ style: 'top-nav', brand: 'Acme' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('round-trips nav + shell', () => {
|
|
430
|
+
let p = updateScreen(createProject([customers]), 'customers', { nav: { order: 2 } })
|
|
431
|
+
p = setShell(p, { style: 'top-nav', footer: '' })
|
|
432
|
+
const back = parseProject(serializeProject(p))
|
|
433
|
+
expect(back.screens[0]!.nav).toEqual({ order: 2 })
|
|
434
|
+
expect(back.theme?.shell).toEqual({ style: 'top-nav', footer: '' })
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
describe('screen templates', () => {
|
|
439
|
+
it('crud -> grid; dashboard -> kpis + chart + grid; master-detail -> grid + md (editing is a grid property)', () => {
|
|
440
|
+
expect(screenFromTemplate(customers, 'crud').blocks.map((b) => b.config.kind)).toEqual(['grid'])
|
|
441
|
+
const dash = screenFromTemplate(customers, 'dashboard').blocks.map((b) => b.config.kind)
|
|
442
|
+
expect(dash).toContain('kpi')
|
|
443
|
+
expect(dash).toContain('chart')
|
|
444
|
+
expect(dash).toContain('grid')
|
|
445
|
+
const md = screenFromTemplate(customers, 'master-detail', { child: orders }).blocks
|
|
446
|
+
expect(md.map((b) => b.config.kind)).toEqual(['grid', 'master-detail'])
|
|
447
|
+
const mdBlock = md.find((b) => b.config.kind === 'master-detail')!
|
|
448
|
+
expect(mdBlock.config).toMatchObject({ childEntity: 'orders' })
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('addScreenFromTemplate appends a screen with a unique id', () => {
|
|
452
|
+
const p = addScreenFromTemplate(createProject([customers]), 'customers', 'dashboard')
|
|
453
|
+
expect(p.screens).toHaveLength(2)
|
|
454
|
+
expect(new Set(p.screens.map((s) => s.id)).size).toBe(2)
|
|
455
|
+
expect(p.screens[1]!.blocks.some((b) => b.config.kind === 'chart')).toBe(true)
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
describe('serialize / parse (studio.config round-trip)', () => {
|
|
460
|
+
it('round-trips a project', () => {
|
|
461
|
+
const p = setTheme(addBlock(createProject([customers, orders]), 'customers', 'chart'), { accent: '#e11d48' })
|
|
462
|
+
const back = parseProject(serializeProject(p))
|
|
463
|
+
expect(back).toEqual(p)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('defaults missing fields and rejects invalid input', () => {
|
|
467
|
+
const min = parseProject(JSON.stringify({ entities: [customers], screens: [] }))
|
|
468
|
+
expect(min.title).toBe('My Studio App')
|
|
469
|
+
expect(min.dataSource).toBe('memory')
|
|
470
|
+
expect(() => parseProject('not json')).toThrow(/not valid JSON/)
|
|
471
|
+
expect(() => parseProject(JSON.stringify({ screens: [] }))).toThrow(/entities/)
|
|
472
|
+
})
|
|
473
|
+
})
|