@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,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}&ndash;{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
+ }