@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
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvAlertsPanel - a drawer listing fired alerts (newest first) from the shared
4
+ * alertStore. Filter by severity, acknowledge, clear, and jump to the row that
5
+ * fired. This is the audit trail of what the rules have caught.
6
+ */
7
+ import { SvDrawer } from '@svgrid/grid'
8
+ import { alertStore } from './alerts/alert-store.svelte'
9
+ import type { AlertEvent, AlertSeverity } from './alerts/alert-types'
10
+
11
+ type Props = {
12
+ open?: boolean
13
+ onJump?: (event: AlertEvent) => void
14
+ onClose?: () => void
15
+ }
16
+
17
+ let { open = $bindable(false), onJump, onClose }: Props = $props()
18
+
19
+ let filter = $state<AlertSeverity | 'all'>('all')
20
+
21
+ const SEV_COLOR: Record<AlertSeverity, string> = {
22
+ info: '#3b82f6', success: '#16a34a', warning: '#d97706', error: '#dc2626',
23
+ }
24
+
25
+ const shown = $derived(
26
+ filter === 'all' ? alertStore.events : alertStore.events.filter((e) => e.severity === filter),
27
+ )
28
+
29
+ function timeLabel(ms: number): string {
30
+ try {
31
+ return new Date(ms).toLocaleTimeString()
32
+ } catch {
33
+ return ''
34
+ }
35
+ }
36
+ </script>
37
+
38
+ <SvDrawer bind:open side="right" size="380px" title="Alerts" onClose={() => onClose?.()}>
39
+ <div class="ap-toolbar">
40
+ <div class="ap-filters" role="group" aria-label="Filter by severity">
41
+ {#each ['all', 'error', 'warning', 'info', 'success'] as sev (sev)}
42
+ <button type="button" class:ap-on={filter === sev} onclick={() => (filter = sev as AlertSeverity | 'all')}>{sev}</button>
43
+ {/each}
44
+ </div>
45
+ <button type="button" class="ap-clear" onclick={() => alertStore.clear()} disabled={alertStore.events.length === 0}>Clear</button>
46
+ </div>
47
+
48
+ {#if shown.length === 0}
49
+ <div class="ap-empty">
50
+ <svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51
+ <path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 0 1-3.4 0" />
52
+ </svg>
53
+ <p>No alerts{filter === 'all' ? ' yet' : ` at ${filter}`}.</p>
54
+ <span>Rules fire here as your data changes.</span>
55
+ </div>
56
+ {:else}
57
+ <ul class="ap-list">
58
+ {#each shown as event, i (event.firedAt + '-' + i)}
59
+ <li class="ap-item" class:ap-ack={event.acknowledged} style={`--sev: ${SEV_COLOR[event.severity]}`}>
60
+ <span class="ap-dot" aria-hidden="true"></span>
61
+ <div class="ap-content">
62
+ <p class="ap-msg">{event.message}</p>
63
+ <div class="ap-meta">
64
+ <span class="ap-rule">{event.ruleName}</span>
65
+ <span class="ap-dotsep" aria-hidden="true"></span>
66
+ <span>{timeLabel(event.firedAt)}</span>
67
+ </div>
68
+ <div class="ap-row-actions">
69
+ {#if event.rowId != null && onJump}
70
+ <button type="button" onclick={() => onJump?.(event)}>Go to row</button>
71
+ {/if}
72
+ {#if !event.acknowledged}
73
+ <button type="button" onclick={() => alertStore.acknowledge(alertStore.events.indexOf(event))}>Acknowledge</button>
74
+ {/if}
75
+ </div>
76
+ </div>
77
+ </li>
78
+ {/each}
79
+ </ul>
80
+ {/if}
81
+
82
+ {#snippet footer()}
83
+ <button type="button" class="ap-ack-all" onclick={() => alertStore.acknowledgeAll()} disabled={alertStore.activeCount === 0}>
84
+ Acknowledge all ({alertStore.activeCount})
85
+ </button>
86
+ {/snippet}
87
+ </SvDrawer>
88
+
89
+ <style>
90
+ /* Theme-agnostic surfaces: currentColor + inherit (readable in any theme). */
91
+ .ap-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; font-family: var(--sg-font, inherit); color: inherit; }
92
+ .ap-filters { display: inline-flex; flex-wrap: wrap; gap: 4px; }
93
+ .ap-filters button, .ap-clear {
94
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent); background: transparent; border-radius: 999px;
95
+ padding: 3px 11px; font-size: 11px; font-weight: 500; cursor: pointer; color: inherit; opacity: 0.7;
96
+ text-transform: capitalize; transition: background 0.13s ease, opacity 0.13s ease, border-color 0.13s ease;
97
+ }
98
+ .ap-filters button:hover, .ap-clear:hover { background: color-mix(in srgb, currentColor 10%, transparent); opacity: 1; }
99
+ .ap-filters button.ap-on { background: var(--sg-accent, #4f46e5); color: #fff; border-color: transparent; opacity: 1; }
100
+ .ap-clear:disabled { opacity: 0.4; cursor: default; }
101
+
102
+ .ap-empty { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 13px; padding: 40px 12px; text-align: center; }
103
+ .ap-empty svg { color: var(--sg-accent, #4f46e5); opacity: 0.7; margin-bottom: 6px; }
104
+ .ap-empty p { margin: 0; font-weight: 600; }
105
+ .ap-empty span { font-size: 12px; opacity: 0.65; }
106
+
107
+ .ap-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-family: var(--sg-font, inherit); color: inherit; }
108
+ .ap-item {
109
+ display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: start;
110
+ border: 1px solid color-mix(in srgb, currentColor 12%, transparent); border-left: 3px solid var(--sev);
111
+ border-radius: 10px; padding: 10px 12px; background: color-mix(in srgb, currentColor 3%, transparent);
112
+ transition: box-shadow 0.14s ease;
113
+ }
114
+ .ap-item:hover { box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--sev) 55%, transparent); }
115
+ .ap-item.ap-ack { opacity: 0.5; }
116
+ .ap-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 999px; background: var(--sev); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sev) 20%, transparent); }
117
+ .ap-content { min-width: 0; }
118
+ .ap-msg { margin: 0; font-size: 13px; line-height: 1.4; }
119
+ .ap-meta { display: flex; align-items: center; gap: 7px; font-size: 11px; opacity: 0.55; margin-top: 5px; }
120
+ .ap-rule { font-weight: 600; }
121
+ .ap-dotsep { width: 3px; height: 3px; border-radius: 999px; background: currentColor; opacity: 0.6; }
122
+ .ap-row-actions { display: flex; gap: 12px; margin-top: 8px; }
123
+ .ap-row-actions button { border: 0; background: transparent; color: var(--sg-accent, #4f46e5); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0; }
124
+ .ap-row-actions button:hover { text-decoration: underline; }
125
+
126
+ .ap-ack-all { width: 100%; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); background: color-mix(in srgb, currentColor 4%, transparent); border-radius: 8px; padding: 8px; font-size: 13px; font-weight: 500; cursor: pointer; color: inherit; transition: background 0.13s ease; }
127
+ .ap-ack-all:hover:not(:disabled) { background: color-mix(in srgb, currentColor 11%, transparent); }
128
+ .ap-ack-all:disabled { opacity: 0.5; cursor: default; }
129
+ </style>
@@ -26,7 +26,9 @@
26
26
  loading = false,
27
27
  height,
28
28
  }: {
29
- schema: EntitySchema
29
+ // Accept any specialized EntitySchema<T> (the type param is invariant); this is
30
+ // pure presentation over the schema's field metadata + plain record rows.
31
+ schema: EntitySchema<any>
30
32
  rows?: ReadonlyArray<Row>
31
33
  /** Show shimmer placeholders (first load) instead of columns. */
32
34
  loading?: boolean
@@ -0,0 +1,341 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvExpressionEditor - authors a predicate expression, either as a structured
4
+ * list of conditions (default) or as free text. Live-validates and, given a
5
+ * `rows` sample, shows how many rows currently match. Reuses the grid UI kit
6
+ * (SvDropDownList / SvTextInput / SvTagsInput) so it feels like the filter row.
7
+ */
8
+ import {
9
+ SvDropDownList,
10
+ SvTextInput,
11
+ SvTextArea,
12
+ SvTagsInput,
13
+ type ExcelFilterOperator,
14
+ } from '@svgrid/grid'
15
+ import { evaluatePredicate } from './expressions/evaluate'
16
+ import {
17
+ ExpressionParseError,
18
+ parsePredicate,
19
+ stringifyPredicate,
20
+ validateExpression,
21
+ } from './expressions/parse'
22
+ import {
23
+ columnById,
24
+ isRangeOperator,
25
+ isSetOperator,
26
+ isValueless,
27
+ operatorsForType,
28
+ type ExprColumn,
29
+ } from './expressions/expression-columns'
30
+ import type { PredicateExpr } from './expressions/expression-types'
31
+
32
+ type Condition = {
33
+ column: string
34
+ op: ExcelFilterOperator
35
+ value: string
36
+ valueTo: string
37
+ values: string[]
38
+ }
39
+
40
+ type Props = {
41
+ columns: ReadonlyArray<ExprColumn>
42
+ value?: PredicateExpr
43
+ rows?: ReadonlyArray<Record<string, unknown>>
44
+ onChange?: (value: PredicateExpr) => void
45
+ mode?: 'builder' | 'text'
46
+ }
47
+
48
+ let {
49
+ columns,
50
+ value = $bindable<PredicateExpr>({ kind: 'const', value: true }),
51
+ rows = [],
52
+ onChange,
53
+ mode = $bindable<'builder' | 'text'>('builder'),
54
+ }: Props = $props()
55
+
56
+ // --- Builder <-> predicate conversion ------------------------------------
57
+
58
+ function toConditions(expr: PredicateExpr): { combinator: 'and' | 'or'; conditions: Condition[] } | null {
59
+ const parts =
60
+ expr.kind === 'and' || expr.kind === 'or' ? expr.parts : [expr]
61
+ const combinator = expr.kind === 'or' ? 'or' : 'and'
62
+ const conditions: Condition[] = []
63
+ for (const p of parts) {
64
+ if (p.kind !== 'cmp') return null // scalarCmp / nested -> text mode only
65
+ conditions.push({
66
+ column: p.column,
67
+ op: p.op,
68
+ value: p.value != null && !Array.isArray(p.value) ? String(p.value) : '',
69
+ valueTo: p.valueTo != null ? String(p.valueTo) : '',
70
+ values: Array.isArray(p.value) ? p.value.map(String) : [],
71
+ })
72
+ }
73
+ return { combinator, conditions }
74
+ }
75
+
76
+ function fromConditions(combinator: 'and' | 'or', conditions: Condition[]): PredicateExpr {
77
+ const parts: PredicateExpr[] = conditions.map((c) => {
78
+ const base = { kind: 'cmp' as const, column: c.column, op: c.op }
79
+ if (isValueless(c.op)) return base
80
+ if (isSetOperator(c.op)) return { ...base, value: c.values }
81
+ if (isRangeOperator(c.op)) return { ...base, value: c.value, valueTo: c.valueTo }
82
+ return { ...base, value: c.value }
83
+ })
84
+ if (parts.length === 0) return { kind: 'const', value: true }
85
+ if (parts.length === 1) return parts[0]
86
+ return { kind: combinator, parts }
87
+ }
88
+
89
+ const initial = toConditions(value)
90
+ let combinator = $state<'and' | 'or'>(initial?.combinator ?? 'and')
91
+ let conditions = $state<Condition[]>(
92
+ initial?.conditions ?? [freshCondition()],
93
+ )
94
+ // If the incoming value is not representable as a flat condition list, start
95
+ // in text mode so nothing is lost.
96
+ if (!initial && mode === 'builder') mode = 'text'
97
+
98
+ let text = $state(stringifyPredicate(value, columns))
99
+ let textError = $state<string | null>(null)
100
+
101
+ function freshCondition(): Condition {
102
+ const first = columns[0]
103
+ const ops = operatorsForType(first?.type)
104
+ return { column: first?.id ?? '', op: ops[0]?.value ?? 'equals', value: '', valueTo: '', values: [] }
105
+ }
106
+
107
+ const columnOptions = $derived(columns.map((c) => ({ value: c.id, label: c.name ?? c.id })))
108
+
109
+ function opsFor(columnId: string) {
110
+ const type = columnById(columns, columnId)?.type
111
+ return operatorsForType(type).map((o) => ({ value: o.value, label: o.label }))
112
+ }
113
+
114
+ // --- Emit ----------------------------------------------------------------
115
+
116
+ function emitBuilder() {
117
+ const expr = fromConditions(combinator, conditions)
118
+ value = expr
119
+ text = stringifyPredicate(expr, columns)
120
+ textError = null
121
+ onChange?.(expr)
122
+ }
123
+
124
+ function emitText() {
125
+ if (!text.trim()) {
126
+ textError = 'Expression is empty'
127
+ return
128
+ }
129
+ try {
130
+ const expr = parsePredicate(text, columns)
131
+ const problems = validateExpression(expr, columns)
132
+ textError = problems.length ? problems.join('; ') : null
133
+ if (!problems.length) {
134
+ value = expr
135
+ onChange?.(expr)
136
+ }
137
+ } catch (err) {
138
+ textError = err instanceof ExpressionParseError ? err.message : 'Invalid expression'
139
+ }
140
+ }
141
+
142
+ function addCondition() {
143
+ conditions = [...conditions, freshCondition()]
144
+ emitBuilder()
145
+ }
146
+ function removeCondition(i: number) {
147
+ conditions = conditions.filter((_, idx) => idx !== i)
148
+ if (conditions.length === 0) conditions = [freshCondition()]
149
+ emitBuilder()
150
+ }
151
+ function setColumn(i: number, columnId: string) {
152
+ const ops = opsFor(columnId)
153
+ conditions[i].column = columnId
154
+ if (!ops.some((o) => o.value === conditions[i].op)) conditions[i].op = ops[0]?.value ?? 'equals'
155
+ emitBuilder()
156
+ }
157
+
158
+ function switchMode(next: 'builder' | 'text') {
159
+ if (next === mode) return
160
+ if (next === 'text') {
161
+ text = stringifyPredicate(value, columns)
162
+ textError = null
163
+ } else {
164
+ // Re-seed the builder from the parsed text, if representable.
165
+ try {
166
+ const expr = parsePredicate(text, columns)
167
+ const c = toConditions(expr)
168
+ if (!c) {
169
+ textError = 'This expression uses maths or grouping - stay in text mode'
170
+ return
171
+ }
172
+ combinator = c.combinator
173
+ conditions = c.conditions
174
+ value = expr
175
+ } catch {
176
+ textError = 'Fix the expression before switching'
177
+ return
178
+ }
179
+ }
180
+ mode = next
181
+ }
182
+
183
+ // --- Live preview --------------------------------------------------------
184
+
185
+ // Cap how many rows the live "matches" counter scans. Rule editors are often
186
+ // opened over the full grid (100k+ rows) and this recomputes on every keystroke
187
+ // / condition edit, so an uncapped scan would freeze the editor. We sample the
188
+ // first N rows and label the count as approximate when the set is larger.
189
+ const PREVIEW_SAMPLE = 2000
190
+
191
+ const matchPreview = $derived.by(() => {
192
+ const total = rows.length
193
+ if (!total) return null
194
+ const limit = total > PREVIEW_SAMPLE ? PREVIEW_SAMPLE : total
195
+ try {
196
+ let n = 0
197
+ for (let i = 0; i < limit; i++) {
198
+ const row = rows[i]
199
+ if (row && evaluatePredicate(value, { row })) n++
200
+ }
201
+ return { n, sampled: limit, total }
202
+ } catch {
203
+ return null
204
+ }
205
+ })
206
+ </script>
207
+
208
+ <div class="sx-editor">
209
+ <div class="sx-head">
210
+ <div class="sx-modes" role="tablist" aria-label="Expression editor mode">
211
+ <button
212
+ type="button"
213
+ role="tab"
214
+ aria-selected={mode === 'builder'}
215
+ class:sx-active={mode === 'builder'}
216
+ onclick={() => switchMode('builder')}>Builder</button>
217
+ <button
218
+ type="button"
219
+ role="tab"
220
+ aria-selected={mode === 'text'}
221
+ class:sx-active={mode === 'text'}
222
+ onclick={() => switchMode('text')}>Text</button>
223
+ </div>
224
+ {#if matchPreview}
225
+ <span class="sx-match" aria-live="polite">
226
+ {#if matchPreview.sampled < matchPreview.total}
227
+ Matches ~{matchPreview.n} of first {matchPreview.sampled}
228
+ {:else}
229
+ Matches {matchPreview.n} of {matchPreview.total}
230
+ {/if}
231
+ </span>
232
+ {/if}
233
+ </div>
234
+
235
+ {#if mode === 'builder'}
236
+ <div class="sx-combinator">
237
+ Match
238
+ <SvDropDownList
239
+ size="sm"
240
+ options={[{ value: 'and', label: 'all' }, { value: 'or', label: 'any' }]}
241
+ value={combinator}
242
+ onChange={(v) => { combinator = v as 'and' | 'or'; emitBuilder() }}
243
+ ariaLabel="Combine conditions" />
244
+ of the following:
245
+ </div>
246
+ <div class="sx-rows">
247
+ {#each conditions as cond, i (i)}
248
+ <div class="sx-row">
249
+ <SvDropDownList
250
+ size="sm"
251
+ options={columnOptions}
252
+ value={cond.column}
253
+ onChange={(v) => setColumn(i, String(v))}
254
+ ariaLabel="Column" />
255
+ <SvDropDownList
256
+ size="sm"
257
+ options={opsFor(cond.column)}
258
+ value={cond.op}
259
+ onChange={(v) => { conditions[i].op = v as ExcelFilterOperator; emitBuilder() }}
260
+ ariaLabel="Operator" />
261
+ {#if isValueless(cond.op)}
262
+ <span class="sx-noval">-</span>
263
+ {:else if isSetOperator(cond.op)}
264
+ <SvTagsInput
265
+ value={cond.values}
266
+ onChange={(vals) => { conditions[i].values = vals; emitBuilder() }}
267
+ placeholder="Add value…"
268
+ ariaLabel="Values" />
269
+ {:else if isRangeOperator(cond.op)}
270
+ <SvTextInput
271
+ size="sm"
272
+ value={cond.value}
273
+ onChange={(v) => { conditions[i].value = v; emitBuilder() }}
274
+ placeholder="From"
275
+ ariaLabel="From" />
276
+ <SvTextInput
277
+ size="sm"
278
+ value={cond.valueTo}
279
+ onChange={(v) => { conditions[i].valueTo = v; emitBuilder() }}
280
+ placeholder="To"
281
+ ariaLabel="To" />
282
+ {:else}
283
+ <SvTextInput
284
+ size="sm"
285
+ value={cond.value}
286
+ onChange={(v) => { conditions[i].value = v; emitBuilder() }}
287
+ placeholder="Value"
288
+ ariaLabel="Value" />
289
+ {/if}
290
+ <button type="button" class="sx-del" onclick={() => removeCondition(i)} aria-label="Remove condition">✕</button>
291
+ </div>
292
+ {/each}
293
+ </div>
294
+ <button type="button" class="sx-add" onclick={addCondition}>+ Add condition</button>
295
+ {:else}
296
+ <SvTextArea
297
+ value={text}
298
+ onChange={(v) => { text = v; emitText() }}
299
+ rows={3}
300
+ placeholder={'e.g. price > 100 AND region IN ("EU", "US")'}
301
+ ariaLabel="Expression text" />
302
+ {#if textError}
303
+ <p class="sx-error" role="alert">{textError}</p>
304
+ {:else}
305
+ <p class="sx-hint">Use AND / OR / NOT, CONTAINS, IN (…), BETWEEN … AND …, and column maths.</p>
306
+ {/if}
307
+ {/if}
308
+ </div>
309
+
310
+ <style>
311
+ /* Theme-agnostic surfaces (see SvGridAlerts): currentColor + inherit. */
312
+ .sx-editor {
313
+ display: flex; flex-direction: column; gap: 10px; font-family: var(--sg-font, inherit); color: inherit;
314
+ background: color-mix(in srgb, currentColor 4%, transparent);
315
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
316
+ border-radius: 10px; padding: 12px;
317
+ }
318
+ .sx-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
319
+ .sx-modes { display: inline-flex; padding: 2px; border: 1px solid color-mix(in srgb, currentColor 16%, transparent); border-radius: 8px; gap: 2px; }
320
+ .sx-modes button { border: 0; background: transparent; color: inherit; opacity: 0.65; padding: 4px 12px; font-size: 12px; font-weight: 500; border-radius: 6px; cursor: pointer; transition: background 0.13s ease, opacity 0.13s ease; }
321
+ .sx-modes button:hover { opacity: 1; }
322
+ .sx-modes button.sx-active { background: var(--sg-accent, #4f46e5); color: #fff; opacity: 1; }
323
+ .sx-match {
324
+ font-size: 11.5px; font-weight: 600; color: var(--sg-accent, #4f46e5);
325
+ background: color-mix(in srgb, var(--sg-accent, #4f46e5) 14%, transparent);
326
+ padding: 3px 9px; border-radius: 999px;
327
+ }
328
+ .sx-combinator { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.7; }
329
+ .sx-rows { display: flex; flex-direction: column; gap: 7px; }
330
+ .sx-row {
331
+ display: grid; grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr) 2fr auto; gap: 7px; align-items: center;
332
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 9px; padding: 8px;
333
+ }
334
+ .sx-noval { text-align: center; font-size: 13px; opacity: 0.5; }
335
+ .sx-del { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; background: transparent; color: inherit; opacity: 0.55; cursor: pointer; font-size: 13px; border-radius: 7px; transition: background 0.13s ease, color 0.13s ease, opacity 0.13s ease; }
336
+ .sx-del:hover { color: var(--sg-danger, #dc2626); opacity: 1; background: color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent); }
337
+ .sx-add { align-self: flex-start; border: 1px dashed color-mix(in srgb, var(--sg-accent, #4f46e5) 45%, currentColor); background: transparent; color: var(--sg-accent, #4f46e5); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.13s ease; }
338
+ .sx-add:hover { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 10%, transparent); }
339
+ .sx-error { margin: 0; font-size: 12px; color: var(--sg-danger, #dc2626); }
340
+ .sx-hint { margin: 0; font-size: 12px; opacity: 0.55; }
341
+ </style>
@@ -0,0 +1,113 @@
1
+ /**
2
+ * DOM tests for the alerts overlay + editors. Covers (1) the observe -> fire
3
+ * loop through `attachAlertEngine` (which needs the browser build for the toast
4
+ * store), and (2) that the Svelte surfaces mount and render without error.
5
+ */
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvGridAlerts from './SvGridAlerts.svelte'
9
+ import SvExpressionEditor from './SvExpressionEditor.svelte'
10
+ import SvAlertsManager from './SvAlertsManager.svelte'
11
+ import { attachAlertEngine } from './alerts/alert-engine-attach'
12
+ import { alertStore } from './alerts/alert-store.svelte'
13
+ import { createAlertRules, memoryAlertRules } from './alerts/alert-storage'
14
+ import type { AlertRule } from './alerts/alert-types'
15
+ import type { ExprColumn } from './expressions/expression-columns'
16
+
17
+ type Row = { id: string; price: number; region: string }
18
+
19
+ const columns: ExprColumn[] = [
20
+ { id: 'price', name: 'Price', type: 'number' },
21
+ { id: 'region', name: 'Region', type: 'text' },
22
+ ]
23
+
24
+ const highPrice: AlertRule = {
25
+ id: 'r1',
26
+ name: 'High price',
27
+ enabled: true,
28
+ severity: 'warning',
29
+ scope: 'row',
30
+ predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
31
+ trigger: { type: 'dataChange' },
32
+ actions: [{ kind: 'toast', message: '{region} at {value}' }, { kind: 'highlight', style: { background: '#fee2e2' } }],
33
+ createdAt: 0,
34
+ }
35
+
36
+ let host: HTMLElement | null = null
37
+ let comp: ReturnType<typeof mount> | null = null
38
+ afterEach(() => {
39
+ if (comp) { unmount(comp); comp = null }
40
+ if (host) { host.remove(); host = null }
41
+ alertStore.clear()
42
+ })
43
+
44
+ function render(Component: unknown, props: Record<string, unknown>): HTMLElement {
45
+ host = document.createElement('div')
46
+ document.body.appendChild(host)
47
+ comp = mount(Component as never, { target: host, props })
48
+ flushSync()
49
+ return host
50
+ }
51
+
52
+ describe('attachAlertEngine observe -> fire loop', () => {
53
+ it('seeds silently, then fires when a row newly matches', () => {
54
+ const rows: Row[] = [
55
+ { id: 'a', price: 80, region: 'EU' },
56
+ { id: 'b', price: 90, region: 'US' },
57
+ ]
58
+ const attach = attachAlertEngine<Row>({
59
+ rules: [highPrice],
60
+ getRowId: (r) => r.id,
61
+ getData: () => rows,
62
+ intervalMs: 0, // drive manually
63
+ })
64
+
65
+ attach.tick() // initial seed - nothing above 100 yet
66
+ expect(alertStore.events).toHaveLength(0)
67
+
68
+ rows[0] = { ...rows[0]!, price: 130 } // 'a' crosses the threshold
69
+ attach.tick()
70
+ expect(alertStore.events.length).toBe(1)
71
+ expect(alertStore.events[0]!.message).toBe('EU at 130')
72
+
73
+ // Staying above the line must not re-fire.
74
+ attach.tick()
75
+ expect(alertStore.events.length).toBe(1)
76
+ attach.detach()
77
+ })
78
+ })
79
+
80
+ describe('SvGridAlerts (DOM)', () => {
81
+ it('renders the alerts controls', () => {
82
+ const el = render(SvGridAlerts, {
83
+ data: [{ id: 'a', price: 120, region: 'EU' }],
84
+ columns,
85
+ rules: [highPrice],
86
+ })
87
+ expect(el.querySelector('.sg-alerts-bell')).toBeTruthy()
88
+ expect(el.textContent).toContain('Manage alerts')
89
+ })
90
+ })
91
+
92
+ describe('SvExpressionEditor (DOM)', () => {
93
+ it('renders builder + text mode tabs', () => {
94
+ const el = render(SvExpressionEditor, {
95
+ columns,
96
+ value: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
97
+ rows: [{ price: 120 }, { price: 50 }],
98
+ })
99
+ expect(el.textContent).toContain('Builder')
100
+ expect(el.textContent).toContain('Text')
101
+ // Live preview counts matches over the sample rows.
102
+ expect(el.textContent).toContain('Matches 1 of 2')
103
+ })
104
+ })
105
+
106
+ describe('SvAlertsManager (DOM)', () => {
107
+ it('lists persisted rules when open', () => {
108
+ const manager = createAlertRules(memoryAlertRules([highPrice]))
109
+ render(SvAlertsManager, { open: true, manager, columns })
110
+ // The manager renders inside a portalled modal.
111
+ expect(document.body.textContent).toContain('High price')
112
+ })
113
+ })