@svgrid/enterprise 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- 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 +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -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} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- 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.ts +13 -1
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -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.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- 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/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 +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- 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 +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- 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
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'
|
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(
|
|
@@ -1172,7 +1178,13 @@ async function exportXlsxNative<
|
|
|
1172
1178
|
: cols.map((_, i) => i)
|
|
1173
1179
|
for (const ci of targetIdxs) {
|
|
1174
1180
|
if (f.type === 'dataBar') condFormats.push({ kind: 'dataBar', colIdx: ci, color: f.color })
|
|
1175
|
-
else
|
|
1181
|
+
else {
|
|
1182
|
+
// min/mid/max are optional on the format; fall back to the same defaults
|
|
1183
|
+
// the on-screen renderer uses (#ffffff -> #000000) so the export matches.
|
|
1184
|
+
const min = f.min ?? '#ffffff'
|
|
1185
|
+
const max = f.max ?? '#000000'
|
|
1186
|
+
condFormats.push({ kind: 'colorScale', colIdx: ci, colors: f.mid ? [min, f.mid, max] : [min, max] })
|
|
1187
|
+
}
|
|
1176
1188
|
}
|
|
1177
1189
|
}
|
|
1178
1190
|
|
|
@@ -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,228 @@
|
|
|
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
|
+
}
|
|
107
|
+
}
|
|
108
|
+
case 'agg': {
|
|
109
|
+
const rows = ctx.rows ?? [ctx.row]
|
|
110
|
+
if (expr.fn === 'count') return rows.length
|
|
111
|
+
const nums: number[] = []
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const n = num(readValue(ctx, row, expr.column))
|
|
114
|
+
if (Number.isFinite(n)) nums.push(n)
|
|
115
|
+
}
|
|
116
|
+
if (nums.length === 0) return expr.fn === 'sum' ? 0 : null
|
|
117
|
+
switch (expr.fn) {
|
|
118
|
+
case 'sum':
|
|
119
|
+
return nums.reduce((s, n) => s + n, 0)
|
|
120
|
+
case 'avg':
|
|
121
|
+
return nums.reduce((s, n) => s + n, 0) / nums.length
|
|
122
|
+
case 'min':
|
|
123
|
+
return Math.min(...nums)
|
|
124
|
+
case 'max':
|
|
125
|
+
return Math.max(...nums)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
case 'func': {
|
|
129
|
+
const name = expr.name.toUpperCase()
|
|
130
|
+
const args = expr.args.map((a) => evaluateScalar(a, ctx))
|
|
131
|
+
const fn = ctx.functions?.[name] ?? BUILTINS[name]
|
|
132
|
+
return fn ? fn(args) : null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function compareScalar(a: Scalar, op: ComparisonOp, b: Scalar): boolean {
|
|
138
|
+
switch (op) {
|
|
139
|
+
case '=':
|
|
140
|
+
return looseEqual(a, b)
|
|
141
|
+
case '!=':
|
|
142
|
+
return !looseEqual(a, b)
|
|
143
|
+
}
|
|
144
|
+
const na = num(a)
|
|
145
|
+
const nb = num(b)
|
|
146
|
+
const numeric = Number.isFinite(na) && Number.isFinite(nb)
|
|
147
|
+
const x: number | string = numeric ? na : String(a ?? '')
|
|
148
|
+
const y: number | string = numeric ? nb : String(b ?? '')
|
|
149
|
+
switch (op) {
|
|
150
|
+
case '>':
|
|
151
|
+
return x > y
|
|
152
|
+
case '<':
|
|
153
|
+
return x < y
|
|
154
|
+
case '>=':
|
|
155
|
+
return x >= y
|
|
156
|
+
case '<=':
|
|
157
|
+
return x <= y
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Evaluate a predicate expression to a boolean for the context's row. */
|
|
162
|
+
export function evaluatePredicate<TData = ExprRow>(
|
|
163
|
+
expr: PredicateExpr,
|
|
164
|
+
ctx: EvalContext<TData>,
|
|
165
|
+
): boolean {
|
|
166
|
+
switch (expr.kind) {
|
|
167
|
+
case 'const':
|
|
168
|
+
return expr.value
|
|
169
|
+
case 'and':
|
|
170
|
+
return expr.parts.every((p) => evaluatePredicate(p, ctx))
|
|
171
|
+
case 'or':
|
|
172
|
+
return expr.parts.some((p) => evaluatePredicate(p, ctx))
|
|
173
|
+
case 'not':
|
|
174
|
+
return !evaluatePredicate(expr.expr, ctx)
|
|
175
|
+
case 'cmp':
|
|
176
|
+
return applyExcelFilter(
|
|
177
|
+
readValue(ctx, ctx.row, expr.column),
|
|
178
|
+
{ id: expr.column, operator: expr.op, value: expr.value, valueTo: expr.valueTo },
|
|
179
|
+
{ locale: ctx.locale },
|
|
180
|
+
) as boolean
|
|
181
|
+
case 'scalarCmp':
|
|
182
|
+
return compareScalar(
|
|
183
|
+
evaluateScalar(expr.left, ctx),
|
|
184
|
+
expr.op,
|
|
185
|
+
evaluateScalar(expr.right, ctx),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Evaluate a change expression - true when the value moved between `ctx.prev`
|
|
192
|
+
* and `ctx.row` in the way the expression describes. Returns false when there
|
|
193
|
+
* is no prior snapshot (nothing has changed yet).
|
|
194
|
+
*/
|
|
195
|
+
export function evaluateChange<TData = ExprRow>(
|
|
196
|
+
expr: ChangeExpr,
|
|
197
|
+
ctx: EvalContext<TData>,
|
|
198
|
+
): boolean {
|
|
199
|
+
if (ctx.prev == null) return false
|
|
200
|
+
const next = readValue(ctx, ctx.row, expr.column)
|
|
201
|
+
const prev = readValue(ctx, ctx.prev, expr.column)
|
|
202
|
+
switch (expr.kind) {
|
|
203
|
+
case 'changed':
|
|
204
|
+
return !looseEqual(prev, next)
|
|
205
|
+
case 'delta': {
|
|
206
|
+
const p = num(prev)
|
|
207
|
+
const n = num(next)
|
|
208
|
+
if (!Number.isFinite(p) || !Number.isFinite(n)) return false
|
|
209
|
+
const d = expr.abs ? Math.abs(n - p) : n - p
|
|
210
|
+
return compareScalar(d, expr.op, expr.value)
|
|
211
|
+
}
|
|
212
|
+
case 'percentChange': {
|
|
213
|
+
const p = num(prev)
|
|
214
|
+
const n = num(next)
|
|
215
|
+
if (!Number.isFinite(p) || !Number.isFinite(n) || p === 0) return false
|
|
216
|
+
const pc = ((n - p) / Math.abs(p)) * 100
|
|
217
|
+
return compareScalar(expr.abs ? Math.abs(pc) : pc, expr.op, expr.value)
|
|
218
|
+
}
|
|
219
|
+
case 'crossed': {
|
|
220
|
+
const p = num(prev)
|
|
221
|
+
const n = num(next)
|
|
222
|
+
if (!Number.isFinite(p) || !Number.isFinite(n)) return false
|
|
223
|
+
return expr.direction === 'above'
|
|
224
|
+
? p < expr.threshold && n >= expr.threshold
|
|
225
|
+
: p > expr.threshold && n <= expr.threshold
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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
|
+
}
|