@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.
Files changed (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
  3. package/dist/node/studio.js +7889 -2460
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. 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', () => {