@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,643 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Headless-ish widgets bound to the model's givens by name. Each widget reads
|
|
3
|
+
// its given's declaration spec (type, tags, default) and commits filter-
|
|
4
|
+
// expression values through the shared runtime — a Dashboard.tsx composes them
|
|
5
|
+
// with ordinary React and restyles via CSS custom properties or className/style:
|
|
6
|
+
//
|
|
7
|
+
// --dash-fg, --dash-muted, --dash-border, --dash-accent, --dash-control-bg
|
|
8
|
+
//
|
|
9
|
+
// <Given name="X"/> picks the control from the declaration; <Controls/> lays
|
|
10
|
+
// out every given the dashboard's query references; <DefaultDashboard/> is the
|
|
11
|
+
// whole no-code dashboard (title + controls + panel) used when a tagged query
|
|
12
|
+
// ships no Dashboard.tsx.
|
|
13
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
14
|
+
import { dashboardInfo, givenSpecs, filters, useGiven, useOptions, useDashboard, Panel, CompositeDashboard } from "./runtime";
|
|
15
|
+
|
|
16
|
+
const V = (name, fallback) => `var(--dash-${name}, ${fallback})`;
|
|
17
|
+
const label_ = (spec) => spec?.tags?.label ?? spec?.name;
|
|
18
|
+
|
|
19
|
+
const labelStyle = {
|
|
20
|
+
color: V("muted", "#888"),
|
|
21
|
+
marginBottom: 2,
|
|
22
|
+
fontSize: 11,
|
|
23
|
+
fontWeight: 500,
|
|
24
|
+
textTransform: "uppercase",
|
|
25
|
+
letterSpacing: ".03em",
|
|
26
|
+
};
|
|
27
|
+
const hintStyle = { fontSize: 10, color: V("muted", "#888"), marginTop: 2, minHeight: 12 };
|
|
28
|
+
const controlStyle = {
|
|
29
|
+
fontSize: 13,
|
|
30
|
+
padding: "4px 7px",
|
|
31
|
+
borderRadius: 6,
|
|
32
|
+
border: `1px solid ${V("border", "#ccc")}`,
|
|
33
|
+
background: V("control-bg", "white"),
|
|
34
|
+
color: V("fg", "#1a1a1a"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const clearBtnStyle = {
|
|
38
|
+
position: "absolute",
|
|
39
|
+
right: 4,
|
|
40
|
+
top: "50%",
|
|
41
|
+
transform: "translateY(-50%)",
|
|
42
|
+
border: "none",
|
|
43
|
+
background: "transparent",
|
|
44
|
+
cursor: "pointer",
|
|
45
|
+
color: V("muted", "#888"),
|
|
46
|
+
fontSize: 16,
|
|
47
|
+
lineHeight: 1,
|
|
48
|
+
padding: "0 4px",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const chipStyle = {
|
|
52
|
+
display: "inline-flex",
|
|
53
|
+
alignItems: "center",
|
|
54
|
+
gap: 3,
|
|
55
|
+
background: V("chip-bg", "#eef2ff"),
|
|
56
|
+
color: V("chip-fg", "#3730a3"),
|
|
57
|
+
borderRadius: 4,
|
|
58
|
+
padding: "1px 3px 1px 7px",
|
|
59
|
+
fontSize: 12,
|
|
60
|
+
lineHeight: 1.35,
|
|
61
|
+
};
|
|
62
|
+
const chipXStyle = {
|
|
63
|
+
border: "none",
|
|
64
|
+
background: "transparent",
|
|
65
|
+
color: "inherit",
|
|
66
|
+
cursor: "pointer",
|
|
67
|
+
fontSize: 15,
|
|
68
|
+
lineHeight: 1,
|
|
69
|
+
padding: 0,
|
|
70
|
+
opacity: 0.7,
|
|
71
|
+
};
|
|
72
|
+
const dropdownStyle = {
|
|
73
|
+
position: "absolute",
|
|
74
|
+
zIndex: 1000,
|
|
75
|
+
top: "calc(100% + 4px)",
|
|
76
|
+
left: 0,
|
|
77
|
+
right: 0,
|
|
78
|
+
margin: 0,
|
|
79
|
+
padding: 4,
|
|
80
|
+
listStyle: "none",
|
|
81
|
+
maxHeight: 220,
|
|
82
|
+
overflowY: "auto",
|
|
83
|
+
background: V("control-bg", "#fff"),
|
|
84
|
+
border: `1px solid ${V("border", "#ccc")}`,
|
|
85
|
+
borderRadius: 8,
|
|
86
|
+
boxShadow: "0 6px 20px rgba(0,0,0,.12)",
|
|
87
|
+
};
|
|
88
|
+
const dropdownItemStyle = {
|
|
89
|
+
display: "block",
|
|
90
|
+
width: "100%",
|
|
91
|
+
textAlign: "left",
|
|
92
|
+
border: "none",
|
|
93
|
+
background: "transparent",
|
|
94
|
+
color: V("fg", "#1a1a1a"),
|
|
95
|
+
fontSize: 14,
|
|
96
|
+
padding: "6px 8px",
|
|
97
|
+
borderRadius: 6,
|
|
98
|
+
cursor: "pointer",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const btnBase = { fontSize: 13, padding: "5px 12px", borderRadius: 6 };
|
|
102
|
+
const primaryBtnStyle = (enabled) => ({
|
|
103
|
+
...btnBase,
|
|
104
|
+
background: V("accent", "#2563eb"),
|
|
105
|
+
color: V("accent-fg", "#fff"),
|
|
106
|
+
border: "1px solid transparent",
|
|
107
|
+
opacity: enabled ? 1 : 0.5,
|
|
108
|
+
cursor: enabled ? "pointer" : "default",
|
|
109
|
+
});
|
|
110
|
+
const secondaryBtnStyle = (enabled) => ({
|
|
111
|
+
...btnBase,
|
|
112
|
+
background: V("control-bg", "#fff"),
|
|
113
|
+
color: V("fg", "#1a1a1a"),
|
|
114
|
+
border: `1px solid ${V("border", "#ccc")}`,
|
|
115
|
+
opacity: enabled ? 1 : 0.5,
|
|
116
|
+
cursor: enabled ? "pointer" : "default",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** Labeled wrapper every control uses; bring your own label with label={null}. */
|
|
120
|
+
export function Field({ label, children, style }) {
|
|
121
|
+
return (
|
|
122
|
+
<label style={{ fontSize: 13, display: "inline-block", ...style }}>
|
|
123
|
+
{label != null && <div style={labelStyle}>{label}</div>}
|
|
124
|
+
{children}
|
|
125
|
+
</label>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A <select> bound to a given. Options: the `options` prop (strings or
|
|
130
|
+
{value, text}), else the given's `# suggest {…}` tag. Plain suggested options are
|
|
131
|
+
column VALUES — for a filter<T> given they're escaped into an exact-match
|
|
132
|
+
filter expression on commit (a raw 'Tesla, Inc.' would parse as two
|
|
133
|
+
alternatives). Explicit {value, text} options pass through untouched — the
|
|
134
|
+
author already supplies filter sources there. */
|
|
135
|
+
export function Select({ given, options, label, style }) {
|
|
136
|
+
const { value, set, spec } = useGiven(given);
|
|
137
|
+
const suggested = useOptions(given);
|
|
138
|
+
const isFilter = !!spec?.filterType;
|
|
139
|
+
const toValue = (o) => (isFilter && o !== "" ? filters.oneOf(String(o)) : String(o));
|
|
140
|
+
const raw = options ?? (suggested.loading ? [value].filter((v) => v != null && v !== "") : suggested.options);
|
|
141
|
+
const opts = raw.map((o) =>
|
|
142
|
+
typeof o === "object" ? o : { value: toValue(o), text: String(o) },
|
|
143
|
+
);
|
|
144
|
+
// Suggest-driven filter selects get an "All" (empty filter) option so a pick
|
|
145
|
+
// is clearable — otherwise setting DEPARTMENT strands you with no way back to
|
|
146
|
+
// unfiltered. Author-supplied `options` are left exactly as given.
|
|
147
|
+
if (!options && isFilter && !opts.some((o) => o.value === "")) {
|
|
148
|
+
opts.unshift({ value: "", text: "All" });
|
|
149
|
+
}
|
|
150
|
+
// Keep the current value selectable even if it isn't in the option list —
|
|
151
|
+
// but "" is already the All option, so don't add a blank entry for it.
|
|
152
|
+
if (value != null && value !== "" && !opts.some((o) => o.value === value)) {
|
|
153
|
+
const texts = isFilter ? filters.values(value) : null;
|
|
154
|
+
opts.push({ value, text: texts?.join(", ") ?? String(value) });
|
|
155
|
+
}
|
|
156
|
+
return (
|
|
157
|
+
<Field label={label ?? label_(spec)}>
|
|
158
|
+
<select value={value ?? ""} onChange={(e) => set(e.target.value)} style={{ ...controlStyle, ...style }}>
|
|
159
|
+
{opts.map((o) => (
|
|
160
|
+
<option key={o.value} value={o.value}>
|
|
161
|
+
{o.text}
|
|
162
|
+
</option>
|
|
163
|
+
))}
|
|
164
|
+
</select>
|
|
165
|
+
</Field>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A committing text input bound to a filter<T> given, with typeahead
|
|
170
|
+
suggestions from the given's `# suggest {…}` tag and filter-syntax validation.
|
|
171
|
+
Users can type filter expressions: `Emma, Olivia`, `Em%`, `-NY`.
|
|
172
|
+
|
|
173
|
+
Free text can't re-run per keystroke (a half-typed expression is invalid), so
|
|
174
|
+
it commits on Enter/blur. That action is made explicit: a "Press ↵ to apply"
|
|
175
|
+
hint shows while the draft differs from what's running, and an inline ✕ clears
|
|
176
|
+
the box (committing the empty = no-filter value). Esc also clears. */
|
|
177
|
+
export function Search({ given, label, placeholder, style }) {
|
|
178
|
+
const { value, set, spec } = useGiven(given);
|
|
179
|
+
const filterType = spec?.filterType ?? "string";
|
|
180
|
+
// Show exact-match string values UNESCAPED: a drilled/committed `Ray\-Ban`
|
|
181
|
+
// (the `-` escaped because a bare hyphen means negation) reads as `Ray-Ban`,
|
|
182
|
+
// `Outerwear\ &\ Coats` reads as `Outerwear & Coats`. Wildcards / ranges /
|
|
183
|
+
// negation aren't exact matches, so they show their raw expression as before.
|
|
184
|
+
const displayOf = (v) => {
|
|
185
|
+
if (v == null || v === "") return "";
|
|
186
|
+
const vals = filterType === "string" ? filters.values(v) : null;
|
|
187
|
+
return vals ? vals.join(", ") : v;
|
|
188
|
+
};
|
|
189
|
+
const [draft, setDraft] = useState(() => displayOf(value));
|
|
190
|
+
// What we last displayed for `value`; an unedited draft equal to it commits the
|
|
191
|
+
// EXACT stored filter (never re-parse the pretty form back into a filter).
|
|
192
|
+
const shownRef = useRef(displayOf(value));
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const d = displayOf(value);
|
|
195
|
+
setDraft(d);
|
|
196
|
+
shownRef.current = d;
|
|
197
|
+
}, [value]);
|
|
198
|
+
// Suggest against the last typed token so `Emma, Ol` suggests Olivia.
|
|
199
|
+
const lastTerm = draft.split(",").pop().trim();
|
|
200
|
+
const { options } = useOptions(given, lastTerm);
|
|
201
|
+
const empty = draft.trim() === "";
|
|
202
|
+
const valid = empty || filters.isValid(filterType, draft);
|
|
203
|
+
const current = value ?? "";
|
|
204
|
+
const dirty = draft !== shownRef.current;
|
|
205
|
+
const listId = `dash-options-${given}`;
|
|
206
|
+
const commit = () => {
|
|
207
|
+
if (!valid || draft === shownRef.current) return; // unedited → keep exact value
|
|
208
|
+
const next = draft.trim();
|
|
209
|
+
if (next === current) return;
|
|
210
|
+
set(next); // user-typed filter expression ("" clears the filter)
|
|
211
|
+
};
|
|
212
|
+
const clear = () => {
|
|
213
|
+
setDraft("");
|
|
214
|
+
if (current !== "") set("");
|
|
215
|
+
};
|
|
216
|
+
return (
|
|
217
|
+
<Field label={label ?? label_(spec)}>
|
|
218
|
+
<div style={{ position: "relative", display: "inline-block" }}>
|
|
219
|
+
<input
|
|
220
|
+
value={draft}
|
|
221
|
+
list={listId}
|
|
222
|
+
placeholder={placeholder ?? spec?.description}
|
|
223
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
224
|
+
onBlur={commit}
|
|
225
|
+
onKeyDown={(e) => {
|
|
226
|
+
if (e.key === "Enter") commit();
|
|
227
|
+
else if (e.key === "Escape") clear();
|
|
228
|
+
}}
|
|
229
|
+
style={{
|
|
230
|
+
...controlStyle,
|
|
231
|
+
minWidth: 150,
|
|
232
|
+
paddingRight: 24,
|
|
233
|
+
border: `1px solid ${valid ? V("border", "#ccc") : V("danger", "#d33")}`,
|
|
234
|
+
...style,
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
{!empty && (
|
|
238
|
+
<button type="button" onClick={clear} aria-label="Clear" style={clearBtnStyle}>
|
|
239
|
+
×
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
<datalist id={listId}>
|
|
243
|
+
{options.map((o) => (
|
|
244
|
+
<option key={o} value={o} />
|
|
245
|
+
))}
|
|
246
|
+
</datalist>
|
|
247
|
+
</div>
|
|
248
|
+
<div style={{ ...hintStyle, color: valid ? V("muted", "#888") : V("danger", "#d33") }}>
|
|
249
|
+
{!valid ? "Invalid filter expression" : dirty ? "Press ↵ to apply" : " "}
|
|
250
|
+
</div>
|
|
251
|
+
</Field>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A tokenized multi-select bound to a filter<string> given: each pick becomes a
|
|
256
|
+
removable chip and the committed value is an exact-match alternatives filter
|
|
257
|
+
(`Emma, Olivia, Sophia`), round-tripped through filters.oneOf / filters.values.
|
|
258
|
+
Suggestions come from the given's `# suggest {…}` tag (server-side typeahead
|
|
259
|
+
when it names a dimension) or an explicit `options` prop. Backspace on an
|
|
260
|
+
empty box removes the last chip; empty selection = no filter (all). */
|
|
261
|
+
export function MultiSelect({ given, label, placeholder, options, style }) {
|
|
262
|
+
const { value, set, spec } = useGiven(given);
|
|
263
|
+
const selected = filters.values(value ?? "") ?? [];
|
|
264
|
+
const [term, setTerm] = useState("");
|
|
265
|
+
const [open, setOpen] = useState(false);
|
|
266
|
+
const suggested = useOptions(given, term);
|
|
267
|
+
const commit = (vals) => set(vals.length ? filters.oneOf(...vals) : "");
|
|
268
|
+
const add = (v) => {
|
|
269
|
+
const t = String(v).trim();
|
|
270
|
+
setTerm("");
|
|
271
|
+
if (!t || selected.includes(t)) return;
|
|
272
|
+
commit([...selected, t]);
|
|
273
|
+
};
|
|
274
|
+
const remove = (v) => commit(selected.filter((x) => x !== v));
|
|
275
|
+
const source = options
|
|
276
|
+
? options.map((o) => (typeof o === "object" ? String(o.value) : String(o)))
|
|
277
|
+
: suggested.options.map(String);
|
|
278
|
+
const lower = term.toLowerCase();
|
|
279
|
+
const avail = source
|
|
280
|
+
.filter((o) => !selected.includes(o))
|
|
281
|
+
.filter((o) => o.toLowerCase().includes(lower))
|
|
282
|
+
.slice(0, 50);
|
|
283
|
+
return (
|
|
284
|
+
<Field label={label ?? label_(spec)}>
|
|
285
|
+
<div style={{ position: "relative", minWidth: 180, ...style }}>
|
|
286
|
+
<div
|
|
287
|
+
style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center", ...controlStyle, padding: 3 }}
|
|
288
|
+
onClick={() => setOpen(true)}
|
|
289
|
+
>
|
|
290
|
+
{selected.map((v) => (
|
|
291
|
+
<span key={v} style={chipStyle}>
|
|
292
|
+
{v}
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
aria-label={`Remove ${v}`}
|
|
296
|
+
// mouseDown+preventDefault so removing a chip doesn't blur the input
|
|
297
|
+
onMouseDown={(e) => {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
remove(v);
|
|
300
|
+
}}
|
|
301
|
+
style={chipXStyle}
|
|
302
|
+
>
|
|
303
|
+
×
|
|
304
|
+
</button>
|
|
305
|
+
</span>
|
|
306
|
+
))}
|
|
307
|
+
<input
|
|
308
|
+
value={term}
|
|
309
|
+
placeholder={selected.length ? "" : placeholder ?? "Add…"}
|
|
310
|
+
onChange={(e) => {
|
|
311
|
+
setTerm(e.target.value);
|
|
312
|
+
setOpen(true);
|
|
313
|
+
}}
|
|
314
|
+
onFocus={() => setOpen(true)}
|
|
315
|
+
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
|
316
|
+
onKeyDown={(e) => {
|
|
317
|
+
if (e.key === "Enter") {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
add(avail[0] ?? term);
|
|
320
|
+
} else if (e.key === "Backspace" && !term && selected.length) {
|
|
321
|
+
remove(selected[selected.length - 1]);
|
|
322
|
+
} else if (e.key === "Escape") {
|
|
323
|
+
setOpen(false);
|
|
324
|
+
}
|
|
325
|
+
}}
|
|
326
|
+
style={{
|
|
327
|
+
border: "none",
|
|
328
|
+
outline: "none",
|
|
329
|
+
background: "transparent",
|
|
330
|
+
color: V("fg", "#1a1a1a"),
|
|
331
|
+
fontSize: 14,
|
|
332
|
+
flex: 1,
|
|
333
|
+
minWidth: 80,
|
|
334
|
+
padding: "2px 0",
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
{open && avail.length > 0 && (
|
|
339
|
+
<ul style={dropdownStyle}>
|
|
340
|
+
{avail.map((o) => (
|
|
341
|
+
<li key={o}>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
// preventDefault keeps focus in the input so the dropdown stays
|
|
345
|
+
// open for picking several in a row.
|
|
346
|
+
onMouseDown={(e) => {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
add(o);
|
|
349
|
+
}}
|
|
350
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = V("controls-bg", "#f3f4f6"))}
|
|
351
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
352
|
+
style={dropdownItemStyle}
|
|
353
|
+
>
|
|
354
|
+
{o}
|
|
355
|
+
</button>
|
|
356
|
+
</li>
|
|
357
|
+
))}
|
|
358
|
+
</ul>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
</Field>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const RANGE_CSS = `
|
|
366
|
+
.dash-range { position: relative; width: 170px; height: 22px; }
|
|
367
|
+
.dash-range .track { position:absolute; top:9px; left:0; right:0; height:4px; border-radius:2px; background:${V("border", "#d5d8dd")}; }
|
|
368
|
+
.dash-range .fill { position:absolute; top:9px; height:4px; border-radius:2px; background:${V("accent", "#1a1a1a")}; }
|
|
369
|
+
.dash-range input[type=range] {
|
|
370
|
+
position:absolute; top:3px; left:0; width:100%; height:16px; margin:0;
|
|
371
|
+
-webkit-appearance:none; appearance:none; background:transparent; pointer-events:none;
|
|
372
|
+
}
|
|
373
|
+
.dash-range input[type=range]::-webkit-slider-thumb {
|
|
374
|
+
-webkit-appearance:none; appearance:none; pointer-events:auto; cursor:pointer;
|
|
375
|
+
height:16px; width:16px; border-radius:50%; background:${V("control-bg", "#fff")}; border:2px solid ${V("accent", "#1a1a1a")}; box-sizing:border-box;
|
|
376
|
+
}
|
|
377
|
+
.dash-range input[type=range]::-moz-range-thumb {
|
|
378
|
+
pointer-events:auto; cursor:pointer;
|
|
379
|
+
height:16px; width:16px; border-radius:50%; background:${V("control-bg", "#fff")}; border:2px solid ${V("accent", "#1a1a1a")}; box-sizing:border-box;
|
|
380
|
+
}
|
|
381
|
+
.dash-range input[type=range]::-moz-range-track { background:transparent; }
|
|
382
|
+
`;
|
|
383
|
+
|
|
384
|
+
/** A dual-thumb slider bound to a filter<number> given holding an inclusive
|
|
385
|
+
range ('[lo to hi]'). Bounds come from min/max props or the declaration's
|
|
386
|
+
range_min/range_max tags. Commits on release, not per drag tick. */
|
|
387
|
+
export function Range({ given, min, max, label, step = 1 }) {
|
|
388
|
+
const { value, set, spec } = useGiven(given);
|
|
389
|
+
const lo0 = min ?? spec?.tags?.range_min ?? 0;
|
|
390
|
+
const hi0 = max ?? spec?.tags?.range_max ?? 100;
|
|
391
|
+
const { lo, hi } = filters.numberRange(value ?? "") ?? { lo: lo0, hi: hi0 };
|
|
392
|
+
const [draft, setDraft] = useState([lo, hi]);
|
|
393
|
+
useEffect(() => setDraft([lo, hi]), [lo, hi]);
|
|
394
|
+
const [dLo, dHi] = draft;
|
|
395
|
+
const pct = (v) => ((v - lo0) / (hi0 - lo0)) * 100;
|
|
396
|
+
const commit = () => {
|
|
397
|
+
const next = filters.between(draft[0], draft[1]);
|
|
398
|
+
if (next !== value) set(next);
|
|
399
|
+
};
|
|
400
|
+
const thumb = (v, z, clamp) => (
|
|
401
|
+
<input
|
|
402
|
+
type="range"
|
|
403
|
+
min={lo0}
|
|
404
|
+
max={hi0}
|
|
405
|
+
step={step}
|
|
406
|
+
value={v}
|
|
407
|
+
style={{ zIndex: z }}
|
|
408
|
+
onChange={(e) => setDraft(clamp(Number(e.target.value)))}
|
|
409
|
+
onMouseUp={commit}
|
|
410
|
+
onTouchEnd={commit}
|
|
411
|
+
onKeyUp={commit}
|
|
412
|
+
/>
|
|
413
|
+
);
|
|
414
|
+
return (
|
|
415
|
+
<div>
|
|
416
|
+
<style>{RANGE_CSS}</style>
|
|
417
|
+
<div style={labelStyle}>
|
|
418
|
+
{label ?? label_(spec)}:{" "}
|
|
419
|
+
<strong style={{ color: V("fg", "#333") }}>
|
|
420
|
+
{dLo}–{dHi}
|
|
421
|
+
</strong>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="dash-range">
|
|
424
|
+
<div className="track" />
|
|
425
|
+
<div className="fill" style={{ left: `${pct(dLo)}%`, width: `${pct(dHi) - pct(dLo)}%` }} />
|
|
426
|
+
{/* Keep the low thumb reachable when both sit near the top of the range. */}
|
|
427
|
+
{thumb(dLo, dLo > (lo0 + hi0) / 2 ? 5 : 3, (v) => [Math.min(v, dHi), dHi])}
|
|
428
|
+
{thumb(dHi, 4, (v) => [dLo, Math.max(v, dLo)])}
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Relative-time presets + a custom literal range, bound to a
|
|
435
|
+
filter<timestamp|date> given. Presets are {value, text} where value is a
|
|
436
|
+
temporal filter EXPRESSION ('' = all time, '7 days' = the last 7 days) —
|
|
437
|
+
override via the `presets` prop or the declaration's tags. Picking
|
|
438
|
+
"Custom range…" swaps in from/to date inputs committed through
|
|
439
|
+
filters.dateRange (never hand-built strings). */
|
|
440
|
+
export function TimeRange({ given, label, presets, style }) {
|
|
441
|
+
const { value, set, spec } = useGiven(given);
|
|
442
|
+
const range = filters.temporalRange(value ?? "");
|
|
443
|
+
const [custom, setCustom] = useState(!!range);
|
|
444
|
+
const [draft, setDraft] = useState({ from: range?.from ?? "", to: range?.to ?? "" });
|
|
445
|
+
// Follow external value changes (URL-seeded links, artifact-tag defaults).
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
const r = filters.temporalRange(value ?? "");
|
|
448
|
+
setCustom(!!r);
|
|
449
|
+
if (r) setDraft({ from: r.from, to: r.to });
|
|
450
|
+
}, [value]);
|
|
451
|
+
const opts = (presets ?? DEFAULT_TIME_PRESETS).map((o) =>
|
|
452
|
+
typeof o === "object" ? o : { value: String(o), text: String(o) },
|
|
453
|
+
);
|
|
454
|
+
const CUSTOM = "__custom__";
|
|
455
|
+
const current = custom ? CUSTOM : (value ?? "");
|
|
456
|
+
if (!custom && !opts.some((o) => o.value === current)) {
|
|
457
|
+
opts.push({ value: current, text: current || "All time" });
|
|
458
|
+
}
|
|
459
|
+
const commitCustom = (next) => {
|
|
460
|
+
setDraft(next);
|
|
461
|
+
if (next.from && next.to) set(filters.dateRange(next.from, next.to));
|
|
462
|
+
};
|
|
463
|
+
const dateInput = (key) => (
|
|
464
|
+
<input
|
|
465
|
+
type="date"
|
|
466
|
+
value={draft[key]}
|
|
467
|
+
onChange={(e) => commitCustom({ ...draft, [key]: e.target.value })}
|
|
468
|
+
style={controlStyle}
|
|
469
|
+
/>
|
|
470
|
+
);
|
|
471
|
+
return (
|
|
472
|
+
<Field label={label ?? label_(spec)} style={style}>
|
|
473
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
|
474
|
+
<select
|
|
475
|
+
value={current}
|
|
476
|
+
onChange={(e) => {
|
|
477
|
+
if (e.target.value === CUSTOM) setCustom(true);
|
|
478
|
+
else {
|
|
479
|
+
setCustom(false);
|
|
480
|
+
set(e.target.value);
|
|
481
|
+
}
|
|
482
|
+
}}
|
|
483
|
+
style={controlStyle}
|
|
484
|
+
>
|
|
485
|
+
{opts.map((o) => (
|
|
486
|
+
<option key={o.value} value={o.value}>
|
|
487
|
+
{o.text}
|
|
488
|
+
</option>
|
|
489
|
+
))}
|
|
490
|
+
<option value={CUSTOM}>Custom range…</option>
|
|
491
|
+
</select>
|
|
492
|
+
{custom && (
|
|
493
|
+
<>
|
|
494
|
+
{dateInput("from")}
|
|
495
|
+
<span style={{ color: V("muted", "#888") }}>to</span>
|
|
496
|
+
{dateInput("to")}
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
</div>
|
|
500
|
+
</Field>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export const DEFAULT_TIME_PRESETS = [
|
|
505
|
+
{ value: "", text: "All time" },
|
|
506
|
+
{ value: "today", text: "Today" },
|
|
507
|
+
{ value: "7 days", text: "Last 7 days" },
|
|
508
|
+
{ value: "30 days", text: "Last 30 days" },
|
|
509
|
+
{ value: "90 days", text: "Last 90 days" },
|
|
510
|
+
{ value: "12 months", text: "Last 12 months" },
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
/** A checkbox bound to a boolean given. */
|
|
514
|
+
export function Checkbox({ given, label, style }) {
|
|
515
|
+
const { value, set, spec } = useGiven(given);
|
|
516
|
+
return (
|
|
517
|
+
<label
|
|
518
|
+
style={{
|
|
519
|
+
display: "flex",
|
|
520
|
+
alignItems: "center",
|
|
521
|
+
gap: 8,
|
|
522
|
+
fontSize: 14,
|
|
523
|
+
color: V("fg", "#374151"),
|
|
524
|
+
cursor: "pointer",
|
|
525
|
+
...style,
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} />
|
|
529
|
+
<span>{label ?? label_(spec)}</span>
|
|
530
|
+
</label>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The right control for a given, picked from its declaration: numeric range
|
|
535
|
+
tags → Range, temporal filter → TimeRange, control=multiselect → MultiSelect,
|
|
536
|
+
suggestions + control=select → Select, boolean → Checkbox, else → Search. */
|
|
537
|
+
export function Given({ name, ...rest }) {
|
|
538
|
+
const spec = givenSpecs().find((s) => s.name === name);
|
|
539
|
+
if (!spec) return null;
|
|
540
|
+
const tags = spec.tags ?? {};
|
|
541
|
+
if (spec.filterType === "number" && tags.range_min != null && tags.range_max != null) {
|
|
542
|
+
return <Range given={name} {...rest} />;
|
|
543
|
+
}
|
|
544
|
+
if (spec.filterType === "timestamp" || spec.filterType === "timestamptz" || spec.filterType === "date") {
|
|
545
|
+
return <TimeRange given={name} {...rest} />;
|
|
546
|
+
}
|
|
547
|
+
if (tags.control === "multiselect") return <MultiSelect given={name} {...rest} />;
|
|
548
|
+
if (spec.suggest && tags.control === "select") return <Select given={name} {...rest} />;
|
|
549
|
+
if (spec.type === "boolean") return <Checkbox given={name} {...rest} />;
|
|
550
|
+
return <Search given={name} {...rest} />;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Every given the dashboard's query references, laid out in a filter bar. Under
|
|
554
|
+
`# artifact { autorun=false }` the bar grows an Apply/Reset pair — controls
|
|
555
|
+
edit a draft and nothing re-runs until Apply. In the live default (autorun),
|
|
556
|
+
changes re-run immediately and no buttons show. */
|
|
557
|
+
export function Controls({ style, children }) {
|
|
558
|
+
const { autorun, apply, reset, dirty } = useDashboard();
|
|
559
|
+
return (
|
|
560
|
+
<div
|
|
561
|
+
style={{
|
|
562
|
+
// position/zIndex: the filter bar (and any open control dropdown inside
|
|
563
|
+
// it) forms a stacking context ABOVE the results Panel — a later DOM
|
|
564
|
+
// sibling whose chart content would otherwise paint over a dropdown.
|
|
565
|
+
position: "relative",
|
|
566
|
+
zIndex: 40,
|
|
567
|
+
display: "flex",
|
|
568
|
+
alignItems: "flex-start",
|
|
569
|
+
gap: 12,
|
|
570
|
+
marginBottom: 14,
|
|
571
|
+
padding: "10px 12px",
|
|
572
|
+
background: V("controls-bg", "#f6f7f9"),
|
|
573
|
+
border: `1px solid ${V("border", "#e5e7eb")}`,
|
|
574
|
+
borderRadius: V("radius", "8px"),
|
|
575
|
+
...style,
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
{/* Every field is label-on-top; flex-start keeps all labels on one line
|
|
579
|
+
(bottom-align made hint-carrying fields float their labels up — jagged). */}
|
|
580
|
+
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: 16, flex: 1 }}>
|
|
581
|
+
{children ?? givenSpecs().map((s) => <Given key={s.name} name={s.name} />)}
|
|
582
|
+
</div>
|
|
583
|
+
{!autorun && (
|
|
584
|
+
// marginTop ≈ label height, so buttons line up with the control inputs
|
|
585
|
+
// (which sit below their labels), not with the labels.
|
|
586
|
+
<div style={{ display: "flex", gap: 6, marginTop: 17 }}>
|
|
587
|
+
<button type="button" onClick={reset} disabled={!dirty} style={secondaryBtnStyle(dirty)}>
|
|
588
|
+
Reset
|
|
589
|
+
</button>
|
|
590
|
+
<button type="button" onClick={apply} disabled={!dirty} style={primaryBtnStyle(dirty)}>
|
|
591
|
+
Apply
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
</div>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The whole no-code dashboard: title + doc comment + controls + panel. Used
|
|
600
|
+
when a `# artifact`-tagged query ships no Dashboard.tsx.
|
|
601
|
+
|
|
602
|
+
Full-width, page-scrolling layout: a tag-only dashboard mounts DIRECTLY in
|
|
603
|
+
the trusted page (no iframe — see mountInPage), so it spans the full width
|
|
604
|
+
and the document itself scrolls, running like the VSCode/Composer preview.
|
|
605
|
+
Title and controls sit at the top of normal flow; the Panel grows to its
|
|
606
|
+
natural height (individual result cards keep their own bounded scroll +
|
|
607
|
+
row caps, so the page stays responsive). */
|
|
608
|
+
export function DefaultDashboard({ givens, theme }) {
|
|
609
|
+
const dash = dashboardInfo();
|
|
610
|
+
// theme={{ accent:"#e11d48", controlsBg:"#fff", … }} → --dash-accent etc. on
|
|
611
|
+
// this wrapper, overriding the runtime defaults for this dashboard only.
|
|
612
|
+
const vars = {};
|
|
613
|
+
if (theme) for (const [k, v] of Object.entries(theme)) vars[`--dash-${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}`] = v;
|
|
614
|
+
return (
|
|
615
|
+
<div
|
|
616
|
+
style={{
|
|
617
|
+
fontFamily: V("font", "system-ui, sans-serif"),
|
|
618
|
+
boxSizing: "border-box",
|
|
619
|
+
padding: 24,
|
|
620
|
+
width: "100%",
|
|
621
|
+
color: V("fg", "#1a1a1a"),
|
|
622
|
+
...vars,
|
|
623
|
+
}}
|
|
624
|
+
>
|
|
625
|
+
<h1 style={{ fontSize: 22, margin: "0 0 4px" }}>{dash.title}</h1>
|
|
626
|
+
{dash.description && (
|
|
627
|
+
<p style={{ color: V("muted", "#666"), margin: "0 0 20px", lineHeight: 1.5 }}>{dash.description}</p>
|
|
628
|
+
)}
|
|
629
|
+
<Controls />
|
|
630
|
+
{/* A COMPOSITE artifact (tiles) runs its tiles and combines them into one
|
|
631
|
+
`# dashboard` that Malloy's own renderer lays out (CompositeDashboard);
|
|
632
|
+
a single artifact runs its one query. A bare <Panel/> delegates a
|
|
633
|
+
composite to CompositeDashboard, so this branch is just an explicit
|
|
634
|
+
version of that. maxHeight:"none" lets the result grow into the page's
|
|
635
|
+
own scroll rather than an inner fixed-height box. */}
|
|
636
|
+
{dashboardInfo().tiles ? (
|
|
637
|
+
<CompositeDashboard givens={givens} style={{ maxHeight: "none" }} />
|
|
638
|
+
) : (
|
|
639
|
+
<Panel givens={givens} style={{ maxHeight: "none" }} />
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}
|