@malloydata/malloyyo 0.2.17 → 0.2.19

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.
@@ -0,0 +1,11 @@
1
+ // @ts-nocheck
2
+ // Browser entry for the SANDBOXED dashboard iframe, bundled on demand by the
3
+ // dashboard dev server. All behavior lives in ./frame-runtime (shared with the
4
+ // hosted app's vendor bundle); this file just mounts the artifact's component.
5
+ // `virtual:dashboard` is aliased by the dev server to the dashboard's
6
+ // Dashboard.tsx — or to the runtime's DefaultDashboard when the `# artifact`
7
+ // tag ships no component.
8
+ import Dashboard from "virtual:dashboard";
9
+ import { mountDashboard } from "./frame-runtime/index";
10
+
11
+ mountDashboard(Dashboard);
@@ -0,0 +1,38 @@
1
+ // @ts-nocheck
2
+ // Browser entry for the IN-PAGE (no-iframe) tag-only dashboard preview. A
3
+ // dashboard with no Dashboard.tsx has no untrusted code to sandbox, so the dev
4
+ // server mounts the runtime's DefaultDashboard DIRECTLY in the trusted shell
5
+ // page — full-width, page-scrolling, like the VSCode/Composer preview. This is
6
+ // the in-page twin of frame-entry.tsx (the sandboxed iframe entry for CUSTOM
7
+ // dashboards): same runtime, but a direct-fetch host instead of postMessage.
8
+ import { mountInPage } from "./frame-runtime/index";
9
+
10
+ const info = window.__DASHBOARD__ || {};
11
+ const name = info.name;
12
+
13
+ // Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`.
14
+ const givensToUrl = (dashboard, givens) => {
15
+ const u = new URL(location.href);
16
+ u.search = "";
17
+ u.searchParams.set("d", dashboard);
18
+ for (const [k, v] of Object.entries(givens)) if (v != null && String(v) !== "") u.searchParams.set("$" + k, String(v));
19
+ return u.pathname + u.search;
20
+ };
21
+
22
+ mountInPage({
23
+ root: document.getElementById("root"),
24
+ // Governed query — the shell's trusted /api/run (the same endpoint the iframe
25
+ // broker forwards to). Returns the raw result the runtime normalizes.
26
+ run: (req, givens) =>
27
+ fetch("/api/run", {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify({ d: name, query: req.query, malloy: req.malloy, givens }),
31
+ })
32
+ .then((r) => r.json())
33
+ .catch((e) => ({ ok: false, problems: [{ message: String(e) }] })),
34
+ navigate: (dashboard, givens) => {
35
+ location.href = givensToUrl(dashboard, givens);
36
+ },
37
+ syncGivens: (givens) => history.replaceState(null, "", givensToUrl(name, givens)),
38
+ });
@@ -0,0 +1,181 @@
1
+ // Copyright (c) The Malloy Foundation
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // Combine N separately-run tile results into ONE `# dashboard` result that the
5
+ // Malloy renderer lays out as a grid of cards — the same structural trick the
6
+ // engine once did server-side, but run client-side so the composite renderer can
7
+ // combine whatever tiles have arrived (Malloy owns the layout).
8
+ //
9
+ // The trick (interfaces format): a `nest:` and a standalone tile result are
10
+ // STRUCTURALLY IDENTICAL — a nest is an `array<record>` whose cell is an
11
+ // `array_cell` of `record_cell`s, which is exactly a tile's `data`/`schema`. So
12
+ // each tile drops into a dashboard card verbatim. Additionally, a tile that is a
13
+ // SINGLE ROW OF MEASURES (an aggregate view, no group-by / no dimensions) is
14
+ // merged straight into the outer dashboard as top-level KPI tiles instead of a
15
+ // named sub-card — matching how a native single-query dashboard renders top-level
16
+ // `aggregate:`. Its `# colspan` is distributed across those KPIs so they sum to
17
+ // the tile's width, and a `# break` lands on the first KPI.
18
+ //
19
+ // Structural types only (same convention as the engine): we touch just the slice
20
+ // of the interfaces Result we read/build.
21
+
22
+ interface ResultAnnotation {
23
+ value: string;
24
+ }
25
+ interface RecordField {
26
+ name: string;
27
+ type: unknown;
28
+ annotations?: ResultAnnotation[];
29
+ }
30
+ interface ResultField {
31
+ kind: string;
32
+ name: string;
33
+ type: unknown;
34
+ annotations?: ResultAnnotation[];
35
+ }
36
+ interface ResultCell {
37
+ kind: string;
38
+ array_value?: ResultCell[];
39
+ record_value?: ResultCell[];
40
+ [k: string]: unknown;
41
+ }
42
+ export interface CombinableResult {
43
+ connection_name?: string;
44
+ annotations?: ResultAnnotation[];
45
+ model_annotations?: ResultAnnotation[];
46
+ schema: { fields: ResultField[] };
47
+ data?: ResultCell;
48
+ [k: string]: unknown;
49
+ }
50
+ export interface DashboardTile {
51
+ name: string;
52
+ result: CombinableResult;
53
+ }
54
+
55
+ /** Render directives to lift from a tile's result annotations onto its card: the
56
+ `# …` tags (`# line_chart`, `# colspan=3`, `# break`, …) but NOT `#(malloy) …`
57
+ metadata, NOT `#" …` docs, and NOT the tile's own `# artifact` tag. */
58
+ function liftRenderTags(annotations?: ResultAnnotation[]): ResultAnnotation[] {
59
+ if (!annotations) return [];
60
+ return annotations.filter((a) => a.value.startsWith("# ") && !a.value.startsWith("# artifact"));
61
+ }
62
+
63
+ /** Turn a tile result into a nest field (an `array<record>` card) carrying the
64
+ tile's render tags. */
65
+ function tileAsNestField(name: string, res: CombinableResult): ResultField {
66
+ const fields: RecordField[] = (res.schema?.fields ?? []).map((f) => ({
67
+ name: f.name,
68
+ type: f.type,
69
+ annotations: f.annotations,
70
+ }));
71
+ return {
72
+ kind: "dimension",
73
+ name,
74
+ type: { kind: "array_type", element_type: { kind: "record_type", fields } },
75
+ annotations: liftRenderTags(res.annotations),
76
+ };
77
+ }
78
+
79
+ function uniqueName(name: string, taken: Set<string>): string {
80
+ if (!taken.has(name)) {
81
+ taken.add(name);
82
+ return name;
83
+ }
84
+ let i = 2;
85
+ while (taken.has(`${name}_${i}`)) i++;
86
+ const out = `${name}_${i}`;
87
+ taken.add(out);
88
+ return out;
89
+ }
90
+
91
+ /** A field is a measure when its `#(malloy) …` annotation carries the
92
+ `calculation` marker (that's what makes the renderer draw it as a big value).
93
+ Dimensions (group_by / select columns) don't have it. */
94
+ function isMeasure(field: ResultField): boolean {
95
+ return (field.annotations ?? []).some((a) => /(^|\s)calculation(\s|$)/.test(a.value));
96
+ }
97
+
98
+ /** A tile is an "aggregate row" when every field is a MEASURE (an aggregate view
99
+ with no group-by / no dimensions / no nesting — which always yields a single
100
+ row). Those merge as top-level KPI tiles rather than a card. This is decided
101
+ from the SCHEMA alone (not the data), so a tile's slot is the same whether we
102
+ have its schema-only result or its full result — the layout doesn't shift when
103
+ data arrives. Requiring measures — not just any scalar — keeps a 1-row detail
104
+ table (`select … limit 1`) rendering as a table card. */
105
+ export function isAggregateRow(res: CombinableResult): boolean {
106
+ const fields = res.schema?.fields ?? [];
107
+ return fields.length > 0 && fields.every(isMeasure);
108
+ }
109
+
110
+ function readNumericTag(annotations: ResultAnnotation[] | undefined, key: string): number | undefined {
111
+ for (const a of annotations ?? []) {
112
+ const m = new RegExp(`^#\\s*${key}\\s*=\\s*(\\d+)`).exec(a.value);
113
+ if (m) return parseInt(m[1], 10);
114
+ }
115
+ return undefined;
116
+ }
117
+ function hasTag(annotations: ResultAnnotation[] | undefined, key: string): boolean {
118
+ return (annotations ?? []).some((a) => new RegExp(`^#\\s*${key}(\\s|=|$)`).test(a.value.trim()));
119
+ }
120
+
121
+ /** Spread a merged tile's colspan across its N KPIs so they SUM to the tile's
122
+ width (colspan=4 over 3 KPIs → [2,1,1]); the outer row keeps the author's
123
+ intended proportions instead of every KPI defaulting to 1. */
124
+ export function distributeColspan(total: number, n: number): number[] {
125
+ const base = Math.floor(total / n);
126
+ const rem = total % n;
127
+ return Array.from({ length: n }, (_, i) => Math.max(1, base + (i < rem ? 1 : 0)));
128
+ }
129
+
130
+ export interface CombineOptions {
131
+ columns?: number;
132
+ }
133
+
134
+ /** Combine tiles into one `# dashboard`-annotated result. Pure — no IO. */
135
+ export function combineTiles(tiles: DashboardTile[], opts: CombineOptions = {}): CombinableResult {
136
+ const taken = new Set<string>();
137
+ const cols =
138
+ typeof opts.columns === "number" && Number.isFinite(opts.columns)
139
+ ? ` {columns=${Math.trunc(opts.columns)}}`
140
+ : "";
141
+ const fields: ResultField[] = [];
142
+ const cells: (ResultCell | undefined)[] = [];
143
+ for (const t of tiles) {
144
+ const res = t.result;
145
+ if (isAggregateRow(res)) {
146
+ // Splice the tile's measures in as top-level fields (→ KPI tiles). Keep each
147
+ // field's own annotations (the `#(malloy) … calculation` marker is what makes
148
+ // it render as a big-value KPI), and re-apply the tile's colspan (distributed)
149
+ // and `# break` (on the first KPI, so the group starts a fresh row). When the
150
+ // tile is still schema-only (no data yet), each KPI cell is null — the slot
151
+ // is reserved; the value fills in when the tile's real result arrives.
152
+ const row = res.data?.array_value?.[0]?.record_value ?? [];
153
+ const defs = res.schema.fields ?? [];
154
+ const tileColspan = readNumericTag(res.annotations, "colspan");
155
+ const spans = tileColspan ? distributeColspan(tileColspan, defs.length) : null;
156
+ const tileBreak = hasTag(res.annotations, "break");
157
+ defs.forEach((f, i) => {
158
+ const ann = (f.annotations ?? []).filter((a) => !/^#\s*colspan/.test(a.value.trim()));
159
+ if (spans) ann.push({ value: `# colspan=${spans[i]}\n` });
160
+ if (tileBreak && i === 0) ann.push({ value: `# break\n` });
161
+ fields.push({ ...f, name: uniqueName(f.name, taken), annotations: ann });
162
+ cells.push(row[i] ?? { kind: "null_cell" });
163
+ });
164
+ } else {
165
+ fields.push(tileAsNestField(uniqueName(t.name, taken), res));
166
+ // A schema-only tile (no data yet) reserves an EMPTY card; its rows fill in
167
+ // when the tile's real result arrives.
168
+ cells.push((res.data as ResultCell) ?? { kind: "array_cell", array_value: [] });
169
+ }
170
+ }
171
+ return {
172
+ connection_name: tiles[0]?.result.connection_name ?? "composite",
173
+ model_annotations: tiles[0]?.result.model_annotations,
174
+ annotations: [{ value: `# dashboard${cols}\n` }],
175
+ schema: { fields },
176
+ data: {
177
+ kind: "array_cell",
178
+ array_value: [{ kind: "record_cell", record_value: cells as ResultCell[] }],
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,138 @@
1
+ // Drill — the shared reading of a `# drill { to=[…] }` dimension click.
2
+ //
3
+ // Two hosts render Malloy results and honor drills: the dashboard frame runtime
4
+ // (runtime.tsx — navigates to a sibling dashboard, or filters in place via a
5
+ // given), and the hosted app's ltool result view (src/components/MalloyResultView.tsx
6
+ // — links out to the dashboard). Both must read the same tag, pick the same
7
+ // target given, and escape the clicked value the same way, so that reading lives
8
+ // here once. What to DO with a resolved drill is each host's business.
9
+ //
10
+ // Browser-only (markDrillableCells touches the DOM), no host access.
11
+
12
+ import { filters } from "./filters";
13
+
14
+ /** A Malloy tag, as the renderer's field metadata exposes it. */
15
+ interface Tag {
16
+ tag(name: string): Tag | undefined;
17
+ text(name: string): string | undefined;
18
+ textArray(name: string): string[] | undefined;
19
+ }
20
+
21
+ /** A result field, from a click payload or the renderer's metadata. */
22
+ interface Field {
23
+ name: string;
24
+ tag?: Tag;
25
+ wasDimension?: () => boolean;
26
+ }
27
+
28
+ /** What the renderer hands `onClick`. */
29
+ export interface CellClickPayload {
30
+ isHeader?: boolean;
31
+ field?: Field;
32
+ value?: unknown;
33
+ event?: { clientX?: number; clientY?: number };
34
+ }
35
+
36
+ /** Enough of the renderer's viz handle to read field metadata. */
37
+ interface VizLike {
38
+ getMetadata(): { getAllFields(): Field[] } | null | undefined;
39
+ }
40
+
41
+ /** A resolved drill: where it may go, and the filter to seed when it gets there. */
42
+ export interface Drill {
43
+ /** `to=` destinations — dashboard slugs and/or the literal `self`. */
44
+ dests: string[];
45
+ /** Given to seed at the destination. */
46
+ given: string;
47
+ /** Exact-match filter expression for the clicked value, escaped. */
48
+ filterExpr: string;
49
+ }
50
+
51
+ /** Slug → menu label: "category_dashboard"/"brand-explorer" → "Category dashboard". */
52
+ export function humanizeSlug(s: string): string {
53
+ const t = String(s).replace(/[-_]+/g, " ").trim();
54
+ return t.charAt(0).toUpperCase() + t.slice(1);
55
+ }
56
+
57
+ /**
58
+ * Read a cell click as a drill, or null when the cell doesn't drill (a header, a
59
+ * measure, an untagged dimension, an empty value).
60
+ */
61
+ export function resolveDrill(payload: CellClickPayload | null | undefined): Drill | null {
62
+ if (!payload || payload.isHeader) return null;
63
+ const f = payload.field;
64
+ // Only dimensions drill — a measure/aggregate click shouldn't navigate.
65
+ if (!f || typeof f.wasDimension !== "function" || !f.wasDimension()) return null;
66
+ const drillTag = f.tag && f.tag.tag("drill");
67
+ if (!drillTag) return null;
68
+ // `to=[a, self]` (array) or `to=x` (single) — a dest is a dashboard slug or `self`.
69
+ const one = drillTag.text("to");
70
+ const dests = drillTag.textArray("to") ?? (one ? [one] : []);
71
+ if (!dests.length || payload.value == null) return null;
72
+ return {
73
+ dests,
74
+ // The target given defaults to the dimension name upper-cased (category →
75
+ // CATEGORY); `given=` names it explicitly when the destination's given
76
+ // differs. One given per drill today; a future syntax may map several from a
77
+ // single query.
78
+ given: drillTag.text("given") || String(f.name).toUpperCase(),
79
+ filterExpr: filters.oneOf(String(payload.value)),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Names of the fields that declare `# drill`, from the renderer's metadata
85
+ * (includes any `# label` so callers can match either against the header text).
86
+ */
87
+ export function drillFieldNames(viz: VizLike): Set<string> {
88
+ const names = new Set<string>();
89
+ try {
90
+ const meta = viz.getMetadata();
91
+ const fields = meta ? meta.getAllFields() : [];
92
+ for (const f of fields) {
93
+ if (f && f.tag && f.tag.tag && f.tag.tag("drill")) {
94
+ names.add(String(f.name));
95
+ const label = f.tag.text && f.tag.text("label");
96
+ if (label) names.add(label);
97
+ }
98
+ }
99
+ } catch {
100
+ /* metadata unavailable — no affordance, clicks still work */
101
+ }
102
+ return names;
103
+ }
104
+
105
+ // The renderer gives no per-cell field id, but each cell carries an inline
106
+ // `grid-column: N / …` and each table's header cells (.th) hold the field names.
107
+ const gridColStart = (el: HTMLElement): string | null => {
108
+ const m = (el.style && el.style.gridColumn ? el.style.gridColumn : "").match(/^\s*(\d+)/);
109
+ return m ? m[1] : null;
110
+ };
111
+
112
+ /**
113
+ * Tag drillable cells with `dash-drill` so the host can style them as links and
114
+ * users can see they're clickable. Per table: header .th cells at a drillable
115
+ * field → that column's grid-column → mark this table's own body .td cells in
116
+ * that column.
117
+ */
118
+ export function markDrillableCells(container: HTMLElement, names: Set<string>): void {
119
+ if (!names.size) return;
120
+ for (const table of container.querySelectorAll<HTMLElement>(".malloy-table")) {
121
+ const mine = (el: Element) => el.closest(".malloy-table") === table; // skip nested tables
122
+ const cols = new Set<string>();
123
+ for (const th of table.querySelectorAll<HTMLElement>(".column-cell.th")) {
124
+ if (!mine(th)) continue;
125
+ const text = (th.textContent || "").replace(/​/g, "").trim();
126
+ const gc = gridColStart(th);
127
+ if (gc && names.has(text)) cols.add(gc);
128
+ }
129
+ if (!cols.size) continue;
130
+ for (const td of table.querySelectorAll<HTMLElement>(".column-cell.td")) {
131
+ // Only leaf value cells — never a cell that wraps a nested table.
132
+ const gc = gridColStart(td);
133
+ if (mine(td) && gc && cols.has(gc) && !td.querySelector(".malloy-table")) {
134
+ td.classList.add("dash-drill");
135
+ }
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,188 @@
1
+ // JS helpers for Malloy filter expressions — the values dashboards bind to
2
+ // `filter<T>` givens. Controls hold ordinary JS state (a list of picks, a
3
+ // lo/hi pair); these helpers convert to/from filter-expression SOURCE strings
4
+ // with correct escaping, backed by Malloy's own parser (@malloydata/malloy-filter)
5
+ // so a dashboard never string-concatenates a filter by hand.
6
+ //
7
+ // Bundled into the sandboxed frame and handed to Dashboard.tsx as the
8
+ // `filters` prop. Pure functions, no host access.
9
+
10
+ import {
11
+ NumberFilterExpression,
12
+ StringFilterExpression,
13
+ TemporalFilterExpression,
14
+ type NumberFilter,
15
+ type StringFilter,
16
+ type TemporalFilter,
17
+ type TemporalUnit,
18
+ } from "@malloydata/malloy-filter";
19
+
20
+ export type { TemporalUnit };
21
+
22
+ export interface FilterHelpers {
23
+ // ── build (JS state → filter expression source) ──────────────────
24
+ /** Exact-match alternatives: oneOf("CA","NY") → 'CA, NY' (escaped). */
25
+ oneOf(...values: string[]): string;
26
+ /** Substring / prefix / suffix match: contains("ann") → '%ann%'. */
27
+ contains(s: string): string;
28
+ startsWith(s: string): string;
29
+ endsWith(s: string): string;
30
+ /** Inclusive numeric range: between(1910, 1930) → '[1910 to 1930]'. */
31
+ between(lo: number, hi: number): string;
32
+ greaterThan(n: number): string;
33
+ atLeast(n: number): string;
34
+ lessThan(n: number): string;
35
+ atMost(n: number): string;
36
+ /** Rolling window ending now, for a filter<timestamp|date> given:
37
+ lastN(7, "day") → '7 days' (Malloy's "in the last 7 days"). */
38
+ lastN(n: number, units: TemporalUnit): string;
39
+ /** Inclusive literal date/time range: dateRange("2026-01-01", "2026-07-01")
40
+ → '2026-01-01 to 2026-07-01'. Accepts date ('2026-01-01') or timestamp
41
+ ('2026-01-01 12:30') literals. */
42
+ dateRange(from: string, to: string): string;
43
+ /** One-sided literal bounds: afterDate("2026-01-01") → 'after 2026-01-01'. */
44
+ afterDate(literal: string): string;
45
+ beforeDate(literal: string): string;
46
+
47
+ // ── read (filter expression source → JS state, null when it isn't that shape) ──
48
+ /** The exact-match values of a string filter: values('CA, NY') → ["CA","NY"].
49
+ Null when the expression is not a plain equality list. */
50
+ values(src: string): string[] | null;
51
+ /** The inclusive bounds of a numeric range: numberRange('[1910 to 1930]') →
52
+ {lo: 1910, hi: 1930}. Null when the expression is not a range. */
53
+ numberRange(src: string): { lo: number; hi: number } | null;
54
+ /** The bound of a one-sided comparison: threshold('> 200') → {op: ">", n: 200}. */
55
+ threshold(src: string): { op: ">" | ">=" | "<" | "<=" | "=" | "!="; n: number } | null;
56
+ /** The rolling window of a temporal filter: inLast('7 days') →
57
+ {n: 7, units: "day"}. Null when the expression is not that shape. */
58
+ inLast(src: string): { n: number; units: TemporalUnit } | null;
59
+ /** The literal bounds of a temporal range: temporalRange('2026-01-01 to
60
+ 2026-07-01') → {from: "2026-01-01", to: "2026-07-01"}. Null otherwise. */
61
+ temporalRange(src: string): { from: string; to: string } | null;
62
+
63
+ // ── validate ──────────────────────────────────────────────────────
64
+ /** True when src parses as a filter over the Malloy type
65
+ ("string" | "number" | "timestamp" | "timestamptz" | "date"). */
66
+ isValid(filterType: string, src: string): boolean;
67
+ }
68
+
69
+ const str = (f: StringFilter | null) => StringFilterExpression.unparse(f);
70
+ const num = (f: NumberFilter | null) => NumberFilterExpression.unparse(f);
71
+ const tmp = (f: TemporalFilter | null) => TemporalFilterExpression.unparse(f);
72
+ const isTemporalType = (t: string) => t === "timestamp" || t === "timestamptz" || t === "date";
73
+
74
+ // Exact-match filter source, minimally escaped. `unparse` conservatively
75
+ // backslash-escapes spaces/hyphens/etc. ('Outerwear\ &\ Coats', 'Ray\-Ban')
76
+ // even though those parse fine unescaped — the escaping then leaks into the URL
77
+ // and the Search box as stray `\`. So prefer the CLEAN comma-joined form when it
78
+ // round-trips to exactly these values, and only fall back to escaping when a
79
+ // value carries filter-significant punctuation (an internal comma, a leading
80
+ // '-', a '%', …) that would otherwise change the parse.
81
+ function exactMatch(values: string[]): string {
82
+ const clean = values.join(", ");
83
+ const { parsed, log } = StringFilterExpression.parse(clean);
84
+ const roundTrips =
85
+ !!parsed &&
86
+ parsed.operator === "=" &&
87
+ !("not" in parsed && (parsed as { not?: boolean }).not) &&
88
+ Array.isArray((parsed as { values?: string[] }).values) &&
89
+ (parsed as { values: string[] }).values.length === values.length &&
90
+ (parsed as { values: string[] }).values.every((v, i) => v === values[i]) &&
91
+ !(log || []).some((l) => l.severity === "error");
92
+ return roundTrips ? clean : str({ operator: "=", values });
93
+ }
94
+
95
+ export const filters: FilterHelpers = {
96
+ oneOf: (...values) => exactMatch(values),
97
+ contains: (s) => str({ operator: "contains", values: [s] }),
98
+ startsWith: (s) => str({ operator: "starts", values: [s] }),
99
+ endsWith: (s) => str({ operator: "ends", values: [s] }),
100
+ between: (lo, hi) =>
101
+ num({
102
+ operator: "range",
103
+ startOperator: ">=",
104
+ startValue: String(lo),
105
+ endOperator: "<=",
106
+ endValue: String(hi),
107
+ }),
108
+ greaterThan: (n) => num({ operator: ">", values: [String(n)] }),
109
+ atLeast: (n) => num({ operator: ">=", values: [String(n)] }),
110
+ lessThan: (n) => num({ operator: "<", values: [String(n)] }),
111
+ atMost: (n) => num({ operator: "<=", values: [String(n)] }),
112
+ lastN: (n, units) => tmp({ operator: "in_last", units, n: String(n) }),
113
+ dateRange: (from, to) =>
114
+ tmp({
115
+ operator: "to",
116
+ fromMoment: { moment: "literal", literal: from },
117
+ toMoment: { moment: "literal", literal: to },
118
+ }),
119
+ afterDate: (literal) => tmp({ operator: "after", after: { moment: "literal", literal } }),
120
+ beforeDate: (literal) => tmp({ operator: "before", before: { moment: "literal", literal } }),
121
+
122
+ values(src) {
123
+ const { parsed } = StringFilterExpression.parse(src);
124
+ if (parsed && parsed.operator === "=" && !("not" in parsed && parsed.not)) {
125
+ return (parsed as { values: string[] }).values;
126
+ }
127
+ return null;
128
+ },
129
+ numberRange(src) {
130
+ const { parsed } = NumberFilterExpression.parse(src);
131
+ if (parsed && parsed.operator === "range") {
132
+ const r = parsed as { startValue: string; endValue: string };
133
+ const lo = Number(r.startValue);
134
+ const hi = Number(r.endValue);
135
+ if (Number.isFinite(lo) && Number.isFinite(hi)) return { lo, hi };
136
+ }
137
+ return null;
138
+ },
139
+ threshold(src) {
140
+ const { parsed } = NumberFilterExpression.parse(src);
141
+ if (
142
+ parsed &&
143
+ (parsed.operator === ">" ||
144
+ parsed.operator === ">=" ||
145
+ parsed.operator === "<" ||
146
+ parsed.operator === "<=" ||
147
+ parsed.operator === "=" ||
148
+ parsed.operator === "!=")
149
+ ) {
150
+ const v = (parsed as { values: string[] }).values;
151
+ const n = Number(v?.[0]);
152
+ if (Number.isFinite(n)) return { op: parsed.operator, n };
153
+ }
154
+ return null;
155
+ },
156
+ inLast(src) {
157
+ const { parsed } = TemporalFilterExpression.parse(src);
158
+ if (parsed && parsed.operator === "in_last") {
159
+ const n = Number(parsed.n);
160
+ if (Number.isFinite(n)) return { n, units: parsed.units };
161
+ }
162
+ return null;
163
+ },
164
+ temporalRange(src) {
165
+ const { parsed } = TemporalFilterExpression.parse(src);
166
+ if (
167
+ parsed &&
168
+ parsed.operator === "to" &&
169
+ parsed.fromMoment.moment === "literal" &&
170
+ parsed.toMoment.moment === "literal"
171
+ ) {
172
+ return { from: parsed.fromMoment.literal, to: parsed.toMoment.literal };
173
+ }
174
+ return null;
175
+ },
176
+ isValid(filterType, src) {
177
+ if (filterType === "number") {
178
+ const r = NumberFilterExpression.parse(src);
179
+ return r.parsed !== null && !r.log.some((l) => l.severity === "error");
180
+ }
181
+ if (isTemporalType(filterType)) {
182
+ const r = TemporalFilterExpression.parse(src);
183
+ return r.parsed !== null && !r.log.some((l) => l.severity === "error");
184
+ }
185
+ const r = StringFilterExpression.parse(src);
186
+ return r.parsed !== null && !r.log.some((l) => l.severity === "error");
187
+ },
188
+ };
@@ -0,0 +1,68 @@
1
+ // @malloyyo/dashboard — the import surface a Dashboard.tsx sees. The bundlers
2
+ // alias "@malloyyo/dashboard" to this file (CLI dev server: esbuild alias;
3
+ // hosted: shimmed to window.__DASH_RUNTIME__, which IS this module bundled
4
+ // into the vendor asset). Everything here also arrives as props on the
5
+ // Dashboard component; imports are the readable form.
6
+ //
7
+ // A CUSTOM dashboard renders itself: hooks + controls + <VegaChart>, drawing
8
+ // its own visuals. It does NOT get the Malloy renderer — `Panel` /
9
+ // `CompositeDashboard` / `DefaultDashboard` are intentionally NOT exported. The
10
+ // renderer runs ONLY in the trusted page for a TAG-ONLY dashboard (no
11
+ // Dashboard.tsx), mounted with mountInPage below. "You want custom, render it
12
+ // yourself." (Importing Panel from a custom dashboard therefore fails to
13
+ // bundle — that's the signal to make it tag-only, or draw it with VegaChart.)
14
+
15
+ export {
16
+ filters,
17
+ runData,
18
+ useGiven,
19
+ useOptions,
20
+ useQuery,
21
+ mount,
22
+ setHost,
23
+ dashboardInfo,
24
+ givenSpecs,
25
+ } from "./runtime";
26
+ export {
27
+ Controls,
28
+ Given,
29
+ Select,
30
+ Search,
31
+ MultiSelect,
32
+ Range,
33
+ Checkbox,
34
+ TimeRange,
35
+ DEFAULT_TIME_PRESETS,
36
+ Field,
37
+ } from "./ui";
38
+ export { VegaChart } from "./vega-chart";
39
+
40
+ import { mount, setHost } from "./runtime";
41
+ import { Controls, Given, Select, Search, MultiSelect, Range, Checkbox, TimeRange, DefaultDashboard } from "./ui";
42
+ import { VegaChart } from "./vega-chart";
43
+
44
+ const WIDGETS = { Controls, Given, Select, Search, MultiSelect, Range, Checkbox, TimeRange, VegaChart };
45
+
46
+ /** Sandboxed-iframe entry (CUSTOM dashboards): mount a Dashboard with the widget
47
+ components in its props. A null Dashboard falls back to DefaultDashboard, but
48
+ tag-only dashboards no longer reach the iframe — they use mountInPage. */
49
+ export function mountDashboard(Dashboard: unknown): void {
50
+ mount(Dashboard ?? DefaultDashboard, WIDGETS);
51
+ }
52
+
53
+ /** Trusted-page entry (TAG-ONLY dashboards, NO iframe): mount DefaultDashboard —
54
+ the Malloy renderer — directly into `root`, wired to a direct-fetch host the
55
+ page supplies (run/navigate/syncGivens). This is how a dashboard with no
56
+ Dashboard.tsx runs full-width, like the VSCode/Composer preview. */
57
+ export function mountInPage(opts: {
58
+ root: HTMLElement;
59
+ run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
60
+ navigate: (dashboard: string, givens: Record<string, unknown>) => void;
61
+ syncGivens: (givens: Record<string, unknown>) => void;
62
+ }): { unmount: () => void } {
63
+ setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
64
+ // bodyReset:false — the dashboard is one element in the app shell, so it must
65
+ // not restyle <body> (the iframe host DOES own the whole document, so it keeps
66
+ // the reset). Returns the React root so the caller can unmount() on teardown.
67
+ return mount(DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
68
+ }