@svgrid/enterprise 2.0.3 → 2.2.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/README.md +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
- package/dist/node/studio.js +7889 -2460
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
|
@@ -2,12 +2,34 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import type { EntitySchema } from '../schema'
|
|
3
3
|
import {
|
|
4
4
|
addBlock,
|
|
5
|
+
applyGridPreset,
|
|
6
|
+
addStateVar,
|
|
7
|
+
updateStateVar,
|
|
8
|
+
removeStateVar,
|
|
9
|
+
stateInitExpr,
|
|
10
|
+
compileStep,
|
|
11
|
+
setTrigger,
|
|
12
|
+
triggersOf,
|
|
13
|
+
compileTriggerSteps,
|
|
14
|
+
setScreenLayout,
|
|
15
|
+
setLayoutOpts,
|
|
16
|
+
gridOpts,
|
|
17
|
+
dockOpts,
|
|
18
|
+
canvasOpts,
|
|
19
|
+
canvasRectOf,
|
|
20
|
+
setCanvasRect,
|
|
5
21
|
duplicateBlock,
|
|
6
22
|
blockColumns,
|
|
23
|
+
blockStyleCss,
|
|
24
|
+
mergeBlockStyle,
|
|
7
25
|
addBlockAt,
|
|
26
|
+
addComponentBlock,
|
|
8
27
|
addEntity,
|
|
28
|
+
addFreestandingScreen,
|
|
9
29
|
addScreen,
|
|
30
|
+
addScreenAction,
|
|
10
31
|
addScreenFromTemplate,
|
|
32
|
+
removeScreenAction,
|
|
11
33
|
parseProject,
|
|
12
34
|
screenFromTemplate,
|
|
13
35
|
serializeProject,
|
|
@@ -37,11 +59,20 @@ import {
|
|
|
37
59
|
removeTab,
|
|
38
60
|
renameTab,
|
|
39
61
|
addTabBlock,
|
|
62
|
+
addTabComponent,
|
|
40
63
|
removeTabBlock,
|
|
64
|
+
addAccordionSection,
|
|
65
|
+
removeAccordionSection,
|
|
66
|
+
renameAccordionSection,
|
|
67
|
+
setAccordionMultiple,
|
|
68
|
+
addAccordionBlock,
|
|
69
|
+
addAccordionComponent,
|
|
70
|
+
removeAccordionBlock,
|
|
41
71
|
flattenBlocks,
|
|
42
72
|
TAB_CHILD_KINDS,
|
|
43
73
|
type MasterDetailConfig,
|
|
44
74
|
type TabsConfig,
|
|
75
|
+
type AccordionConfig,
|
|
45
76
|
type StudioProject,
|
|
46
77
|
} from './project'
|
|
47
78
|
|
|
@@ -60,6 +91,39 @@ const orders: EntitySchema = {
|
|
|
60
91
|
fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'total', type: 'number' }],
|
|
61
92
|
}
|
|
62
93
|
|
|
94
|
+
describe('per-block style', () => {
|
|
95
|
+
it('blockStyleCss emits override declarations and sanitizes the color', () => {
|
|
96
|
+
expect(blockStyleCss(undefined)).toBe('')
|
|
97
|
+
expect(blockStyleCss({})).toBe('')
|
|
98
|
+
expect(blockStyleCss({ border: false })).toBe('border: none')
|
|
99
|
+
expect(blockStyleCss({ border: true })).toContain('border: 1px solid')
|
|
100
|
+
expect(blockStyleCss({ shadow: false })).toBe('box-shadow: none')
|
|
101
|
+
expect(blockStyleCss({ padding: 16, margin: 8, radius: 12 })).toBe('padding: 16px; margin: 8px; border-radius: 12px')
|
|
102
|
+
// A malicious color can't break out of the inline style attribute.
|
|
103
|
+
expect(blockStyleCss({ background: '#fff' })).toBe('background: #fff')
|
|
104
|
+
expect(blockStyleCss({ background: 'red;"><script>' })).toBe('background: redscript')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('mergeBlockStyle merges and drops cleared keys (undefined)', () => {
|
|
108
|
+
expect(mergeBlockStyle(undefined, { padding: 12 })).toEqual({ padding: 12 })
|
|
109
|
+
expect(mergeBlockStyle({ padding: 12, border: false }, { margin: 4 })).toEqual({ padding: 12, border: false, margin: 4 })
|
|
110
|
+
expect(mergeBlockStyle({ padding: 12, border: false }, { border: undefined })).toEqual({ padding: 12 })
|
|
111
|
+
expect(mergeBlockStyle({ padding: 12 }, { padding: undefined })).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('updateBlock merges a style patch onto the block, round-trips through parse', () => {
|
|
115
|
+
let p = createProject([customers])
|
|
116
|
+
const sid = p.screens[0]!.id
|
|
117
|
+
const bid = p.screens[0]!.blocks[0]!.id
|
|
118
|
+
p = updateBlock(p, sid, bid, { style: { border: false, padding: 24 } })
|
|
119
|
+
expect(p.screens[0]!.blocks[0]!.style).toEqual({ border: false, padding: 24 })
|
|
120
|
+
p = updateBlock(p, sid, bid, { style: { padding: undefined } })
|
|
121
|
+
expect(p.screens[0]!.blocks[0]!.style).toEqual({ border: false })
|
|
122
|
+
const round = parseProject(serializeProject(p))
|
|
123
|
+
expect(round.screens[0]!.blocks[0]!.style).toEqual({ border: false })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
63
127
|
describe('Tabs container ops', () => {
|
|
64
128
|
const base = () => defaultBlockConfig('tabs', customers) as TabsConfig
|
|
65
129
|
|
|
@@ -120,6 +184,74 @@ describe('Tabs container ops', () => {
|
|
|
120
184
|
expect((p.screens.find((s) => s.id === sid)!.blocks[0]!.config as TabsConfig).tabs[0]!.blocks).toHaveLength(0)
|
|
121
185
|
expect(p.screens.find((s) => s.id === sid)!.blocks).toHaveLength(1) // container intact
|
|
122
186
|
})
|
|
187
|
+
|
|
188
|
+
it('a component can be dropped into a tab (specific registry key -> configured child)', () => {
|
|
189
|
+
const base = defaultBlockConfig('tabs', customers) as TabsConfig
|
|
190
|
+
const cfg = addTabComponent(base, 0, 'badge')
|
|
191
|
+
const child = cfg.tabs[0]!.blocks[0]!
|
|
192
|
+
expect(child.config.kind).toBe('component')
|
|
193
|
+
expect((child.config as { component: string }).component).toBe('badge')
|
|
194
|
+
// Seeded with the registry's default props + content, so it renders immediately.
|
|
195
|
+
expect((child.config as { props: Record<string, unknown> }).props).toMatchObject({ variant: 'neutral', _content: 'Badge' })
|
|
196
|
+
// An unknown component key is a no-op (never inserts a broken child).
|
|
197
|
+
expect(addTabComponent(base, 0, 'not-real')).toBe(base)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('Accordion container (mirrors Tabs)', () => {
|
|
202
|
+
const base = () => defaultBlockConfig('accordion', customers) as AccordionConfig
|
|
203
|
+
|
|
204
|
+
it('defaults to two empty sections, single-open', () => {
|
|
205
|
+
const cfg = base()
|
|
206
|
+
expect(cfg.sections.map((s) => s.label)).toEqual(['Section 1', 'Section 2'])
|
|
207
|
+
expect(cfg.sections.every((s) => s.blocks.length === 0)).toBe(true)
|
|
208
|
+
expect(cfg.multiple).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('adds / renames / removes sections (keeps at least one) + toggles multiple', () => {
|
|
212
|
+
let cfg = addAccordionSection(base(), 'Extra')
|
|
213
|
+
expect(cfg.sections).toHaveLength(3)
|
|
214
|
+
cfg = renameAccordionSection(cfg, 0, 'First')
|
|
215
|
+
expect(cfg.sections[0]!.label).toBe('First')
|
|
216
|
+
cfg = setAccordionMultiple(cfg, true)
|
|
217
|
+
expect(cfg.multiple).toBe(true)
|
|
218
|
+
cfg = removeAccordionSection(cfg, 2)
|
|
219
|
+
expect(cfg.sections).toHaveLength(2)
|
|
220
|
+
const one = removeAccordionSection(removeAccordionSection(cfg, 1), 0)
|
|
221
|
+
expect(one.sections).toHaveLength(1) // never drops the last section
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('hosts display blocks + components with unique ids, and removes them', () => {
|
|
225
|
+
let cfg = addAccordionBlock(base(), 0, 'chart', customers)
|
|
226
|
+
cfg = addAccordionComponent(cfg, 0, 'stat')
|
|
227
|
+
expect(cfg.sections[0]!.blocks.map((b) => b.config.kind)).toEqual(['chart', 'component'])
|
|
228
|
+
const ids = cfg.sections[0]!.blocks.map((b) => b.id)
|
|
229
|
+
expect(new Set(ids).size).toBe(2)
|
|
230
|
+
// grid is controller-bound -> rejected inside a section
|
|
231
|
+
expect(addAccordionBlock(cfg, 0, 'grid', customers)).toBe(cfg)
|
|
232
|
+
cfg = removeAccordionBlock(cfg, 0, ids[0]!)
|
|
233
|
+
expect(cfg.sections[0]!.blocks.map((b) => b.config.kind)).toEqual(['component'])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('flattenBlocks + updateBlock + removeBlock reach a block nested in a section', () => {
|
|
237
|
+
let p = createProject([customers])
|
|
238
|
+
const sid = p.screens[0]!.id
|
|
239
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [] } : s)) }
|
|
240
|
+
p = addBlock(p, sid, 'accordion')
|
|
241
|
+
const ab = p.screens.find((s) => s.id === sid)!.blocks[0]!
|
|
242
|
+
p = updateBlock(p, sid, ab.id, { config: addAccordionBlock(ab.config as AccordionConfig, 1, 'gauge', customers) })
|
|
243
|
+
const cfg0 = p.screens.find((s) => s.id === sid)!.blocks[0]!.config as AccordionConfig
|
|
244
|
+
const childId = cfg0.sections[1]!.blocks[0]!.id
|
|
245
|
+
expect(flattenBlocks(p.screens.find((s) => s.id === sid)!.blocks).map((b) => b.config.kind)).toEqual(['accordion', 'gauge'])
|
|
246
|
+
|
|
247
|
+
p = updateBlock(p, sid, childId, { config: { measure: 'mrr', reduce: 'avg' } as Partial<import('./project').GaugeConfig> })
|
|
248
|
+
const child = (p.screens.find((s) => s.id === sid)!.blocks[0]!.config as AccordionConfig).sections[1]!.blocks[0]!
|
|
249
|
+
expect(child.config).toMatchObject({ kind: 'gauge', measure: 'mrr' })
|
|
250
|
+
|
|
251
|
+
p = removeBlock(p, sid, childId)
|
|
252
|
+
expect((p.screens.find((s) => s.id === sid)!.blocks[0]!.config as AccordionConfig).sections[1]!.blocks).toHaveLength(0)
|
|
253
|
+
expect(p.screens.find((s) => s.id === sid)!.blocks).toHaveLength(1)
|
|
254
|
+
})
|
|
123
255
|
})
|
|
124
256
|
|
|
125
257
|
describe('createProject / defaultScreenFor', () => {
|
|
@@ -190,6 +322,138 @@ describe('block ops', () => {
|
|
|
190
322
|
expect(blockColumns({ span: 2, colSpan: 99 })).toBe(12) // clamped
|
|
191
323
|
})
|
|
192
324
|
|
|
325
|
+
it('applyGridPreset rewrites block column spans by pattern', () => {
|
|
326
|
+
let p = addBlock(base, sid, 'kpi') // base already has a grid; now [grid, kpi]
|
|
327
|
+
p = addBlock(p, sid, 'chart') // [grid, kpi, chart]
|
|
328
|
+
const spans = (pp: typeof p) => pp.screens[0]!.blocks.map((b) => blockColumns(b))
|
|
329
|
+
expect(spans(applyGridPreset(p, sid, 'full'))).toEqual([12, 12, 12])
|
|
330
|
+
expect(spans(applyGridPreset(p, sid, 'two-col'))).toEqual([6, 6, 6])
|
|
331
|
+
expect(spans(applyGridPreset(p, sid, 'three-col'))).toEqual([4, 4, 4])
|
|
332
|
+
expect(spans(applyGridPreset(p, sid, 'sidebar'))).toEqual([4, 8, 8]) // first narrow, rest wide
|
|
333
|
+
expect(spans(applyGridPreset(p, sid, 'kpi-row'))).toEqual([12, 3, 12]) // only the kpi block is a 3-wide strip cell
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('setScreenLayout(split/dock) gives every block its OWN leaf, not one tabs stack', () => {
|
|
337
|
+
// Two "center" blocks (grid + chart) used to collapse into a single tabs leaf,
|
|
338
|
+
// trapping them as tabs (unresizable when locked). Each must be its own pane.
|
|
339
|
+
let p = addBlock(base, sid, 'chart') // base has a grid; now [grid, chart] - both center
|
|
340
|
+
p = setScreenLayout(p, sid, 'split')
|
|
341
|
+
const dock = p.screens[0]!.dock!
|
|
342
|
+
const leaves: { panes: number }[] = []
|
|
343
|
+
const walk = (n: any) => { if (n.type === 'tabs') leaves.push({ panes: n.panes.length }); else n.children.forEach(walk) }
|
|
344
|
+
walk(dock.main)
|
|
345
|
+
expect(leaves.length).toBe(2) // grid + chart => two separate leaves
|
|
346
|
+
expect(leaves.every((l) => l.panes === 1)).toBe(true) // never stacked as tabs
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('setScreenLayout re-seeds a fresh pane arrangement when coming from a non-pane layout', () => {
|
|
350
|
+
let p = addBlock(base, sid, 'chart')
|
|
351
|
+
p = setScreenLayout(p, sid, 'split')
|
|
352
|
+
const firstDock = p.screens[0]!.dock
|
|
353
|
+
// grid -> split -> grid -> split must NOT carry a stale arrangement (fresh seed).
|
|
354
|
+
p = setScreenLayout(p, sid, 'grid')
|
|
355
|
+
p = setScreenLayout(p, sid, 'split')
|
|
356
|
+
expect(p.screens[0]!.dock).not.toBe(firstDock) // re-seeded, not the old object
|
|
357
|
+
// ...but split <-> dock preserves the arrangement.
|
|
358
|
+
const beforeToggle = p.screens[0]!.dock
|
|
359
|
+
p = setScreenLayout(p, sid, 'dock')
|
|
360
|
+
expect(p.screens[0]!.dock).toBe(beforeToggle)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('setLayoutOpts merges per-mode settings; getters fall back to defaults; round-trips', () => {
|
|
364
|
+
let p = setLayoutOpts(base, sid, 'grid', { colGap: 24 })
|
|
365
|
+
p = setLayoutOpts(p, sid, 'grid', { maxWidth: 1200 }) // merges, keeps colGap
|
|
366
|
+
p = setLayoutOpts(p, sid, 'dock', { allowPopout: true, headerPosition: 'bottom' })
|
|
367
|
+
const s = p.screens[0]!
|
|
368
|
+
expect(gridOpts(s).colGap).toBe(24)
|
|
369
|
+
expect(gridOpts(s).maxWidth).toBe(1200)
|
|
370
|
+
expect(gridOpts(s).rowGap).toBe(16) // default preserved
|
|
371
|
+
expect(dockOpts(s).allowPopout).toBe(true)
|
|
372
|
+
expect(dockOpts(s).headerPosition).toBe('bottom')
|
|
373
|
+
expect(canvasOpts(s).cols).toBe(12) // untouched mode -> defaults
|
|
374
|
+
const round = parseProject(serializeProject(p)).screens[0]!
|
|
375
|
+
expect(gridOpts(round).colGap).toBe(24)
|
|
376
|
+
expect(dockOpts(round).allowPopout).toBe(true)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('canvas column count clamps stored rects + drives new placements', () => {
|
|
380
|
+
let p = addBlock(base, sid, 'kpi')
|
|
381
|
+
p = setScreenLayout(p, sid, 'canvas')
|
|
382
|
+
const kpi = p.screens[0]!.blocks[1]!
|
|
383
|
+
p = setCanvasRect(p, sid, kpi.id, { col: 8, colSpan: 4 }) // fits in 12
|
|
384
|
+
// Shrink the grid to 6 columns: the col-8/span-4 rect must clamp on read.
|
|
385
|
+
p = setLayoutOpts(p, sid, 'canvas', { cols: 6 })
|
|
386
|
+
const r = canvasRectOf(p.screens[0]!, kpi.id)
|
|
387
|
+
expect(r.colSpan).toBeLessThanOrEqual(6)
|
|
388
|
+
expect(r.col + r.colSpan).toBeLessThanOrEqual(6)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('state variables: add / update / remove keep names valid + unique', () => {
|
|
392
|
+
let p = addStateVar(base, sid, { name: 'count', type: 'number', initial: '5' })
|
|
393
|
+
p = addStateVar(p, sid, { name: 'count' }) // dup -> count2
|
|
394
|
+
p = addStateVar(p, sid, { name: '2bad name!' }) // sanitized
|
|
395
|
+
const names = p.screens[0]!.state!.map((v) => v.name)
|
|
396
|
+
expect(names).toEqual(['count', 'count2', '_2bad_name_'])
|
|
397
|
+
expect(stateInitExpr(p.screens[0]!.state![0]!)).toBe('5')
|
|
398
|
+
p = updateStateVar(p, sid, 'count', { type: 'boolean', initial: 'true' })
|
|
399
|
+
expect(stateInitExpr(p.screens[0]!.state![0]!)).toBe('true')
|
|
400
|
+
p = removeStateVar(p, sid, 'count2')
|
|
401
|
+
expect(p.screens[0]!.state!.map((v) => v.name)).toEqual(['count', '_2bad_name_'])
|
|
402
|
+
// round-trips
|
|
403
|
+
expect(parseProject(serializeProject(p)).screens[0]!.state).toEqual(p.screens[0]!.state)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('entity triggers: setTrigger / triggersOf + compile to server code (payload = v)', () => {
|
|
407
|
+
let p = createProject([customers])
|
|
408
|
+
p = setTrigger(p, 'customers', 'beforeCreate', [
|
|
409
|
+
{ type: 'requireField', field: 'name' },
|
|
410
|
+
{ type: 'setField', field: 'name', value: { kind: 'field', name: 'name' } },
|
|
411
|
+
{ type: 'reject', condition: { left: { kind: 'field', name: 'age' }, op: 'lt', right: { kind: 'literal', value: '0' } }, message: 'age must be >= 0' },
|
|
412
|
+
])
|
|
413
|
+
expect(triggersOf(p, 'customers').beforeCreate).toHaveLength(3)
|
|
414
|
+
const body = compileTriggerSteps(triggersOf(p, 'customers').beforeCreate!)
|
|
415
|
+
expect(body).toContain("if (v['name'] == null || v['name'] === '') throw new Error('name is required')")
|
|
416
|
+
expect(body).toContain("v['name'] = v?.['name']")
|
|
417
|
+
expect(body).toContain("if (Number(v?.['age']) < Number(0)) throw new Error('age must be >= 0')")
|
|
418
|
+
// clearing an event with null drops it (and the entity when empty)
|
|
419
|
+
p = setTrigger(p, 'customers', 'beforeCreate', null)
|
|
420
|
+
expect(p.triggers).toBeUndefined()
|
|
421
|
+
// round-trip
|
|
422
|
+
p = setTrigger(p, 'customers', 'afterCreate', [{ type: 'code', code: 'console.log(v)' }])
|
|
423
|
+
expect(parseProject(serializeProject(p)).triggers).toEqual(p.triggers)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('logic-core step verbs compile to ctx code', () => {
|
|
427
|
+
expect(compileStep({ type: 'setVar', name: 'q', value: { kind: 'literal', value: 'hi' } })).toBe("ctx.state.q = 'hi'")
|
|
428
|
+
expect(compileStep({ type: 'setVar', name: 'sel', value: { kind: 'state', name: 'row' } })).toBe('ctx.state.sel = ctx.state.row')
|
|
429
|
+
expect(compileStep({ type: 'createRecord', values: [{ field: 'name', value: { kind: 'param', name: 'q' } }] })).toBe("await ctx.data.create({ name: ctx.params['q'] })")
|
|
430
|
+
expect(compileStep({ type: 'updateRecord', id: { kind: 'field', name: 'id' }, values: [{ field: 'done', value: { kind: 'literal', value: 'true' } }] })).toBe("await ctx.data.update(row?.['id'], { done: true })")
|
|
431
|
+
expect(compileStep({ type: 'deleteRecord', id: { kind: 'state', name: 'selId' } })).toBe('await ctx.data.delete(ctx.state.selId)')
|
|
432
|
+
// branch nests + indents its body
|
|
433
|
+
const br = compileStep({ type: 'branch', condition: { left: { kind: 'state', name: 'n' }, op: 'gt', right: { kind: 'literal', value: '3' } }, then: [{ type: 'alert', message: 'big' }], else: [{ type: 'alert', message: 'small' }] })
|
|
434
|
+
expect(br).toBe("if (Number(ctx.state.n) > Number(3)) {\n alert('big')\n} else {\n alert('small')\n}")
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('setScreenLayout(canvas) seeds cell rects; setCanvasRect clamps to the grid + round-trips', () => {
|
|
438
|
+
let p = addBlock(base, sid, 'kpi') // [grid, kpi]
|
|
439
|
+
p = setScreenLayout(p, sid, 'canvas')
|
|
440
|
+
const s = () => p.screens[0]!
|
|
441
|
+
expect(s().layout).toBe('canvas')
|
|
442
|
+
// Every block got a rect.
|
|
443
|
+
for (const b of s().blocks) expect(canvasRectOf(s(), b.id)).toBeTruthy()
|
|
444
|
+
const kpi = s().blocks[1]!
|
|
445
|
+
// Move + oversize: col clamps so col + colSpan <= 12; spans clamp to >= 1.
|
|
446
|
+
p = setCanvasRect(p, sid, kpi.id, { col: 20, row: -5, colSpan: 99, rowSpan: 0 })
|
|
447
|
+
const r = canvasRectOf(s(), kpi.id)
|
|
448
|
+
expect(r.colSpan).toBe(12)
|
|
449
|
+
expect(r.col).toBe(0) // 12-span can only sit at col 0
|
|
450
|
+
expect(r.row).toBe(0)
|
|
451
|
+
expect(r.rowSpan).toBe(1)
|
|
452
|
+
// Survives serialize/parse.
|
|
453
|
+
const round = parseProject(serializeProject(p))
|
|
454
|
+
expect(canvasRectOf(round.screens[0]!, kpi.id)).toEqual(r)
|
|
455
|
+
})
|
|
456
|
+
|
|
193
457
|
it('addBlockAt inserts at the given index (palette drop)', () => {
|
|
194
458
|
const p = addBlockAt(base, sid, 'chart', 0) // before the grid
|
|
195
459
|
expect(p.screens[0]!.blocks.map((b) => b.config.kind)).toEqual(['chart', 'grid'])
|
|
@@ -248,6 +512,95 @@ describe('entity + screen ops', () => {
|
|
|
248
512
|
})
|
|
249
513
|
})
|
|
250
514
|
|
|
515
|
+
describe('freestanding screens + custom actions', () => {
|
|
516
|
+
it('addFreestandingScreen creates a screen with no entity and no blocks', () => {
|
|
517
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
518
|
+
const s = p.screens.find((s) => s.title === 'Reports')!
|
|
519
|
+
expect(s.entity).toBeUndefined()
|
|
520
|
+
expect(s.blocks).toEqual([])
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('a second freestanding screen gets a unique id and route', () => {
|
|
524
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
525
|
+
p = addFreestandingScreen(p, { title: 'Sync' })
|
|
526
|
+
const freestanding = p.screens.filter((s) => s.entity === undefined)
|
|
527
|
+
expect(freestanding).toHaveLength(2)
|
|
528
|
+
expect(new Set(freestanding.map((s) => s.id)).size).toBe(2)
|
|
529
|
+
expect(new Set(freestanding.map((s) => s.route)).size).toBe(2)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('addScreenAction generates a unique id and appends to the screen; removeScreenAction removes it', () => {
|
|
533
|
+
const base = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
534
|
+
const screenId = base.screens.find((s) => s.title === 'Reports')!.id
|
|
535
|
+
let p = addScreenAction(base, screenId, { label: 'Run report', confirm: 'Run the monthly report?' })
|
|
536
|
+
const screen = p.screens.find((s) => s.id === screenId)!
|
|
537
|
+
expect(screen.actions).toHaveLength(1)
|
|
538
|
+
expect(screen.actions![0]).toMatchObject({ label: 'Run report', confirm: 'Run the monthly report?' })
|
|
539
|
+
const actionId = screen.actions![0]!.id
|
|
540
|
+
expect(actionId).toBeTruthy()
|
|
541
|
+
|
|
542
|
+
p = removeScreenAction(p, screenId, actionId)
|
|
543
|
+
expect(p.screens.find((s) => s.id === screenId)!.actions).toEqual([])
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('addScreenAction ids stay unique project-wide, not just per-screen', () => {
|
|
547
|
+
let p = createProject([customers])
|
|
548
|
+
const s1 = p.screens[0]!.id
|
|
549
|
+
p = addFreestandingScreen(p, { title: 'Reports' })
|
|
550
|
+
const s2 = p.screens.find((s) => s.title === 'Reports')!.id
|
|
551
|
+
p = addScreenAction(p, s1, { label: 'First' })
|
|
552
|
+
p = addScreenAction(p, s2, { label: 'Second' })
|
|
553
|
+
const id1 = p.screens.find((s) => s.id === s1)!.actions![0]!.id
|
|
554
|
+
const id2 = p.screens.find((s) => s.id === s2)!.actions![0]!.id
|
|
555
|
+
expect(id1).not.toBe(id2)
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
describe('component blocks', () => {
|
|
560
|
+
it('addComponentBlock adds a component block to a freestanding screen (no entity needed)', () => {
|
|
561
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
562
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
563
|
+
const next = addComponentBlock(p, sid, 'button', { variant: 'primary' })
|
|
564
|
+
const screen = next.screens.find((s) => s.id === sid)!
|
|
565
|
+
expect(screen.blocks).toHaveLength(1)
|
|
566
|
+
expect(screen.blocks[0]!.config).toMatchObject({ kind: 'component', component: 'button', props: { variant: 'primary' } })
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('addComponentBlock also works mixed onto an entity-bound screen', () => {
|
|
570
|
+
const p = createProject([customers])
|
|
571
|
+
const sid = p.screens[0]!.id
|
|
572
|
+
const before = p.screens[0]!.blocks.length
|
|
573
|
+
const next = addComponentBlock(p, sid, 'alert', {})
|
|
574
|
+
const screen = next.screens.find((s) => s.id === sid)!
|
|
575
|
+
expect(screen.blocks).toHaveLength(before + 1)
|
|
576
|
+
expect(screen.blocks.at(-1)!.config).toMatchObject({ kind: 'component', component: 'alert' })
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('addComponentBlock inserts at an index and gives the block a unique id', () => {
|
|
580
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
581
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
582
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
583
|
+
p = addComponentBlock(p, sid, 'badge', {}, 0)
|
|
584
|
+
const screen = p.screens.find((s) => s.id === sid)!
|
|
585
|
+
expect(screen.blocks.map((b) => (b.config as { component: string }).component)).toEqual(['badge', 'button'])
|
|
586
|
+
expect(new Set(screen.blocks.map((b) => b.id)).size).toBe(2)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('validateProject warns on a component block with no component selected', () => {
|
|
590
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
591
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
592
|
+
p = addComponentBlock(p, sid, '', {})
|
|
593
|
+
expect(validateProject(p).some((i) => /Component block has no component selected/.test(i.message))).toBe(true)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('a freestanding screen with only a component block does not warn about being empty', () => {
|
|
597
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
598
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
599
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
600
|
+
expect(validateProject(p).some((i) => /is empty/.test(i.message))).toBe(false)
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
251
604
|
describe('validateProject', () => {
|
|
252
605
|
it('addScreen dedupes the route so two screens for one entity do not collide', () => {
|
|
253
606
|
const p = addScreen(createProject([customers]), 'customers')
|
|
@@ -300,6 +653,23 @@ describe('validateProject', () => {
|
|
|
300
653
|
const treeIssue = validateProject(p).find((i) => i.block === treeId)
|
|
301
654
|
expect(treeIssue?.message).toMatch(/label field/)
|
|
302
655
|
})
|
|
656
|
+
|
|
657
|
+
it('a freestanding screen (no entity) does not trigger the missing-entity error', () => {
|
|
658
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
659
|
+
expect(validateProject(p).some((i) => /missing entity/.test(i.message))).toBe(false)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('warns that an empty freestanding screen needs an action or content', () => {
|
|
663
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
664
|
+
expect(validateProject(p).some((i) => /is empty - add an action or some content/.test(i.message))).toBe(true)
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('a freestanding screen with a custom action does not warn about being empty', () => {
|
|
668
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports' })
|
|
669
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
670
|
+
p = addScreenAction(p, sid, { label: 'Run report' })
|
|
671
|
+
expect(validateProject(p).some((i) => /is empty/.test(i.message))).toBe(false)
|
|
672
|
+
})
|
|
303
673
|
})
|
|
304
674
|
|
|
305
675
|
describe('block/screen efficiency ops', () => {
|