@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
package/src/board.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * board (view) - registers the Enterprise Kanban board renderer with the grid so
3
+ * `<SvGrid board={...}>` renders lanes of cards instead of the table. The grid
4
+ * ships the `board` prop and its config types for free; the *renderer*
5
+ * (SvGridBoard) is Pro and plugs in through the grid's `registerBoardView` seam.
6
+ *
7
+ * Soft-gated like the rest of Enterprise: it works without a license key but the
8
+ * grid shows the "unlicensed" watermark + a one-time console nudge.
9
+ *
10
+ * ```ts
11
+ * import { setLicenseKey, enableBoardView } from '@svgrid/enterprise'
12
+ * setLicenseKey('YOUR-KEY')
13
+ * enableBoardView()
14
+ * // then: <SvGrid {data} {columns} board={{ groupBy: 'status' }} />
15
+ * ```
16
+ */
17
+ import { registerBoardView } from '@svgrid/grid'
18
+ import { isLicenseKeySet } from './license'
19
+ import { emitUnlicensedNudge } from './watermark'
20
+ import SvGridBoard from './SvGridBoard.svelte'
21
+
22
+ let enabled = false
23
+
24
+ /**
25
+ * Register the Enterprise Kanban board view. Idempotent - safe to call from every
26
+ * component that uses `board`, or once at app start. Also invoked by
27
+ * {@link installEnterprise} so wiring the Pro API turns the view on too.
28
+ */
29
+ export function enableBoardView(): void {
30
+ if (enabled) return
31
+ enabled = true
32
+ registerBoardView(SvGridBoard as never)
33
+ if (!isLicenseKeySet()) emitUnlicensedNudge()
34
+ }
35
+
36
+ export { default as SvGridBoard } from './SvGridBoard.svelte'
@@ -83,6 +83,10 @@ const XML_ESCAPE: Record<string, string> = {
83
83
  "'": '&apos;',
84
84
  }
85
85
  // Control chars that are illegal in XML 1.0 (would make Excel reject the file).
86
+ // XML 1.0 forbids these control characters outright, so they are stripped
87
+ // rather than escaped - a stray \x00 from a data source would otherwise
88
+ // produce a workbook Excel refuses to open.
89
+ // eslint-disable-next-line no-control-regex
86
90
  const INVALID_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g
87
91
  function esc(s: string): string {
88
92
  return s.replace(INVALID_XML, '').replace(/[&<>"']/g, (c) => XML_ESCAPE[c]!)
package/src/export-xls.ts CHANGED
@@ -17,6 +17,9 @@ import type { RowData } from '@svgrid/grid'
17
17
  import type { SerializeOptions } from './export-serialize'
18
18
 
19
19
  // Control chars illegal in XML 1.0 - strip so a stray \x00 can't corrupt the file.
20
+ // See export-ooxml.ts: XML 1.0 has no representation for these at all, so a
21
+ // control character has to be dropped before it reaches the file.
22
+ // eslint-disable-next-line no-control-regex
20
23
  const INVALID_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g
21
24
  function xmlEscape(s: string): string {
22
25
  return s
package/src/export.ts CHANGED
@@ -349,6 +349,9 @@ async function ensureGlobals(format: ExportFormat): Promise<void> {
349
349
  if (format === 'xlsx' && g.JSZip == null) {
350
350
  let mod: unknown
351
351
  try {
352
+ // Optional peer, loaded on demand. The `@ts-ignore` keeps a consumer's
353
+ // type-check clean when jszip isn't installed (no-op when it is).
354
+ // @ts-ignore - "jszip" is an optional peerDependency
352
355
  mod = await import('jszip')
353
356
  } catch {
354
357
  throw new Error(
@@ -362,7 +365,10 @@ async function ensureGlobals(format: ExportFormat): Promise<void> {
362
365
  let pdfMakeMod: unknown
363
366
  let vfsMod: unknown
364
367
  try {
368
+ // Optional peer, loaded on demand (keeps consumer type-checks clean when absent).
369
+ // @ts-ignore - "pdfmake" is an optional peerDependency
365
370
  pdfMakeMod = await import('pdfmake/build/pdfmake')
371
+ // @ts-ignore - "pdfmake" is an optional peerDependency
366
372
  vfsMod = await import('pdfmake/build/vfs_fonts')
367
373
  } catch {
368
374
  throw new Error(
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { evaluatePredicate, evaluateScalar, evaluateChange } from './evaluate'
3
+ import type { ChangeExpr, EvalContext, PredicateExpr, ScalarExpr } from './expression-types'
4
+
5
+ type Row = { price: number; qty: number; name: string; region: string }
6
+
7
+ const row: Row = { price: 120, qty: 3, name: 'Widget', region: 'EU' }
8
+ const ctx = (r: Row, extra: Partial<EvalContext<Row>> = {}): EvalContext<Row> => ({ row: r, ...extra })
9
+
10
+ describe('evaluateScalar', () => {
11
+ it('reads columns, literals and arithmetic', () => {
12
+ const total: ScalarExpr = { kind: 'bin', op: '*', left: { kind: 'col', id: 'price' }, right: { kind: 'col', id: 'qty' } }
13
+ expect(evaluateScalar(total, ctx(row))).toBe(360)
14
+ expect(evaluateScalar({ kind: 'lit', value: 5 }, ctx(row))).toBe(5)
15
+ expect(evaluateScalar({ kind: 'neg', expr: { kind: 'col', id: 'price' } }, ctx(row))).toBe(-120)
16
+ })
17
+
18
+ it('concatenates strings with +', () => {
19
+ const e: ScalarExpr = { kind: 'bin', op: '+', left: { kind: 'col', id: 'name' }, right: { kind: 'lit', value: '!' } }
20
+ expect(evaluateScalar(e, ctx(row))).toBe('Widget!')
21
+ })
22
+
23
+ it('guards divide by zero', () => {
24
+ const e: ScalarExpr = { kind: 'bin', op: '/', left: { kind: 'lit', value: 10 }, right: { kind: 'lit', value: 0 } }
25
+ expect(Number.isNaN(evaluateScalar(e, ctx(row)) as number)).toBe(true)
26
+ })
27
+
28
+ it('runs built-in functions', () => {
29
+ expect(evaluateScalar({ kind: 'func', name: 'ABS', args: [{ kind: 'lit', value: -7 }] }, ctx(row))).toBe(7)
30
+ expect(evaluateScalar({ kind: 'func', name: 'ROUND', args: [{ kind: 'lit', value: 3.14159 }, { kind: 'lit', value: 2 }] }, ctx(row))).toBe(3.14)
31
+ })
32
+
33
+ it('aggregates over the row set in scope', () => {
34
+ const rows: Row[] = [row, { ...row, price: 80 }, { ...row, price: 40 }]
35
+ const sum: ScalarExpr = { kind: 'agg', fn: 'sum', column: 'price' }
36
+ expect(evaluateScalar(sum, ctx(row, { rows }))).toBe(240)
37
+ expect(evaluateScalar({ kind: 'agg', fn: 'avg', column: 'price' }, ctx(row, { rows }))).toBe(80)
38
+ expect(evaluateScalar({ kind: 'agg', fn: 'count', column: 'price' }, ctx(row, { rows }))).toBe(3)
39
+ })
40
+ })
41
+
42
+ describe('evaluatePredicate', () => {
43
+ it('cmp delegates to applyExcelFilter semantics', () => {
44
+ const gt: PredicateExpr = { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 }
45
+ expect(evaluatePredicate(gt, ctx(row))).toBe(true)
46
+ expect(evaluatePredicate(gt, ctx({ ...row, price: 50 }))).toBe(false)
47
+
48
+ const contains: PredicateExpr = { kind: 'cmp', column: 'name', op: 'contains', value: 'idge' }
49
+ expect(evaluatePredicate(contains, ctx(row))).toBe(true)
50
+
51
+ const inSet: PredicateExpr = { kind: 'cmp', column: 'region', op: 'in', value: ['EU', 'US'] }
52
+ expect(evaluatePredicate(inSet, ctx(row))).toBe(true)
53
+ expect(evaluatePredicate(inSet, ctx({ ...row, region: 'APAC' }))).toBe(false)
54
+ })
55
+
56
+ it('combines with and/or/not', () => {
57
+ const expr: PredicateExpr = {
58
+ kind: 'and',
59
+ parts: [
60
+ { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
61
+ { kind: 'not', expr: { kind: 'cmp', column: 'region', op: 'equals', value: 'US' } },
62
+ ],
63
+ }
64
+ expect(evaluatePredicate(expr, ctx(row))).toBe(true)
65
+ expect(evaluatePredicate(expr, ctx({ ...row, region: 'US' }))).toBe(false)
66
+ })
67
+
68
+ it('scalarCmp compares cross-column maths', () => {
69
+ // price / qty >= 40 -> 120/3 = 40 -> true
70
+ const expr: PredicateExpr = {
71
+ kind: 'scalarCmp',
72
+ left: { kind: 'bin', op: '/', left: { kind: 'col', id: 'price' }, right: { kind: 'col', id: 'qty' } },
73
+ op: '>=',
74
+ right: { kind: 'lit', value: 40 },
75
+ }
76
+ expect(evaluatePredicate(expr, ctx(row))).toBe(true)
77
+ expect(evaluatePredicate(expr, ctx({ ...row, qty: 4 }))).toBe(false)
78
+ })
79
+ })
80
+
81
+ describe('evaluateChange', () => {
82
+ const change = (e: ChangeExpr, prev: Row, next: Row) => evaluateChange(e, ctx(next, { prev }))
83
+
84
+ it('detects a bare change', () => {
85
+ expect(change({ kind: 'changed', column: 'price' }, row, { ...row, price: 121 })).toBe(true)
86
+ expect(change({ kind: 'changed', column: 'price' }, row, { ...row })).toBe(false)
87
+ })
88
+
89
+ it('returns false with no prior snapshot', () => {
90
+ expect(evaluateChange({ kind: 'changed', column: 'price' }, ctx(row))).toBe(false)
91
+ })
92
+
93
+ it('tests absolute delta', () => {
94
+ const e: ChangeExpr = { kind: 'delta', column: 'price', op: '>', value: 10, abs: true }
95
+ expect(change(e, row, { ...row, price: 140 })).toBe(true)
96
+ expect(change(e, row, { ...row, price: 100 })).toBe(true) // abs of -20
97
+ expect(change(e, row, { ...row, price: 125 })).toBe(false)
98
+ })
99
+
100
+ it('tests percent change', () => {
101
+ const e: ChangeExpr = { kind: 'percentChange', column: 'price', op: '>', value: 5, abs: true }
102
+ expect(change(e, row, { ...row, price: 132 })).toBe(true) // +10%
103
+ expect(change(e, row, { ...row, price: 123 })).toBe(false) // +2.5%
104
+ })
105
+
106
+ it('detects threshold crossing', () => {
107
+ const above: ChangeExpr = { kind: 'crossed', column: 'price', threshold: 130, direction: 'above' }
108
+ expect(change(above, row, { ...row, price: 135 })).toBe(true)
109
+ expect(change(above, { ...row, price: 131 }, { ...row, price: 140 })).toBe(false) // already above
110
+ })
111
+ })
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Expression evaluators - pure functions, no DOM. Predicate leaves delegate to
3
+ * the grid's `applyExcelFilter` so operator semantics match the filter row
4
+ * exactly; scalar maths and change detection are handled here.
5
+ */
6
+ import { applyExcelFilter } from '@svgrid/grid/filtering'
7
+ import type {
8
+ ChangeExpr,
9
+ ComparisonOp,
10
+ EvalContext,
11
+ ExprRow,
12
+ PredicateExpr,
13
+ ScalarExpr,
14
+ } from './expression-types'
15
+
16
+ type Scalar = number | string | boolean | null
17
+
18
+ function readValue<TData>(ctx: EvalContext<TData>, row: TData, columnId: string): unknown {
19
+ if (ctx.getValue) return ctx.getValue(row, columnId)
20
+ return (row as Record<string, unknown>)?.[columnId]
21
+ }
22
+
23
+ /** Number coercion that returns NaN for blanks/non-numerics (never 0). */
24
+ function num(v: unknown): number {
25
+ if (v == null || v === '') return NaN
26
+ if (typeof v === 'boolean') return v ? 1 : 0
27
+ return Number(v)
28
+ }
29
+
30
+ /** Loose value equality used by `changed` and `=` on non-numeric operands. */
31
+ function looseEqual(a: unknown, b: unknown): boolean {
32
+ if (a == null && b == null) return true
33
+ const na = num(a)
34
+ const nb = num(b)
35
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na === nb
36
+ return String(a ?? '') === String(b ?? '')
37
+ }
38
+
39
+ const BUILTINS: Record<string, (args: Scalar[]) => Scalar> = {
40
+ ABS: (a) => Math.abs(num(a[0])),
41
+ ROUND: (a) => {
42
+ const n = num(a[0])
43
+ const d = a.length > 1 ? num(a[1]) : 0
44
+ const f = 10 ** (Number.isFinite(d) ? d : 0)
45
+ return Math.round(n * f) / f
46
+ },
47
+ FLOOR: (a) => Math.floor(num(a[0])),
48
+ CEIL: (a) => Math.ceil(num(a[0])),
49
+ MIN: (a) => Math.min(...a.map(num)),
50
+ MAX: (a) => Math.max(...a.map(num)),
51
+ IF: (a) => (truthy(a[0] ?? null) ? (a[1] ?? null) : (a[2] ?? null)),
52
+ COALESCE: (a) => a.find((v) => v != null && v !== '') ?? null,
53
+ CONCAT: (a) => a.map((v) => (v == null ? '' : String(v))).join(''),
54
+ LOWER: (a) => String(a[0] ?? '').toLowerCase(),
55
+ UPPER: (a) => String(a[0] ?? '').toUpperCase(),
56
+ LEN: (a) => String(a[0] ?? '').length,
57
+ }
58
+
59
+ /** Names of the built-in scalar functions (for editor validation/autocomplete). */
60
+ export const BUILTIN_FUNCTION_NAMES: ReadonlyArray<string> = Object.keys(BUILTINS)
61
+
62
+ /** Whether `name` (any case) is a known built-in scalar function. */
63
+ export function isBuiltinFunction(name: string): boolean {
64
+ return name.toUpperCase() in BUILTINS
65
+ }
66
+
67
+ function truthy(v: Scalar): boolean {
68
+ if (typeof v === 'boolean') return v
69
+ if (v == null || v === '') return false
70
+ const n = num(v)
71
+ return Number.isFinite(n) ? n !== 0 : true
72
+ }
73
+
74
+ /** Evaluate a scalar expression to a single value. */
75
+ export function evaluateScalar<TData = ExprRow>(
76
+ expr: ScalarExpr,
77
+ ctx: EvalContext<TData>,
78
+ ): Scalar {
79
+ switch (expr.kind) {
80
+ case 'lit':
81
+ return expr.value
82
+ case 'col':
83
+ return (readValue(ctx, ctx.row, expr.id) ?? null) as Scalar
84
+ case 'neg':
85
+ return -num(evaluateScalar(expr.expr, ctx))
86
+ case 'bin': {
87
+ const l = evaluateScalar(expr.left, ctx)
88
+ const r = evaluateScalar(expr.right, ctx)
89
+ if (expr.op === '+') {
90
+ const ln = num(l)
91
+ const rn = num(r)
92
+ if (Number.isFinite(ln) && Number.isFinite(rn)) return ln + rn
93
+ return String(l ?? '') + String(r ?? '')
94
+ }
95
+ const a = num(l)
96
+ const b = num(r)
97
+ switch (expr.op) {
98
+ case '-':
99
+ return a - b
100
+ case '*':
101
+ return a * b
102
+ case '/':
103
+ return b === 0 ? NaN : a / b
104
+ case '%':
105
+ return b === 0 ? NaN : a % b
106
+ // Rules are authored at runtime, so an operator outside the union can
107
+ // arrive from parsed JSON. Without this the switch falls out of its
108
+ // case and into `case 'agg'`, reading agg fields off a binary node.
109
+ default:
110
+ return null
111
+ }
112
+ }
113
+ case 'agg': {
114
+ const rows = ctx.rows ?? [ctx.row]
115
+ if (expr.fn === 'count') return rows.length
116
+ const nums: number[] = []
117
+ for (const row of rows) {
118
+ const n = num(readValue(ctx, row, expr.column))
119
+ if (Number.isFinite(n)) nums.push(n)
120
+ }
121
+ if (nums.length === 0) return expr.fn === 'sum' ? 0 : null
122
+ switch (expr.fn) {
123
+ case 'sum':
124
+ return nums.reduce((s, n) => s + n, 0)
125
+ case 'avg':
126
+ return nums.reduce((s, n) => s + n, 0) / nums.length
127
+ case 'min':
128
+ return Math.min(...nums)
129
+ case 'max':
130
+ return Math.max(...nums)
131
+ // Same here: falling through to `case 'func'` would call
132
+ // `expr.name.toUpperCase()` on an agg node and throw.
133
+ default:
134
+ return null
135
+ }
136
+ }
137
+ case 'func': {
138
+ const name = expr.name.toUpperCase()
139
+ const args = expr.args.map((a) => evaluateScalar(a, ctx))
140
+ const fn = ctx.functions?.[name] ?? BUILTINS[name]
141
+ return fn ? fn(args) : null
142
+ }
143
+ }
144
+ }
145
+
146
+ function compareScalar(a: Scalar, op: ComparisonOp, b: Scalar): boolean {
147
+ switch (op) {
148
+ case '=':
149
+ return looseEqual(a, b)
150
+ case '!=':
151
+ return !looseEqual(a, b)
152
+ }
153
+ const na = num(a)
154
+ const nb = num(b)
155
+ const numeric = Number.isFinite(na) && Number.isFinite(nb)
156
+ const x: number | string = numeric ? na : String(a ?? '')
157
+ const y: number | string = numeric ? nb : String(b ?? '')
158
+ switch (op) {
159
+ case '>':
160
+ return x > y
161
+ case '<':
162
+ return x < y
163
+ case '>=':
164
+ return x >= y
165
+ case '<=':
166
+ return x <= y
167
+ }
168
+ }
169
+
170
+ /** Evaluate a predicate expression to a boolean for the context's row. */
171
+ export function evaluatePredicate<TData = ExprRow>(
172
+ expr: PredicateExpr,
173
+ ctx: EvalContext<TData>,
174
+ ): boolean {
175
+ switch (expr.kind) {
176
+ case 'const':
177
+ return expr.value
178
+ case 'and':
179
+ return expr.parts.every((p) => evaluatePredicate(p, ctx))
180
+ case 'or':
181
+ return expr.parts.some((p) => evaluatePredicate(p, ctx))
182
+ case 'not':
183
+ return !evaluatePredicate(expr.expr, ctx)
184
+ case 'cmp':
185
+ return applyExcelFilter(
186
+ readValue(ctx, ctx.row, expr.column),
187
+ { id: expr.column, operator: expr.op, value: expr.value, valueTo: expr.valueTo },
188
+ { locale: ctx.locale },
189
+ ) as boolean
190
+ case 'scalarCmp':
191
+ return compareScalar(
192
+ evaluateScalar(expr.left, ctx),
193
+ expr.op,
194
+ evaluateScalar(expr.right, ctx),
195
+ )
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Evaluate a change expression - true when the value moved between `ctx.prev`
201
+ * and `ctx.row` in the way the expression describes. Returns false when there
202
+ * is no prior snapshot (nothing has changed yet).
203
+ */
204
+ export function evaluateChange<TData = ExprRow>(
205
+ expr: ChangeExpr,
206
+ ctx: EvalContext<TData>,
207
+ ): boolean {
208
+ if (ctx.prev == null) return false
209
+ const next = readValue(ctx, ctx.row, expr.column)
210
+ const prev = readValue(ctx, ctx.prev, expr.column)
211
+ switch (expr.kind) {
212
+ case 'changed':
213
+ return !looseEqual(prev, next)
214
+ case 'delta': {
215
+ const p = num(prev)
216
+ const n = num(next)
217
+ if (!Number.isFinite(p) || !Number.isFinite(n)) return false
218
+ const d = expr.abs ? Math.abs(n - p) : n - p
219
+ return compareScalar(d, expr.op, expr.value)
220
+ }
221
+ case 'percentChange': {
222
+ const p = num(prev)
223
+ const n = num(next)
224
+ if (!Number.isFinite(p) || !Number.isFinite(n) || p === 0) return false
225
+ const pc = ((n - p) / Math.abs(p)) * 100
226
+ return compareScalar(expr.abs ? Math.abs(pc) : pc, expr.op, expr.value)
227
+ }
228
+ case 'crossed': {
229
+ const p = num(prev)
230
+ const n = num(next)
231
+ if (!Number.isFinite(p) || !Number.isFinite(n)) return false
232
+ return expr.direction === 'above'
233
+ ? p < expr.threshold && n >= expr.threshold
234
+ : p > expr.threshold && n <= expr.threshold
235
+ }
236
+ }
237
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Column metadata for the expression layer - the small, self-contained operator
3
+ * catalogue the editor draws from, plus name <-> id resolution for the
4
+ * `[Bracketed Name]` reference syntax.
5
+ *
6
+ * The operator set mirrors the grid's own filter row (text / number / date),
7
+ * so an expression's operators line up with what a user already knows from the
8
+ * column menu. We keep a local copy rather than importing the grid's internal
9
+ * `filter-operators` module (which is not part of the public surface).
10
+ */
11
+ import type { ExcelFilterOperator } from '@svgrid/grid'
12
+
13
+ /** Coarse value type used to decide which operators a column offers. */
14
+ export type ExprColumnType = 'text' | 'number' | 'date' | 'datetime' | 'boolean'
15
+
16
+ /** A column as the expression layer sees it. */
17
+ export type ExprColumn = {
18
+ /** Canonical id (the field key stored in the AST). */
19
+ id: string
20
+ /** Human label; also the token used inside `[Bracketed Name]` references. */
21
+ name?: string
22
+ type?: ExprColumnType
23
+ }
24
+
25
+ /** One operator entry: its label and which column types it applies to. */
26
+ export type OperatorMeta = {
27
+ value: ExcelFilterOperator
28
+ label: string
29
+ types: ReadonlyArray<ExprColumnType>
30
+ /** No value input (acts on emptiness alone). */
31
+ valueless?: boolean
32
+ /** Multi-value set membership (renders a token/chip input). */
33
+ set?: boolean
34
+ /** Needs a second value (`valueTo`). */
35
+ range?: boolean
36
+ }
37
+
38
+ const TEXT: ExprColumnType[] = ['text']
39
+ const NUM: ExprColumnType[] = ['number']
40
+ const DATES: ExprColumnType[] = ['date', 'datetime']
41
+ const ALL: ExprColumnType[] = ['text', 'number', 'date', 'datetime', 'boolean']
42
+
43
+ /** The full operator catalogue, ordered as the editor should present it. */
44
+ export const OPERATORS: ReadonlyArray<OperatorMeta> = [
45
+ { value: 'equals', label: 'Equals', types: ALL },
46
+ { value: 'notEquals', label: 'Not equals', types: ALL },
47
+ { value: 'contains', label: 'Contains', types: TEXT },
48
+ { value: 'notContains', label: 'Not contains', types: TEXT },
49
+ { value: 'startsWith', label: 'Starts with', types: TEXT },
50
+ { value: 'endsWith', label: 'Ends with', types: TEXT },
51
+ { value: 'regex', label: 'Matches regex', types: TEXT },
52
+ { value: 'greaterThan', label: 'Greater than', types: [...NUM, ...DATES] },
53
+ { value: 'lessThan', label: 'Less than', types: [...NUM, ...DATES] },
54
+ { value: 'between', label: 'Between', types: [...NUM, ...DATES], range: true },
55
+ { value: 'in', label: 'In', types: ['text', 'number'], set: true },
56
+ { value: 'notIn', label: 'Not in', types: ['text', 'number'], set: true },
57
+ { value: 'isBlank', label: 'Is blank', types: ALL, valueless: true },
58
+ { value: 'isNotBlank', label: 'Is not blank', types: ALL, valueless: true },
59
+ ]
60
+
61
+ const BY_VALUE = new Map<ExcelFilterOperator, OperatorMeta>(
62
+ OPERATORS.map((op) => [op.value, op]),
63
+ )
64
+
65
+ /** Metadata for one operator (falls back to a plain `equals`). */
66
+ export function operatorMeta(op: ExcelFilterOperator): OperatorMeta {
67
+ return BY_VALUE.get(op) ?? { value: 'equals', label: 'Equals', types: ALL }
68
+ }
69
+
70
+ /** Operators valid for a column type (defaults to the text set). */
71
+ export function operatorsForType(type: ExprColumnType | undefined): OperatorMeta[] {
72
+ const t = type ?? 'text'
73
+ return OPERATORS.filter((op) => op.types.includes(t))
74
+ }
75
+
76
+ /** Whether an operator needs no value input. */
77
+ export function isValueless(op: ExcelFilterOperator): boolean {
78
+ return operatorMeta(op).valueless === true
79
+ }
80
+
81
+ /** Whether an operator takes a multi-value token list. */
82
+ export function isSetOperator(op: ExcelFilterOperator): boolean {
83
+ return operatorMeta(op).set === true
84
+ }
85
+
86
+ /** Whether an operator needs a second (`valueTo`) value. */
87
+ export function isRangeOperator(op: ExcelFilterOperator): boolean {
88
+ return operatorMeta(op).range === true
89
+ }
90
+
91
+ /** Look a column up by its canonical id. */
92
+ export function columnById(
93
+ columns: ReadonlyArray<ExprColumn>,
94
+ id: string,
95
+ ): ExprColumn | undefined {
96
+ return columns.find((c) => c.id === id)
97
+ }
98
+
99
+ /**
100
+ * Resolve a reference (as written in an expression) to a column id. A reference
101
+ * matches a column id first, then a column name (case-insensitive) - so both
102
+ * `price` and `[Unit Price]` resolve. Returns the raw ref unchanged when
103
+ * nothing matches, leaving `validateExpression` to flag it.
104
+ */
105
+ export function resolveColumnRef(
106
+ columns: ReadonlyArray<ExprColumn>,
107
+ ref: string,
108
+ ): string {
109
+ if (columnById(columns, ref)) return ref
110
+ const lc = ref.toLowerCase()
111
+ const byName = columns.find((c) => (c.name ?? '').toLowerCase() === lc)
112
+ return byName ? byName.id : ref
113
+ }
114
+
115
+ /** The display label for a column id (its name, or the id itself). */
116
+ export function columnLabel(
117
+ columns: ReadonlyArray<ExprColumn>,
118
+ id: string,
119
+ ): string {
120
+ return columnById(columns, id)?.name ?? id
121
+ }
122
+
123
+ /**
124
+ * How a column id should be written inside an expression: `[Name]` when the
125
+ * label has spaces or non-word characters, otherwise the bare id.
126
+ */
127
+ export function columnRefText(
128
+ columns: ReadonlyArray<ExprColumn>,
129
+ id: string,
130
+ ): string {
131
+ const label = columnLabel(columns, id)
132
+ return /^[A-Za-z_$][\w$]*$/.test(label) ? label : `[${label}]`
133
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Expression / query language - the model layer.
3
+ *
4
+ * A tiny, dependency-free expression language that end users author through the
5
+ * grid UI. It is the connective tissue behind the Alert Rules engine (and, in
6
+ * time, styled columns and calculated columns): one predicate/scalar/change
7
+ * model, evaluated the same way everywhere.
8
+ *
9
+ * The canonical, persisted form is a JSON AST (the types below) - no parser on
10
+ * the hot path. A light tokenizer/parser (`parse.ts`) is offered only for the
11
+ * editor's free-text mode; the structured builder writes the AST directly.
12
+ *
13
+ * Leaf comparisons delegate to the grid's own `applyExcelFilter`, so a rule's
14
+ * "greater than" is byte-for-byte the grid filter's "greater than".
15
+ */
16
+ import type { ExcelFilterOperator } from '@svgrid/grid'
17
+
18
+ /** Symbolic comparison operators used by `scalarCmp` (general value compare). */
19
+ export type ComparisonOp = '=' | '!=' | '>' | '<' | '>=' | '<='
20
+
21
+ /** Arithmetic operators for `bin` scalar nodes. */
22
+ export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
23
+
24
+ /** Column aggregate reducers (evaluated over the row set in scope). */
25
+ export type AggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'
26
+
27
+ /**
28
+ * A scalar expression - evaluates to a single value (number / string /
29
+ * boolean / null) for a given row (and, for aggregates, the row set).
30
+ */
31
+ export type ScalarExpr =
32
+ | { kind: 'col'; id: string }
33
+ | { kind: 'lit'; value: string | number | boolean | null }
34
+ | { kind: 'neg'; expr: ScalarExpr }
35
+ | { kind: 'bin'; op: ArithmeticOp; left: ScalarExpr; right: ScalarExpr }
36
+ | { kind: 'agg'; fn: AggFn; column: string }
37
+ | { kind: 'func'; name: string; args: ScalarExpr[] }
38
+
39
+ /**
40
+ * A boolean (predicate) expression - the thing an alert rule / filter needs.
41
+ *
42
+ * - `cmp` maps straight onto the grid's `applyExcelFilter` (text/set/range ops).
43
+ * - `scalarCmp` compares two scalar expressions with a symbolic operator, so
44
+ * cross-column maths (`[filled] / [target] >= 0.9`) is expressible.
45
+ */
46
+ export type PredicateExpr =
47
+ | { kind: 'and'; parts: PredicateExpr[] }
48
+ | { kind: 'or'; parts: PredicateExpr[] }
49
+ | { kind: 'not'; expr: PredicateExpr }
50
+ | { kind: 'cmp'; column: string; op: ExcelFilterOperator; value?: unknown; valueTo?: unknown }
51
+ | { kind: 'scalarCmp'; left: ScalarExpr; op: ComparisonOp; right: ScalarExpr }
52
+ | { kind: 'const'; value: boolean }
53
+
54
+ /**
55
+ * A change (observable) expression - true when a value moves between the
56
+ * previous and current snapshot of a row. Powers "relative change" alerts.
57
+ */
58
+ export type ChangeExpr =
59
+ | { kind: 'changed'; column: string }
60
+ | { kind: 'delta'; column: string; op: ComparisonOp; value: number; abs?: boolean }
61
+ | { kind: 'percentChange'; column: string; op: ComparisonOp; value: number; abs?: boolean }
62
+ | { kind: 'crossed'; column: string; threshold: number; direction: 'above' | 'below' }
63
+
64
+ /** The shape the evaluator reads rows as (a plain field bag by default). */
65
+ export type ExprRow = Record<string, unknown>
66
+
67
+ /**
68
+ * Everything the evaluator needs for one evaluation. `getValue` lets a caller
69
+ * resolve a column id to a value when it is not a plain `row[id]` field;
70
+ * `functions` injects extra scalar functions (e.g. delegate to HyperFormula).
71
+ */
72
+ export type EvalContext<TData = ExprRow> = {
73
+ row: TData
74
+ /** Prior snapshot of the same row - required by `ChangeExpr`. */
75
+ prev?: TData
76
+ /** The row set in scope - required by aggregates. Defaults to `[row]`. */
77
+ rows?: ReadonlyArray<TData>
78
+ /** Resolve a column id to its cell value. Defaults to `row[id]`. */
79
+ getValue?: (row: TData, columnId: string) => unknown
80
+ /** BCP-47 locale(s) threaded into `applyExcelFilter` for text folding. */
81
+ locale?: string | ReadonlyArray<string>
82
+ /** Extra scalar functions by upper-case name, merged over the builtins. */
83
+ functions?: Record<string, (args: Array<number | string | boolean | null>) => number | string | boolean | null>
84
+ }