@svgrid/enterprise 2.2.1 → 2.3.1

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 (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -13,7 +13,11 @@
13
13
  * that becomes its plain-text children in both the live preview and codegen.
14
14
  */
15
15
 
16
- export type UiPropType = 'string' | 'number' | 'boolean' | 'select' | 'color'
16
+ import { GENERATED_UI_SURFACE } from './ui-components.generated.js'
17
+
18
+ export type UiPropType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'json' | 'date'
19
+
20
+ export type UiPropGroup = 'common' | 'appearance' | 'behavior' | 'advanced'
17
21
 
18
22
  export type UiComponentProp = {
19
23
  key: string
@@ -22,8 +26,36 @@ export type UiComponentProp = {
22
26
  /** Choices for `type: 'select'`. */
23
27
  options?: string[]
24
28
  default?: unknown
29
+ /** Guidance shown as the property panel tooltip - sourced from the component's
30
+ * own JSDoc via the extractor, so it stays true to the source. */
31
+ description?: string
32
+ /** Property-panel grouping (defaults to 'common'). */
33
+ group?: UiPropGroup
34
+ /** A function / code-only prop (e.g. loadChildren) - listed read-only, set in code. */
35
+ code?: boolean
25
36
  }
26
37
 
38
+ /** A wireable component event: `key` is the handle event name (`ctx.x.on<key>`),
39
+ * `prop` the component callback prop that fires it (e.g. `onChange`). */
40
+ export type UiComponentEvent = { key: string; label: string; prop?: string; description?: string }
41
+
42
+ /** Standard DOM / interaction events EVERY component block can wire (VS-style full
43
+ * event surface) - forwarded by the generated wrapper to the block's handle. `dom`
44
+ * is the DOM event the wrapper listens for; `key` is the handle event name. */
45
+ export const STANDARD_UI_EVENTS: ReadonlyArray<{ key: string; label: string; dom: string; description: string }> = [
46
+ { key: 'click', label: 'Click', dom: 'click', description: 'The component (or a child) was clicked.' },
47
+ { key: 'dblclick', label: 'Double click', dom: 'dblclick', description: 'The component was double-clicked.' },
48
+ { key: 'change', label: 'Change', dom: 'change', description: 'A committed value change (fires on blur for inputs).' },
49
+ { key: 'input', label: 'Input', dom: 'input', description: 'Fires on every keystroke / immediate value edit.' },
50
+ { key: 'focus', label: 'Focus', dom: 'focusin', description: 'The component (or a child) gained focus.' },
51
+ { key: 'blur', label: 'Blur', dom: 'focusout', description: 'The component (or a child) lost focus.' },
52
+ { key: 'keydown', label: 'Key down', dom: 'keydown', description: 'A key was pressed while focused.' },
53
+ { key: 'keyup', label: 'Key up', dom: 'keyup', description: 'A key was released while focused.' },
54
+ { key: 'contextmenu', label: 'Context menu', dom: 'contextmenu', description: 'Right-click / context-menu request.' },
55
+ { key: 'mouseenter', label: 'Mouse enter', dom: 'mouseenter', description: 'The pointer entered the component.' },
56
+ { key: 'mouseleave', label: 'Mouse leave', dom: 'mouseleave', description: 'The pointer left the component.' },
57
+ ]
58
+
27
59
  export type UiComponentSpec = {
28
60
  /** Registry key - stored as `ComponentConfig.component`. */
29
61
  key: string
@@ -31,8 +63,14 @@ export type UiComponentSpec = {
31
63
  category: string
32
64
  /** Named export from `@svgrid/grid`. */
33
65
  importName: string
34
- /** Scalar "chrome" props only - see module doc. */
66
+ /** Hand-curated prop entries. In the EXPORTED registry these are merged with
67
+ * the extracted full surface (ui-components.generated.ts): hand entries win
68
+ * by key (label/options/default), extraction supplies the rest + tooltips. */
35
69
  props: UiComponentProp[]
70
+ /** Wireable events (merged with extracted function-prop events the same way). */
71
+ events?: UiComponentEvent[]
72
+ /** Extracted props to keep OUT of the property panel (edge cases). */
73
+ hiddenProps?: string[]
36
74
  /** True if the component's real content is a `children` snippet - adds one
37
75
  * literal-text field to the property panel, stored under `props._content`. */
38
76
  hasContent?: boolean
@@ -45,7 +83,7 @@ export type UiComponentSpec = {
45
83
  fixed?: Array<{ key: string; expr: string; preview: unknown }>
46
84
  }
47
85
 
48
- export const UI_COMPONENT_REGISTRY: ReadonlyArray<UiComponentSpec> = [
86
+ const BASE_REGISTRY: ReadonlyArray<UiComponentSpec> = [
49
87
  {
50
88
  key: 'button',
51
89
  label: 'Button',
@@ -465,8 +503,240 @@ export const UI_COMPONENT_REGISTRY: ReadonlyArray<UiComponentSpec> = [
465
503
  },
466
504
  ],
467
505
  },
506
+ // --- wave 1: full-kit registration (props/events come from the extractor) ---
507
+ { key: 'masked-input', label: 'Masked input', category: 'Inputs', importName: 'SvMaskedInput', props: [] },
508
+ { key: 'phone-input', label: 'Phone input', category: 'Inputs', importName: 'SvPhoneInput', props: [] },
509
+ {
510
+ key: 'tags-input', label: 'Tags input', category: 'Inputs', importName: 'SvTagsInput',
511
+ props: [{ key: 'value', label: 'Tags', type: 'json', default: ['svelte', 'grid'] }],
512
+ },
513
+ { key: 'duration-input', label: 'Duration input', category: 'Inputs', importName: 'SvDurationInput', props: [] },
514
+ {
515
+ key: 'radio-group', label: 'Radio group', category: 'Inputs', importName: 'SvRadioGroup',
516
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }, { value: 'c', label: 'Option C' }] }],
517
+ },
518
+ {
519
+ key: 'button-group', label: 'Button group', category: 'Actions', importName: 'SvButtonGroup',
520
+ props: [{ key: 'items', label: 'Items', type: 'json', default: [{ value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }] }],
521
+ },
522
+ {
523
+ key: 'list-box', label: 'List box', category: 'Inputs', importName: 'SvListBox',
524
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: '1', label: 'First' }, { value: '2', label: 'Second' }, { value: '3', label: 'Third' }] }],
525
+ },
526
+ {
527
+ key: 'dropdown-list', label: 'Dropdown list', category: 'Inputs', importName: 'SvDropDownList',
528
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: '1', label: 'First' }, { value: '2', label: 'Second' }, { value: '3', label: 'Third' }] }],
529
+ },
530
+ {
531
+ key: 'combo-box', label: 'Combo box', category: 'Inputs', importName: 'SvComboBox',
532
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: '1', label: 'First' }, { value: '2', label: 'Second' }, { value: '3', label: 'Third' }] }],
533
+ },
534
+ {
535
+ key: 'auto-complete', label: 'Auto complete', category: 'Inputs', importName: 'SvAutoComplete',
536
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: 'red', label: 'Red' }, { value: 'green', label: 'Green' }, { value: 'blue', label: 'Blue' }] }],
537
+ },
538
+ {
539
+ key: 'multi-select', label: 'Multi select', category: 'Inputs', importName: 'SvMultiSelect',
540
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: '1', label: 'First' }, { value: '2', label: 'Second' }, { value: '3', label: 'Third' }] }],
541
+ },
542
+ { key: 'repeat-button', label: 'Repeat button', category: 'Actions', importName: 'SvRepeatButton', props: [], hasContent: true, contentLabel: 'Label', contentDefault: 'Hold me' },
543
+ { key: 'rich-text', label: 'Rich text editor', category: 'Inputs', importName: 'SvRichText', props: [] },
544
+ {
545
+ key: 'tree', label: 'Tree', category: 'Display', importName: 'SvTree',
546
+ props: [{ key: 'nodes', label: 'Nodes', type: 'json', default: [{ id: '1', label: 'Documents', children: [{ id: '1-1', label: 'Reports' }, { id: '1-2', label: 'Invoices' }] }, { id: '2', label: 'Pictures' }] }],
547
+ },
548
+ { key: 'country-input', label: 'Country input', category: 'Inputs', importName: 'SvCountryInput', props: [] },
549
+
550
+ // --- Typography / prose -------------------------------------------------
551
+ // The kit's text primitives. Cheap to expose and they are what a freestanding
552
+ // page (an "About", a help panel) is mostly made of - without them a designer
553
+ // user has no way to place a heading or a paragraph.
554
+ {
555
+ key: 'title', label: 'Heading', category: 'Display', importName: 'SvTitle',
556
+ props: [{ key: 'size', label: 'Level', type: 'select', options: ['1', '2', '3', '4', '5', '6'], default: '2' }],
557
+ hasContent: true, contentLabel: 'Text', contentDefault: 'Section heading',
558
+ },
559
+ {
560
+ key: 'text', label: 'Text', category: 'Display', importName: 'SvText',
561
+ props: [
562
+ { key: 'size', label: 'Size', type: 'select', options: ['xs', 'sm', 'md', 'lg', 'xl'], default: 'md' },
563
+ { key: 'weight', label: 'Weight', type: 'select', options: ['normal', 'medium', 'semibold', 'bold'], default: 'normal' },
564
+ { key: 'tone', label: 'Tone', type: 'select', options: ['default', 'muted', 'accent', 'success', 'warning', 'error'], default: 'default' },
565
+ ],
566
+ hasContent: true, contentLabel: 'Text', contentDefault: 'Body text.',
567
+ },
568
+ {
569
+ key: 'anchor', label: 'Link', category: 'Display', importName: 'SvAnchor',
570
+ props: [
571
+ { key: 'href', label: 'URL', type: 'string', default: '#' },
572
+ { key: 'tone', label: 'Tone', type: 'select', options: ['accent', 'default', 'muted'], default: 'accent' },
573
+ { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
574
+ ],
575
+ hasContent: true, contentLabel: 'Label', contentDefault: 'Read more',
576
+ },
577
+ {
578
+ key: 'blockquote', label: 'Quote', category: 'Display', importName: 'SvBlockquote',
579
+ props: [], hasContent: true, contentLabel: 'Quote', contentDefault: 'A quoted passage.',
580
+ },
581
+ {
582
+ key: 'code', label: 'Inline code', category: 'Display', importName: 'SvCode',
583
+ props: [], hasContent: true, contentLabel: 'Code', contentDefault: 'npm install',
584
+ },
585
+ {
586
+ key: 'kbd', label: 'Keyboard key', category: 'Display', importName: 'SvKbd',
587
+ props: [{ key: 'size', label: 'Size', type: 'select', options: ['sm', 'md'], default: 'md' }],
588
+ hasContent: true, contentLabel: 'Key', contentDefault: 'Ctrl',
589
+ },
590
+ {
591
+ key: 'mark', label: 'Highlight', category: 'Display', importName: 'SvMark',
592
+ props: [{ key: 'tone', label: 'Tone', type: 'select', options: ['yellow', 'accent', 'success', 'warning', 'error'], default: 'yellow' }],
593
+ hasContent: true, contentLabel: 'Text', contentDefault: 'highlighted',
594
+ },
595
+ {
596
+ key: 'list', label: 'List', category: 'Display', importName: 'SvList',
597
+ props: [],
598
+ fixed: [{ key: 'items', expr: "['First item', 'Second item', 'Third item']", preview: ['First item', 'Second item', 'Third item'] }],
599
+ },
600
+
601
+ // --- Feedback -----------------------------------------------------------
602
+ {
603
+ key: 'spinner', label: 'Spinner', category: 'Feedback', importName: 'SvSpinner',
604
+ props: [{ key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' }],
605
+ },
606
+ {
607
+ key: 'loading-overlay', label: 'Loading overlay', category: 'Feedback', importName: 'SvLoadingOverlay',
608
+ props: [
609
+ { key: 'visible', label: 'Visible', type: 'boolean', default: true },
610
+ { key: 'label', label: 'Label', type: 'string', default: 'Loading...' },
611
+ ],
612
+ },
613
+ {
614
+ key: 'result', label: 'Result', category: 'Feedback', importName: 'SvResult',
615
+ props: [
616
+ { key: 'status', label: 'Status', type: 'select', options: ['success', 'error', 'warning', 'info'], default: 'success' },
617
+ { key: 'title', label: 'Title', type: 'string', default: 'All done' },
618
+ ],
619
+ },
620
+
621
+ // --- Structure ----------------------------------------------------------
622
+ {
623
+ key: 'collapsible', label: 'Collapsible', category: 'Layout', importName: 'SvCollapsible',
624
+ props: [
625
+ { key: 'title', label: 'Title', type: 'string', default: 'Details' },
626
+ { key: 'open', label: 'Open', type: 'boolean', default: false },
627
+ ],
628
+ hasContent: true, contentLabel: 'Content', contentDefault: 'Hidden until expanded.',
629
+ },
630
+ {
631
+ key: 'carousel', label: 'Carousel', category: 'Layout', importName: 'SvCarousel',
632
+ props: [],
633
+ },
634
+
635
+ // --- Date / time + richer pickers ---------------------------------------
636
+ // These are grid cell editors too; as standalone blocks they are what a
637
+ // filter bar or a booking form is built from.
638
+ { key: 'date-time-picker', label: 'Date & time picker', category: 'Inputs', importName: 'SvDateTimePicker', props: [] },
639
+ { key: 'time-picker', label: 'Time picker', category: 'Inputs', importName: 'SvTimePicker', props: [] },
640
+ { key: 'date-range-input', label: 'Date range', category: 'Inputs', importName: 'SvDateRangeInput', props: [] },
641
+ {
642
+ key: 'segmented', label: 'Segmented control', category: 'Inputs', importName: 'SvSegmented',
643
+ props: [{ key: 'options', label: 'Options', type: 'json', default: [{ value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }] }],
644
+ },
645
+ {
646
+ key: 'tree-select', label: 'Tree select', category: 'Inputs', importName: 'SvTreeSelect',
647
+ props: [{ key: 'nodes', label: 'Nodes', type: 'json', default: [{ id: '1', label: 'Europe', children: [{ id: '1-1', label: 'France' }, { id: '1-2', label: 'Spain' }] }, { id: '2', label: 'Asia' }] }],
648
+ },
649
+ { key: 'file-upload', label: 'File upload', category: 'Inputs', importName: 'SvFileUpload', props: [] },
468
650
  ]
469
651
 
652
+ // ---------------------------------------------------------------------------
653
+ // Merge: the exported registry = hand-curated spec + the component's FULL
654
+ // extracted surface (ui-components.generated.ts). Hand entries win by key;
655
+ // extraction contributes every remaining prop with its JSDoc tooltip, plus
656
+ // the event list. Consumers see one complete `props`/`events` per component.
657
+ // ---------------------------------------------------------------------------
658
+
659
+ const GROUP_ORDER: Record<UiPropGroup, number> = { common: 0, appearance: 1, behavior: 2, advanced: 3 }
660
+
661
+ function mergeSpec(spec: UiComponentSpec): UiComponentSpec {
662
+ const generated = GENERATED_UI_SURFACE[spec.importName]
663
+ if (!generated) return spec
664
+ const skip = new Set<string>([...(spec.hiddenProps ?? []), ...(spec.fixed ?? []).map((f) => f.key), '_content'])
665
+ const genByKey = new Map(generated.props.map((p) => [p.key, p]))
666
+ // Hand-curated entries first (their order is intentional), enriched with the
667
+ // extracted description/group when the hand entry doesn't set them.
668
+ const curated = spec.props
669
+ .filter((p) => !skip.has(p.key))
670
+ .map((p) => {
671
+ const g = genByKey.get(p.key)
672
+ return g ? { ...g, ...p, description: p.description ?? g.description, group: p.group ?? g.group } : p
673
+ })
674
+ const curatedKeys = new Set(curated.map((p) => p.key))
675
+ const rest = generated.props
676
+ .filter((p) => !skip.has(p.key) && !curatedKeys.has(p.key))
677
+ .map((p) => ({ ...p }) as UiComponentProp)
678
+ .sort((a, b) => GROUP_ORDER[a.group ?? 'common'] - GROUP_ORDER[b.group ?? 'common'])
679
+ // Events: extracted list as the base, hand entries override by key.
680
+ const handEvents = new Map((spec.events ?? []).map((e) => [e.key, e]))
681
+ const events: UiComponentEvent[] = [
682
+ ...generated.events.map((e) => ({ ...e, ...(handEvents.get(e.key) ?? {}) })),
683
+ ...(spec.events ?? []).filter((e) => !generated.events.some((g) => g.key === e.key)),
684
+ ]
685
+ return { ...spec, props: [...curated, ...rest], events }
686
+ }
687
+
688
+ export const UI_COMPONENT_REGISTRY: ReadonlyArray<UiComponentSpec> = BASE_REGISTRY.map(mergeSpec)
689
+
470
690
  export function uiComponentSpec(key: string): UiComponentSpec | undefined {
471
691
  return UI_COMPONENT_REGISTRY.find((s) => s.key === key)
472
692
  }
693
+
694
+ // --- Grid: the "All properties" surface ------------------------------------
695
+ // The grid block has rich CURATED controls (column/form builders, grouping, paging,
696
+ // ...). These are the SvGrid props those controls already drive - excluded from the
697
+ // grid's "All properties" panel so nothing shows twice. Everything else in the
698
+ // extracted SvGrid surface is offered as a raw, editable override (config.props).
699
+ export const GRID_CURATED_PROPS: ReadonlySet<string> = new Set<string>([
700
+ 'data', 'columns', 'board', 'scheduler', 'loading', 'loadingOverlay', 'emptyMessage',
701
+ 'fitColumns', 'sortable', 'externalSort', 'filterable', 'externalFilter', 'filterMode',
702
+ 'showGlobalFilter', 'showColumnFilters', 'showFilterRow', 'showFilterMenu', 'showGroupingControls',
703
+ 'groupable', 'showRowSelection', 'selectionMode', 'enableCellSelection', 'enableRowSummaries',
704
+ 'zebraRows', 'showPagination', 'pageable', 'externalPagination', 'rowCount', 'pageIndex',
705
+ 'pageSize', 'pageSizeOptions', 'paginationPosition', 'rowHeight', 'containerHeight',
706
+ 'columnVirtualization', 'initialColumnPinning', 'conditionalFormats', 'features', 'editable',
707
+ 'enableInlineEditing',
708
+ ])
709
+
710
+ /** The Grid's raw prop surface for the "All properties" panel: every extracted SvGrid
711
+ * prop the curated controls do NOT already manage. Sorted by group then label. */
712
+ export function gridPropSurface(): UiComponentProp[] {
713
+ const g = GENERATED_UI_SURFACE['SvGrid']
714
+ if (!g) return []
715
+ const order: Record<string, number> = { common: 0, appearance: 1, behavior: 2, advanced: 3 }
716
+ return g.props
717
+ .filter((p) => !GRID_CURATED_PROPS.has(p.key))
718
+ .map((p) => ({ ...p }) as UiComponentProp)
719
+ .sort((a, b) => (order[a.group ?? 'common']! - order[b.group ?? 'common']!) || a.label.localeCompare(b.label))
720
+ }
721
+
722
+ // Props NOT offered on `ctx.grid` as runtime-settable options: structural data,
723
+ // controller-owned plumbing, and seed-once `initial*` props (the grid reads those at
724
+ // mount, so `setOption` on them is a no-op - excluding them keeps intellisense honest).
725
+ const GRID_API_UNSETTABLE: ReadonlySet<string> = new Set<string>([
726
+ 'data', 'columns', 'features', 'loading', 'onApiReady',
727
+ 'externalSort', 'externalFilter', 'externalPagination', 'rowCount', 'pageIndex',
728
+ 'initialSorting', 'initialColumnPinning', 'initialHiddenColumns', 'columnOrder',
729
+ ])
730
+
731
+ /** The Grid props settable at runtime through `ctx.grid.<prop> = value` (backed by
732
+ * `api.setOption`): the full extracted SvGrid surface minus controller-owned /
733
+ * seed-once props and function/snippet props. Drives the `ctx.grid` type + the code
734
+ * view autocomplete. Sorted alphabetically. */
735
+ export function gridApiSettableProps(): UiComponentProp[] {
736
+ const g = GENERATED_UI_SURFACE['SvGrid']
737
+ if (!g) return []
738
+ return g.props
739
+ .filter((p) => !GRID_API_UNSETTABLE.has(p.key) && !p.code && !/^on[A-Z]/.test(p.key))
740
+ .map((p) => ({ ...p }) as UiComponentProp)
741
+ .sort((a, b) => a.key.localeCompare(b.key))
742
+ }
@@ -11,6 +11,7 @@ export {
11
11
  createKitHandlers,
12
12
  type KitDataSourceOptions,
13
13
  type KitHandlerOptions,
14
+ type KitScope,
14
15
  type KitHandlers,
15
16
  type KitMessage,
16
17
  } from './transport'
@@ -63,6 +63,19 @@ describe('createSqlDataSource reads', () => {
63
63
  expect(calls[1]!.params).toEqual([20])
64
64
  })
65
65
 
66
+ it('schema-qualifies the table as "schema"."table" when dbSchema is set', async () => {
67
+ const { execute, calls } = recorder([[{ id: '1' }], [{ count: 1 }]])
68
+ const src = createSqlDataSource({ schema, table: 'customers', dbSchema: 'analytics', execute })
69
+ await src.getRows(req({}))
70
+ expect(calls[0]!.sql).toContain('FROM "analytics"."customers"')
71
+ expect(calls[1]!.sql).toContain('FROM "analytics"."customers"')
72
+ // Writes are qualified too.
73
+ const { execute: ex2, calls: c2 } = recorder([[{ id: '9' }]])
74
+ const src2 = createSqlDataSource({ schema, table: 'customers', dbSchema: 'analytics', execute: ex2 })
75
+ await src2.createRow({ name: 'Zed' } as never)
76
+ expect(c2[0]!.sql).toContain('INSERT INTO "analytics"."customers"')
77
+ })
78
+
66
79
  it('honors the Postgres dialect ($ placeholders)', async () => {
67
80
  const { execute, calls } = recorder([[], [{ count: 0 }]])
68
81
  const src = createSqlDataSource({
@@ -41,6 +41,9 @@ export type SqlDataSourceConfig<TData extends RowData> = {
41
41
  schema: EntitySchema<TData>
42
42
  /** Table (or view) name. Quoted per the dialect; not interpolated raw. */
43
43
  table: string
44
+ /** DB schema / namespace (e.g. Postgres `public`, SQL Server `dbo`). When set,
45
+ * the table is addressed as `"schema"."table"`. Each part is quoted separately. */
46
+ dbSchema?: string
44
47
  execute: SqlExecutor
45
48
  dialect?: SqlDialect
46
49
  /**
@@ -71,11 +74,13 @@ export function createSqlDataSource<TData extends RowData>(
71
74
  config: SqlDataSourceConfig<TData>,
72
75
  ): WritableDataSource<TData> & AggregateSource {
73
76
  nudgeEnterprise('Studio') // soft-gate; never blocks, safe on the server
74
- const { schema, table, execute } = config
77
+ const { schema, execute } = config
75
78
  const dialect = config.dialect ?? {}
76
79
  const returning = config.returning ?? true
77
80
  const id = quoter(dialect)
78
81
  const idField = resolveIdField(schema)
82
+ // Optionally schema-qualify the table: "schema"."table" (each part quoted separately).
83
+ const qtable = config.dbSchema ? `${id(config.dbSchema)}.${id(config.table)}` : id(config.table)
79
84
 
80
85
  // Column mapping: when a field has a `dbColumn` (e.g. Drizzle `created_at` vs
81
86
  // key `createdAt`), read/write the real column and alias it back to the field.
@@ -94,7 +99,7 @@ export function createSqlDataSource<TData extends RowData>(
94
99
  async getRows(request: ServerRequest): Promise<ServerResult<TData>> {
95
100
  const plan = planQuery(schema, request)
96
101
  const sql = planToSql(plan, planDialect)
97
- const t = id(table)
102
+ const t = qtable
98
103
  const rowsSql = squish(
99
104
  `SELECT ${projection} FROM ${t} ${sql.whereText} ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,
100
105
  )
@@ -112,7 +117,7 @@ export function createSqlDataSource<TData extends RowData>(
112
117
  const bind = binder(dialect, params)
113
118
  const values = cols.map((c) => bind((input as RowData)[c]))
114
119
  const sql = squish(
115
- `INSERT INTO ${id(table)} (${cols.map(col).join(', ')}) VALUES (${values.join(', ')})${RET}`,
120
+ `INSERT INTO ${qtable} (${cols.map(col).join(', ')}) VALUES (${values.join(', ')})${RET}`,
116
121
  )
117
122
  const rows = await execute(sql, params)
118
123
  return (rows[0] ?? input) as TData
@@ -125,7 +130,7 @@ export function createSqlDataSource<TData extends RowData>(
125
130
  const bind = binder(dialect, params)
126
131
  const sets = cols.map((c) => `${col(c)} = ${bind((patch as RowData)[c])}`)
127
132
  const where = `${col(idField)} = ${bind(rowIdValue)}`
128
- const sql = squish(`UPDATE ${id(table)} SET ${sets.join(', ')} WHERE ${where}${RET}`)
133
+ const sql = squish(`UPDATE ${qtable} SET ${sets.join(', ')} WHERE ${where}${RET}`)
129
134
  const rows = await execute(sql, params)
130
135
  return (rows[0] ?? { ...patch, [idField]: rowIdValue }) as TData
131
136
  },
@@ -133,7 +138,7 @@ export function createSqlDataSource<TData extends RowData>(
133
138
  async deleteRow(rowIdValue: string): Promise<void> {
134
139
  const params: unknown[] = []
135
140
  const bind = binder(dialect, params)
136
- const sql = squish(`DELETE FROM ${id(table)} WHERE ${col(idField)} = ${bind(rowIdValue)}`)
141
+ const sql = squish(`DELETE FROM ${qtable} WHERE ${col(idField)} = ${bind(rowIdValue)}`)
137
142
  await execute(sql, params)
138
143
  },
139
144
 
@@ -151,13 +156,13 @@ export function createSqlDataSource<TData extends RowData>(
151
156
  : `${SQL_AGG[request.reduce]}(${col(request.measure)})`
152
157
  // No dimension -> a single grand total (a KPI), no GROUP BY.
153
158
  if (!request.dimension) {
154
- const text = squish(`SELECT ${fn} AS value FROM ${id(table)} ${sql.whereText}`)
159
+ const text = squish(`SELECT ${fn} AS value FROM ${qtable} ${sql.whereText}`)
155
160
  const rows = await execute(text, sql.params)
156
161
  return [{ category: '', value: Number(rows[0]?.value ?? 0) }]
157
162
  }
158
163
  const dim = col(request.dimension)
159
164
  const text = squish(
160
- `SELECT ${dim} AS category, ${fn} AS value FROM ${id(table)} ${sql.whereText} GROUP BY ${dim} ORDER BY value DESC`,
165
+ `SELECT ${dim} AS category, ${fn} AS value FROM ${qtable} ${sql.whereText} GROUP BY ${dim} ORDER BY value DESC`,
161
166
  )
162
167
  const rows = await execute(text, sql.params)
163
168
  return rows.map((r) => ({ category: String(r.category ?? ''), value: Number(r.value ?? 0) }))
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Row scoping (`scope`) - the mechanism behind multi-tenancy.
3
+ *
4
+ * Scoping reads is the easy half and the useless half on its own: if create can
5
+ * plant a row in another tenant, or update/delete can reach one by guessing an
6
+ * id, the isolation is decorative. These tests cover all four paths plus the
7
+ * ways a client might try to talk its way out of the scope.
8
+ */
9
+ import { describe, expect, it } from 'vitest'
10
+ import type { ServerRequest } from '@svgrid/grid'
11
+ import type { EntitySchema } from '../schema'
12
+ import { createInMemoryDataSource } from './in-memory'
13
+ import { createKitDataSource, createKitHandlers } from './transport'
14
+
15
+ type Row = { id: string; name: string; tenantId: string }
16
+
17
+ const schema: EntitySchema<Row> = {
18
+ name: 'notes',
19
+ fields: [
20
+ { field: 'id', type: 'text', primaryKey: true },
21
+ { field: 'name', type: 'text' },
22
+ { field: 'tenantId', type: 'text' },
23
+ ],
24
+ }
25
+
26
+ const seed: Row[] = [
27
+ { id: '1', name: 'Acme note', tenantId: 'acme' },
28
+ { id: '2', name: 'Acme second', tenantId: 'acme' },
29
+ { id: '3', name: 'Globex secret', tenantId: 'globex' },
30
+ ]
31
+
32
+ /** Wire a client to handlers scoped to `tenant` (null = unscoped/super-admin). */
33
+ function wire(tenant: string | null, opts: { throwOnResolve?: boolean } = {}) {
34
+ const backend = createInMemoryDataSource(seed.map((r) => ({ ...r })), schema)
35
+ const handlers = createKitHandlers({
36
+ schema,
37
+ source: backend,
38
+ scope: () => {
39
+ if (opts.throwOnResolve) throw new Error('no tenant on session')
40
+ return tenant ? { field: 'tenantId', value: tenant } : null
41
+ },
42
+ })
43
+ const fakeFetch = (url: string, init?: RequestInit) =>
44
+ handlers.handle(new Request(`http://localhost${url}`, init))
45
+ const client = createKitDataSource<Row>({ endpoint: '/api/notes', fetch: fakeFetch })
46
+ return { client, backend, handlers }
47
+ }
48
+
49
+ const req = (partial: Partial<ServerRequest> = {}): ServerRequest => ({
50
+ startRow: 0, endRow: 50, pageIndex: 0, pageSize: 50, sortModel: [], filterModel: {}, ...partial,
51
+ })
52
+
53
+ describe('row scoping: reads', () => {
54
+ it('returns only the caller tenant\'s rows', async () => {
55
+ const { client } = wire('acme')
56
+ const { rows } = await client.getRows(req())
57
+ expect(rows.map((r) => r.id)).toEqual(['1', '2'])
58
+ })
59
+
60
+ it('is unscoped when the resolver returns null', async () => {
61
+ const { client } = wire(null)
62
+ const { rows } = await client.getRows(req())
63
+ expect(rows).toHaveLength(3)
64
+ })
65
+
66
+ it('a client-supplied tenant filter cannot widen the scope', async () => {
67
+ const { client } = wire('acme')
68
+ // Ask for globex explicitly - the server's predicate is written last.
69
+ const { rows } = await client.getRows(
70
+ req({ filterModel: { columns: { tenantId: { operator: 'equals', value: 'globex' } } } }),
71
+ )
72
+ expect(rows.every((r) => r.tenantId === 'acme')).toBe(true)
73
+ })
74
+ })
75
+
76
+ describe('row scoping: writes', () => {
77
+ it('stamps the tenant on create, overriding what the client sent', async () => {
78
+ const { client, backend } = wire('acme')
79
+ const created = await client.createRow!({ id: '9', name: 'New', tenantId: 'globex' } as Partial<Row>)
80
+ expect(created.tenantId).toBe('acme')
81
+ const { rows } = await backend.getRows(req())
82
+ expect(rows.find((r) => r.id === '9')!.tenantId).toBe('acme')
83
+ })
84
+
85
+ it('rejects updating a row in another tenant', async () => {
86
+ const { client } = wire('acme')
87
+ await expect(client.updateRow!('3', { name: 'hacked' })).rejects.toThrow()
88
+ })
89
+
90
+ it('rejects deleting a row in another tenant', async () => {
91
+ const { client } = wire('acme')
92
+ await expect(client.deleteRow!('3')).rejects.toThrow()
93
+ })
94
+
95
+ it('leaves the other tenant\'s data untouched after a rejected write', async () => {
96
+ const { client, backend } = wire('acme')
97
+ await client.updateRow!('3', { name: 'hacked' }).catch(() => {})
98
+ await client.deleteRow!('3').catch(() => {})
99
+ const { rows } = await backend.getRows(req())
100
+ expect(rows.find((r) => r.id === '3')).toEqual({ id: '3', name: 'Globex secret', tenantId: 'globex' })
101
+ })
102
+
103
+ it('allows updating and deleting the caller\'s own rows', async () => {
104
+ const { client } = wire('acme')
105
+ const updated = await client.updateRow!('1', { name: 'Renamed' })
106
+ expect(updated.name).toBe('Renamed')
107
+ await expect(client.deleteRow!('2')).resolves.not.toThrow()
108
+ })
109
+
110
+ it('a patch cannot move a row into another tenant', async () => {
111
+ const { client, backend } = wire('acme')
112
+ await client.updateRow!('1', { tenantId: 'globex' } as Partial<Row>)
113
+ const { rows } = await backend.getRows(req())
114
+ expect(rows.find((r) => r.id === '1')!.tenantId).toBe('acme')
115
+ })
116
+ })
117
+
118
+ describe('row scoping: resolver failure', () => {
119
+ it('rejects rather than falling through to an unscoped query', async () => {
120
+ // The dangerous failure mode: "I cannot tell which tenant you are" must not
121
+ // mean "show everything".
122
+ const { client } = wire('acme', { throwOnResolve: true })
123
+ await expect(client.getRows(req())).rejects.toThrow()
124
+ })
125
+ })