@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.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sveltekit/index.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
+
})
|