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