@malloydata/malloyyo 0.2.23 → 0.2.25

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.
@@ -10,14 +10,28 @@ import { mountInPage } from "./frame-runtime/index";
10
10
  const info = window.__DASHBOARD__ || {};
11
11
  const name = info.name;
12
12
 
13
- // Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`.
14
- const givensToUrl = (dashboard, givens) => {
13
+ // Reflect committed givens into the shell URL as `?d=<name>&$NAME=…`, plus any
14
+ // `~key` view-state (useUrlState). A tag-only dashboard runs no custom code, so
15
+ // there's normally no view-state here — but the two namespaces share one query
16
+ // string, so each write re-emits the other's params rather than erasing them.
17
+ let lastGivens = {};
18
+ // Seeded (`~key` -> value, prefix stripped) from what the shell put in the page,
19
+ // so a link's view-state survives the very first givens sync.
20
+ let lastUrlState = Object.fromEntries(
21
+ Object.entries(window.__INITIAL_URLSTATE__ || {}).map(([k, v]) => [k[0] === "~" ? k.slice(1) : k, v]),
22
+ );
23
+ const shareUrl = (dashboard) => {
15
24
  const u = new URL(location.href);
16
25
  u.search = "";
17
26
  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));
27
+ for (const [k, v] of Object.entries(lastGivens)) if (v != null && String(v) !== "") u.searchParams.set("$" + k, String(v));
28
+ for (const [k, v] of Object.entries(lastUrlState)) if (v != null) u.searchParams.set("~" + k, String(v));
19
29
  return u.pathname + u.search;
20
30
  };
31
+ const givensToUrl = (dashboard, givens) => {
32
+ lastGivens = givens;
33
+ return shareUrl(dashboard);
34
+ };
21
35
 
22
36
  mountInPage({
23
37
  root: document.getElementById("root"),
@@ -32,7 +46,13 @@ mountInPage({
32
46
  .then((r) => r.json())
33
47
  .catch((e) => ({ ok: false, problems: [{ message: String(e) }] })),
34
48
  navigate: (dashboard, givens) => {
49
+ // Leaving this dashboard: carry the drilled givens, drop the view-state.
50
+ lastUrlState = {};
35
51
  location.href = givensToUrl(dashboard, givens);
36
52
  },
37
53
  syncGivens: (givens) => history.replaceState(null, "", givensToUrl(name, givens)),
54
+ syncUrlState: (state) => {
55
+ lastUrlState = state;
56
+ history.replaceState(null, "", shareUrl(name));
57
+ },
38
58
  });
@@ -18,6 +18,7 @@ export {
18
18
  useGiven,
19
19
  useOptions,
20
20
  useQuery,
21
+ useUrlState,
21
22
  mount,
22
23
  setHost,
23
24
  dashboardInfo,
@@ -59,8 +60,17 @@ export function mountInPage(opts: {
59
60
  run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
60
61
  navigate: (dashboard: string, givens: Record<string, unknown>) => void;
61
62
  syncGivens: (givens: Record<string, unknown>) => void;
63
+ /** Mirror useUrlState view-state into the URL as `~key` params. Optional: a
64
+ host that omits it simply has no shareable view-state (tag-only
65
+ dashboards run no custom code, so none exists). */
66
+ syncUrlState?: (state: Record<string, string>) => void;
62
67
  }): { unmount: () => void } {
63
- setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
68
+ setHost({
69
+ run: opts.run,
70
+ navigate: opts.navigate,
71
+ syncGivens: opts.syncGivens,
72
+ syncUrlState: opts.syncUrlState,
73
+ });
64
74
  // bodyReset:false — the dashboard is one element in the app shell, so it must
65
75
  // not restyle <body> (the iframe host DOES own the whole document, so it keeps
66
76
  // the reset). Returns the React root so the caller can unmount() on teardown.
@@ -80,8 +90,16 @@ export function mountStatic(
80
90
  run: (req: { query?: string; malloy?: string }, givens: Record<string, unknown>) => Promise<unknown>;
81
91
  navigate: (dashboard: string, givens: Record<string, unknown>) => void;
82
92
  syncGivens: (givens: Record<string, unknown>) => void;
93
+ /** Mirror useUrlState view-state into the URL as `~key` params — a static
94
+ site's custom components DO use it, so this host should supply it. */
95
+ syncUrlState?: (state: Record<string, string>) => void;
83
96
  },
84
97
  ): { unmount: () => void } {
85
- setHost({ run: opts.run, navigate: opts.navigate, syncGivens: opts.syncGivens });
98
+ setHost({
99
+ run: opts.run,
100
+ navigate: opts.navigate,
101
+ syncGivens: opts.syncGivens,
102
+ syncUrlState: opts.syncUrlState,
103
+ });
86
104
  return mount(Dashboard ?? DefaultDashboard, WIDGETS, opts.root, { bodyReset: false });
87
105
  }
@@ -9,9 +9,10 @@
9
9
  // and posts results back.
10
10
  //
11
11
  // Injected frame globals (read lazily — script order differs between hosts):
12
- // window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
13
- // window.__GIVENS__ given specs introspected from the model's given: decls
14
- // window.__INITIAL_GIVENS__ URL-seeded given values for shareable links
12
+ // window.__DASHBOARD__ { name, query, title, description? } (the # artifact tag)
13
+ // window.__GIVENS__ given specs introspected from the model's given: decls
14
+ // window.__INITIAL_GIVENS__ URL-seeded given values ($-prefixed) for shareable links
15
+ // window.__INITIAL_URLSTATE__ URL-seeded useUrlState view-state (~-prefixed)
15
16
  import React, {
16
17
  createContext,
17
18
  useCallback,
@@ -35,9 +36,10 @@ export const dashboardInfo = () => window.__DASHBOARD__ || {};
35
36
  export const givenSpecs = () => window.__GIVENS__ || [];
36
37
 
37
38
  // ── host bridge ─────────────────────────────────────────────────────
38
- // The runtime performs three privileged actions it can't do itself: run a
39
- // governed query, navigate to a sibling dashboard, and mirror the committed
40
- // givens into the shareable URL. It delegates all three to a HOST. Two hosts
39
+ // The runtime performs four privileged actions it can't do itself: run a
40
+ // governed query, navigate to a sibling dashboard, mirror the committed givens
41
+ // into the shareable URL, and mirror a custom component's view-state
42
+ // (useUrlState) into it too. It delegates all of them to a HOST. Two hosts
41
43
  // implement the contract:
42
44
  // • postMessage host (default) — the sandboxed iframe: posts to the trusted
43
45
  // parent shell, which holds the runner. This is the CUSTOM-dashboard path.
@@ -74,6 +76,9 @@ let host = {
74
76
  syncGivens(givens) {
75
77
  parent.postMessage({ type: "givens", givens }, "*");
76
78
  },
79
+ syncUrlState(state) {
80
+ parent.postMessage({ type: "urlstate", state }, "*");
81
+ },
77
82
  };
78
83
 
79
84
  /** Swap the host — a tag-only dashboard mounted in the trusted page (mountInPage)
@@ -133,6 +138,74 @@ export function useGiven(name) {
133
138
  };
134
139
  }
135
140
 
141
+ // ── view-state in the URL (useUrlState) ─────────────────────────────
142
+ // Givens are the GOVERNED QUERY CONTRACT: declared in the model, filter-typed,
143
+ // they drive the auto-rendered controls and are visible to MCP. A custom
144
+ // component that computes its query inputs in JS has other state that belongs
145
+ // in the URL but is not a query parameter — an anagram rack, a Scrabble board,
146
+ // a "reuse letters" checkbox. Stuffing those into givens overloads what a given
147
+ // is (and they aren't in givenSpecs(), so they never rehydrate).
148
+ //
149
+ // useUrlState is that second channel: a useState twin whose value lives in the
150
+ // URL under a `~key` param, on the same parent<->frame transport as givens. The
151
+ // component runs in a sandboxed cross-origin iframe and CANNOT touch the
152
+ // top-level URL itself, so only the runtime + trusted parent can do this.
153
+ const UrlStateCtx = createContext(null);
154
+
155
+ /** value -> URL string. Strings pass through verbatim (a readable `~rack=cats`
156
+ beats `~rack=%22cats%22`); numbers/booleans stringify; everything else is
157
+ JSON. */
158
+ export function serializeUrlState(v) {
159
+ if (typeof v === "string") return v;
160
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
161
+ return JSON.stringify(v ?? null);
162
+ }
163
+
164
+ /** URL string -> value, typed by the `initial` the caller declared. A value the
165
+ URL can't produce (a bad number, malformed JSON) falls back to `initial`
166
+ rather than throwing — a hand-edited link must not white-screen a page. */
167
+ export function deserializeUrlState(raw, initial) {
168
+ if (typeof initial === "string") return raw;
169
+ if (typeof initial === "number") {
170
+ const n = Number(raw);
171
+ return Number.isFinite(n) ? n : initial;
172
+ }
173
+ if (typeof initial === "boolean") return raw === "true" || raw === "1";
174
+ try {
175
+ return JSON.parse(raw);
176
+ } catch {
177
+ return initial;
178
+ }
179
+ }
180
+
181
+ /** A `useState` twin backed by the browser URL — the dashboard-runtime
182
+ equivalent of useSearchParams, abstracted over the iframe boundary:
183
+
184
+ const [rack, setRack] = useUrlState("rack", ""); // string
185
+ const [reuse, setReuse] = useUrlState("reuse", false); // boolean
186
+ const [board, setBoard] = useUrlState("board", "........");
187
+
188
+ The value is read from `?~rack=…` on load (else `initial`), and every change
189
+ is mirrored back (debounced, replaceState — no history spam), so the URL is
190
+ always shareable/bookmarkable. `setValue` takes a value or an updater fn.
191
+ Typing follows `initial` (string / number / boolean / JSON-serializable);
192
+ it has nothing to do with Malloy given types. A value equal to `initial` is
193
+ dropped from the URL, so defaults never clutter the link. */
194
+ export function useUrlState(key, initial) {
195
+ const ctx = useContext(UrlStateCtx);
196
+ if (!ctx) throw new Error("useUrlState must run inside the dashboard runtime");
197
+ const { state, setKey } = ctx;
198
+ const raw = state[key];
199
+ const value = raw === undefined ? initial : deserializeUrlState(raw, initial);
200
+ // `initial` is usually an inline literal (a fresh array each render), so it
201
+ // can't be a dep — capture it in a ref the setter reads. The default a key is
202
+ // declared with doesn't change over a component's life.
203
+ const initialRef = useRef(initial);
204
+ initialRef.current = initial;
205
+ const set = useCallback((next) => setKey(key, next, initialRef.current), [key, setKey]);
206
+ return [value, set];
207
+ }
208
+
136
209
  // ── queries as hooks ────────────────────────────────────────────────
137
210
  /** Run a query and get plain data back: { rows, result, loading, error }.
138
211
  req: { query?: string, malloy?: string, givens?: object }. For charting
@@ -821,25 +894,61 @@ function Root({ Dashboard, extraProps }) {
821
894
  useEffect(() => {
822
895
  host.syncGivens(committed);
823
896
  }, [committed]);
897
+
898
+ // useUrlState's store: `~key` -> serialized string, seeded from the URL. It
899
+ // starts as the WHOLE seed (not just keys some hook has claimed) so params
900
+ // belonging to a component that hasn't mounted yet survive the first sync.
901
+ const urlSeed = useMemo(() => {
902
+ const s = {};
903
+ for (const [k, v] of Object.entries(window.__INITIAL_URLSTATE__ || {})) {
904
+ s[k[0] === "~" ? k.slice(1) : k] = v;
905
+ }
906
+ return s;
907
+ }, []);
908
+ const [urlState, setUrlState] = useState(urlSeed);
909
+ const setUrlKey = useCallback((key, next, initial) => {
910
+ setUrlState((prev) => {
911
+ const cur = prev[key] === undefined ? initial : deserializeUrlState(prev[key], initial);
912
+ const v = typeof next === "function" ? next(cur) : next;
913
+ const s = serializeUrlState(v);
914
+ if (s === prev[key]) return prev; // no-op set: don't re-render or re-sync
915
+ const out = { ...prev };
916
+ // A value back at its default is absent, not empty — keeps links clean.
917
+ if (s === serializeUrlState(initial)) delete out[key];
918
+ else out[key] = s;
919
+ return out;
920
+ });
921
+ }, []);
922
+ // Debounced: view-state is typed into (a rack, a board cell), and Safari
923
+ // throttles replaceState. Givens commit discretely, so they sync immediately.
924
+ useEffect(() => {
925
+ const t = setTimeout(() => host.syncUrlState?.(urlState), 150);
926
+ return () => clearTimeout(t);
927
+ }, [urlState]);
928
+ const urlCtx = useMemo(() => ({ state: urlState, setKey: setUrlKey }), [urlState, setUrlKey]);
929
+
824
930
  const ctx = useMemo(
825
931
  () => ({ givens: committed, draft, setGiven, apply, reset, dirty, autorun }),
826
932
  [committed, draft, setGiven, apply, reset, dirty, autorun],
827
933
  );
828
934
  return (
829
935
  <Ctx.Provider value={ctx}>
830
- <Dashboard
831
- dashboard={dashboardInfo()}
832
- givenSpecs={givenSpecs()}
833
- givens={committed}
834
- setGiven={setGiven}
835
- Panel={Panel}
836
- filters={filters}
837
- useGiven={useGiven}
838
- useOptions={useOptions}
839
- useQuery={useQuery}
840
- runData={runData}
841
- {...extraProps}
842
- />
936
+ <UrlStateCtx.Provider value={urlCtx}>
937
+ <Dashboard
938
+ dashboard={dashboardInfo()}
939
+ givenSpecs={givenSpecs()}
940
+ givens={committed}
941
+ setGiven={setGiven}
942
+ Panel={Panel}
943
+ filters={filters}
944
+ useGiven={useGiven}
945
+ useOptions={useOptions}
946
+ useQuery={useQuery}
947
+ useUrlState={useUrlState}
948
+ runData={runData}
949
+ {...extraProps}
950
+ />
951
+ </UrlStateCtx.Provider>
843
952
  </Ctx.Provider>
844
953
  );
845
954
  }
@@ -16,7 +16,7 @@ import * as duckdb from "@duckdb/duckdb-wasm";
16
16
  import { DuckDBWASMConnection } from "@malloydata/db-duckdb/wasm";
17
17
  import { API, SingleConnectionRuntime } from "@malloydata/malloy";
18
18
  import { mountStatic } from "./frame-runtime/index";
19
- import { givensFromSearch, givensToParams } from "./shared/givens-url";
19
+ import { givensFromSearch, shareSearch, urlStateFromSearch } from "./shared/givens-url";
20
20
 
21
21
  const info = window.__DASHBOARD__ || {};
22
22
  const MODEL_FILES = window.__MODEL_FILES__ || {};
@@ -138,11 +138,22 @@ async function run(req: { query?: string; malloy?: string }, givens: Record<stri
138
138
 
139
139
  // Same param encoding as the dev server (shared/givens-url); only the path
140
140
  // shape differs — sibling .html pages here vs `/?d=` there.
141
- const givensToUrl = (dashboard: string, givens: Record<string, unknown>) => {
141
+ //
142
+ // The URL carries TWO namespaces — `$given` and `~view-state` (useUrlState) —
143
+ // written by two independent syncs, so each write must re-emit the other's
144
+ // params or it would erase them. Both are cached here, seeded from the URL this
145
+ // page opened with, and every write rebuilds the whole query string.
146
+ let lastGivens: Record<string, unknown> = givensFromSearch(location.search);
147
+ let lastUrlState: Record<string, unknown> = urlStateFromSearch(location.search);
148
+
149
+ const dashUrl = (dashboard: string, givens: Record<string, unknown>, urlState: Record<string, unknown>) => {
142
150
  const u = new URL(`./${dashboard}.html`, document.baseURI);
143
- u.search = givensToParams(givens).toString();
144
- return u.pathname + u.search;
151
+ return u.pathname + shareSearch({ givens, urlState });
145
152
  };
153
+ // A drill LEAVES this dashboard: carry the givens it seeded, but not this
154
+ // component's view-state — that belongs to the component being left.
155
+ const navigateUrl = (dashboard: string, givens: Record<string, unknown>) =>
156
+ dashUrl(dashboard, givens, {});
146
157
 
147
158
  // Custom dashboards drive drills by posting to `parent` directly:
148
159
  // parent.postMessage({ type: "navigate", dashboard, givens }, "*")
@@ -157,11 +168,17 @@ function installMessageBridge(name: string) {
157
168
  const m = e.data;
158
169
  if (!m || typeof m !== "object") return;
159
170
  if (m.type === "givens" && m.givens) {
160
- history.replaceState(null, "", givensToUrl(name, m.givens));
171
+ lastGivens = m.givens;
172
+ history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
173
+ return;
174
+ }
175
+ if (m.type === "urlstate" && m.state) {
176
+ lastUrlState = m.state;
177
+ history.replaceState(null, "", dashUrl(name, lastGivens, lastUrlState));
161
178
  return;
162
179
  }
163
180
  if (m.type === "navigate" && typeof m.dashboard === "string") {
164
- location.href = givensToUrl(m.dashboard, m.givens || {});
181
+ location.href = navigateUrl(m.dashboard, m.givens || {});
165
182
  }
166
183
  });
167
184
  }
@@ -173,14 +190,24 @@ export function boot(Dashboard: unknown) {
173
190
  // there, here from our own query string, but through the SAME encoder, so the
174
191
  // `$` prefix the runtime keys off is preserved. Set before mount: the runtime
175
192
  // reads this global lazily when it computes initial given values.
176
- (window as any).__INITIAL_GIVENS__ = givensFromSearch(location.search);
193
+ (window as any).__INITIAL_GIVENS__ = lastGivens;
194
+ // …and the `~` half: a custom component's useUrlState (a rack, a board) —
195
+ // view-state, not a query parameter, but just as shareable.
196
+ (window as any).__INITIAL_URLSTATE__ = lastUrlState;
177
197
  installMessageBridge(info.name);
178
198
  return mountStatic(Dashboard, {
179
199
  root: document.getElementById("root") as HTMLElement,
180
200
  run,
181
201
  navigate: (dashboard, givens) => {
182
- location.href = givensToUrl(dashboard, givens);
202
+ location.href = navigateUrl(dashboard, givens);
203
+ },
204
+ syncGivens: (givens) => {
205
+ lastGivens = givens;
206
+ history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
207
+ },
208
+ syncUrlState: (state) => {
209
+ lastUrlState = state;
210
+ history.replaceState(null, "", dashUrl(info.name, lastGivens, lastUrlState));
183
211
  },
184
- syncGivens: (givens) => history.replaceState(null, "", givensToUrl(info.name, givens)),
185
212
  });
186
213
  }