@malloydata/malloyyo 0.2.17 → 0.2.18
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/dist/frame-entry.tsx +11 -0
- package/dist/frame-inpage-entry.tsx +38 -0
- package/dist/frame-runtime/combine.ts +181 -0
- package/dist/frame-runtime/drill.ts +138 -0
- package/dist/frame-runtime/filters.ts +188 -0
- package/dist/frame-runtime/index.ts +68 -0
- package/dist/frame-runtime/runtime.tsx +943 -0
- package/dist/frame-runtime/ui.tsx +643 -0
- package/dist/frame-runtime/vega-chart.tsx +163 -0
- package/dist/index.js +166 -124
- package/package.json +10 -4
|
@@ -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
|
+
}
|