← Files DataARCHIVED FILE
templates/data-app/base/src/dashboard-url-state.js
14.1 KB · Sep 30, 2026 · 23:19 UTC
import { isTemporalField } from "./source-provenance.js";
const filterParameterPrefix = "f.";
const maximumFilterIdLength = 200;
// A view contains selections, never reviewed rows. Keep each parameter bounded
// while permitting large reviewed multi-select sets without the old 100-item cap.
const maximumFilterValueLength = 65_536;
const maximumMapEntries = 2_048;
const auxiliaryParameters = { a: "assumptions", s: "sections", c: "charts", t: "tabViews", focus: "focus" };
const reservedParameters = new Set(["view", "tab", ...Object.keys(auxiliaryParameters)]);
const unsafeKeys = new Set(["__proto__", "prototype", "constructor"]);
const safeKey = value => typeof value === "string" && value.length > 0
&& value.length <= maximumFilterIdLength && !unsafeKeys.has(value) && !/[\u0000-\u001f\u007f]/u.test(value);
const plainObject = value => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
let encoder;
function safeString(value) {
// Metadata extraction imports this module without web globals or URL validation.
return typeof value === "string" && value.length <= maximumFilterValueLength
&& (encoder ??= new TextEncoder()).encode(value).length <= maximumFilterValueLength;
}
const scalar = value => safeString(value) || typeof value === "boolean"
|| typeof value === "number" && Number.isFinite(value);
const selection = value => scalar(value) || Array.isArray(value)
&& value.length <= maximumFilterValueLength && value.every(scalar);
function oversizedView() {
const error = new RangeError("This dashboard view is too large to share in a link.");
error.code = "DATA_APP_VIEW_TOO_LARGE";
return error;
}
function assertParameterSize(value) {
let serialized;
try { serialized = typeof value === "string" ? value : JSON.stringify(value); }
catch { return; } // Invalid shapes are rejected by the schema checks below.
if (!safeString(serialized)) throw oversizedView();
const checkMap = item => {
if (!plainObject(item)) return;
if (Object.keys(item).length > maximumMapEntries) throw oversizedView();
Object.values(item).forEach(checkMap);
};
if (typeof value !== "string") checkMap(value);
}
function checkedMap(value, accept) {
if (!plainObject(value)) return null;
const entries = Object.entries(value);
if (entries.length > maximumMapEntries || entries.some(([key, item]) => !safeKey(key) || !accept(item))) return null;
return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));
}
function auxiliaryValue(name, value) {
if (name === "a") return checkedMap(value, item => typeof item === "number" && Number.isFinite(item));
if (name === "focus") return checkedMap(value, safeString);
if (name === "s") {
const sections = checkedMap(value, item => checkedMap(item, selection) !== null);
return sections && Object.fromEntries(Object.entries(sections).map(([id, filters]) => [id, checkedMap(filters, selection)]));
}
if (name === "t") {
const views = checkedMap(value, item => plainObject(item) && Object.keys(item).every(key =>
key === "filters" ? checkedMap(item[key], selection) !== null
: key === "focus" ? checkedMap(item[key], safeString) !== null : false));
return views && Object.fromEntries(Object.entries(views).map(([id, view]) => [id, {
...(view.filters && Object.keys(view.filters).length ? { filters: checkedMap(view.filters, selection) } : {}),
...(view.focus && Object.keys(view.focus).length ? { focus: checkedMap(view.focus, safeString) } : {}),
}]));
}
if (name !== "c") return null;
const charts = checkedMap(value, item => plainObject(item) && Object.keys(item).every(key => {
if (!["inlineFilters", "visibleSeries", "zoomRange"].includes(key)) return false;
if (item[key] == null) return true;
if (key === "inlineFilters") return checkedMap(item[key], selection) !== null;
if (key === "visibleSeries") return Array.isArray(item[key]) && item[key].every(safeString);
return plainObject(item[key]) && Object.keys(item[key]).length === 2
&& Object.hasOwn(item[key], "start") && Object.hasOwn(item[key], "end")
&& scalar(item[key].start) && scalar(item[key].end);
}));
return charts && Object.fromEntries(Object.entries(charts).map(([id, chart]) => [id, {
...(chart.inlineFilters && Object.keys(chart.inlineFilters).length
? { inlineFilters: checkedMap(chart.inlineFilters, selection) } : {}),
...(chart.visibleSeries ? { visibleSeries: [...new Set(chart.visibleSeries)].sort() } : {}),
...(chart.zoomRange ? { zoomRange: { start: chart.zoomRange.start, end: chart.zoomRange.end } } : {}),
}]));
}
function parseAuxiliary(name, value) {
if (!safeString(value)) return null;
try { return auxiliaryValue(name, JSON.parse(value)); } catch { return null; }
}
const emptyState = () => ({ tab: null, filters: {}, hasViewState: false, complete: false,
assumptions: {}, sections: {}, charts: {}, tabViews: {}, focus: {} });
function shareableFilterDefinitions(snapshot, complete = false) {
return (snapshot?.filters ?? [])
.filter(({ id, shareInUrl }) => safeKey(id) && (complete || shareInUrl !== false))
.sort((left, right) => left.id.localeCompare(right.id));
}
function rowFilterChoice(row, field) {
const value = row?.[field];
return value !== undefined && value !== null && String(value).length > 0 ? String(value) : null;
}
/** Keep exact choices per query so explicit queryIds retain their eager ordering. */
export function createDashboardFilterChoicesAccumulator(filters = []) {
const entries = new Map();
for (const filter of filters) {
if (typeof filter.id !== "string" || entries.has(filter.id)) {
throw new Error("On-demand query loading requires unique string filter IDs.");
}
entries.set(filter.id, { filter, queries: new Map() });
}
return {
addRow(queryId, row) {
for (const { filter, queries } of entries.values()) {
if (Array.isArray(filter.queryIds) && !filter.queryIds.includes(queryId)) continue;
const choice = rowFilterChoice(row, filter.field);
if (choice === null) continue;
if (!queries.has(queryId)) queries.set(queryId, new Set());
queries.get(queryId).add(choice);
}
},
finish() {
return Object.fromEntries([...entries].map(([id, { filter, queries }]) => {
const ids = Array.isArray(filter.queryIds) ? filter.queryIds : [...queries.keys()];
return [id, [...new Set(ids.flatMap(queryId => [...(queries.get(queryId) ?? [])]))]];
}));
},
};
}
function filterChoices(snapshot, filter) {
const metadata = snapshot?._dataAppQueryLoading;
if (metadata?.version === 1 && Object.hasOwn(metadata.filterChoices ?? {}, filter.id)) {
return new Set(metadata.filterChoices[filter.id]);
}
const queries = Array.isArray(filter.queryIds)
? filter.queryIds.map((id) => snapshot?.queries?.[id]).filter(Boolean)
: Object.values(snapshot?.queries ?? {});
return new Set(queries.flatMap(({ rows = [] }) => rows
.map(row => rowFilterChoice(row, filter.field)).filter(value => value !== null)));
}
export function validFilterValue(snapshot, filter, value) {
if (Array.isArray(value)) {
if (!filter.multiple || isTemporalField(filter.field, filter.type ?? filter.valueType)
|| !value.every(item => typeof item === "string" && item !== "all")
|| !safeString(JSON.stringify(value))) return false;
const choices = filterChoices(snapshot, filter);
return value.every(item => choices.has(item));
}
if (!safeString(value)) return false;
if (value !== "all") {
const choices = filterChoices(snapshot, filter);
if (filter.mode === "through" && /^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/.test(value)) {
const [start, end] = value.split("..");
const validDay = day => /^\d{4}-\d{2}-\d{2}$/.test(day) && Number.isFinite(Date.parse(day))
&& new Date(day).toISOString().slice(0, 10) === day;
const dates = [...choices].filter(validDay).sort();
// Calendar presets can start or end between sparse observations. Preserve
// their exact scope while keeping both boundaries inside reviewed coverage.
return validDay(start) && validDay(end) && start <= end && dates.length > 0
&& start >= dates[0] && end <= dates.at(-1);
}
return choices.has(value);
}
return !isTemporalField(filter.field, filter.type ?? filter.valueType)
|| !filter.defaultValue || filter.defaultValue === "all";
}
function locationUrl(location) {
try {
return new URL(typeof location === "string" ? location : location?.href ?? "");
} catch {
return null;
}
}
export function readDashboardUrlState(snapshot, tabs = [], location = globalThis.location) {
const url = locationUrl(location);
if (!url) return emptyState();
const complete = url.searchParams.getAll("view").length === 1 && url.searchParams.get("view") === "1";
const requestedTabs = url.searchParams.getAll("tab");
const requestedTab = requestedTabs.length === 1 ? requestedTabs[0] : null;
const tab = safeKey(requestedTab) && tabs.some(({ id }) => id === requestedTab) ? requestedTab : null;
const filterEntries = [];
for (const filter of shareableFilterDefinitions(snapshot, complete)) {
const parameter = `${filterParameterPrefix}${filter.id}`;
if (!url.searchParams.has(parameter)) continue;
const values = url.searchParams.getAll(parameter);
if (values.length !== 1 || !safeString(values[0])) continue;
let value = values[0];
if (filter.multiple && value.startsWith("[")) {
try { value = JSON.parse(value); } catch { continue; }
}
if (!validFilterValue(snapshot, filter, value)) continue;
filterEntries.push([filter.id, value]);
}
const state = { ...emptyState(), tab, filters: Object.fromEntries(filterEntries), complete,
hasViewState: complete || tab !== null || filterEntries.length > 0 };
if (complete) {
for (const [parameter, property] of Object.entries(auxiliaryParameters)) {
const values = url.searchParams.getAll(parameter);
if (values.length === 1) state[property] = parseAuxiliary(parameter, values[0]) ?? {};
}
}
return state;
}
export function resolveDashboardUrlFilters(snapshot, localFilters = {}, state, { reset = false } = {}) {
if (!state?.hasViewState && !reset) return { ...localFilters };
const resolved = new Map(Object.entries(localFilters));
for (const filter of shareableFilterDefinitions(snapshot, state?.complete)) {
resolved.set(filter.id, Object.hasOwn(state?.filters ?? {}, filter.id)
? state.filters[filter.id] : filter.defaultValue ?? "all");
}
return Object.fromEntries(resolved);
}
export function serializeDashboardUrlState(snapshot, tabs = [], state = {}) {
const { tab, filters = {} } = state;
// Even an entirely default view must override a recipient's remembered choices.
const parameters = new URLSearchParams({ view: "1" });
if (safeKey(tab) && tabs.some(({ id }) => id === tab)) {
parameters.set("tab", tab);
}
for (const filter of shareableFilterDefinitions(snapshot, true)) {
const value = filters[filter.id];
const encode = selection => Array.isArray(selection)
? JSON.stringify([...new Set(selection)].sort()) : String(selection);
const defaultValue = filter.defaultValue ?? "all";
if (value === undefined || encode(value) === encode(defaultValue)) continue;
const encoded = encode(value);
assertParameterSize(encoded);
if (!validFilterValue(snapshot, filter, value)) continue;
parameters.set(`${filterParameterPrefix}${filter.id}`, encoded);
}
for (const [parameter, property] of Object.entries(auxiliaryParameters)) {
const supplied = state[property] ?? {};
if (plainObject(supplied) && Object.keys(supplied).length) assertParameterSize(supplied);
let value = auxiliaryValue(parameter, supplied);
if (!value) continue;
if (parameter === "a") value = Object.fromEntries(Object.entries(value).filter(([, number]) => number !== 0));
if (parameter === "s") value = Object.fromEntries(Object.entries(value).filter(([, filters]) => Object.keys(filters).length));
if (parameter === "c" || parameter === "t") value = Object.fromEntries(Object.entries(value).filter(([, chart]) => Object.keys(chart).length));
if (!Object.keys(value).length) continue;
const serialized = JSON.stringify(value);
if (safeString(serialized)) parameters.set(parameter, serialized);
}
return parameters;
}
// Handoffs know the URL but not necessarily the reviewed snapshot. Preserve only
// this view contract; tokens, task identity, and unrelated browser state stay out.
export function dashboardViewSearchParams(location) {
const url = locationUrl(location);
const parameters = new URLSearchParams();
if (!url) return parameters;
const complete = url.searchParams.getAll("view").length === 1 && url.searchParams.get("view") === "1";
if (complete) parameters.set("view", "1");
for (const key of new Set(url.searchParams.keys())) {
const values = url.searchParams.getAll(key);
if (values.length !== 1 || !safeString(values[0])) continue;
const value = values[0];
if (key === "tab" && safeKey(value)) parameters.set(key, value);
else if (key.startsWith(filterParameterPrefix) && safeKey(key.slice(filterParameterPrefix.length))) {
// Without the declaration, a value starting with "[" could be a literal
// categorical label or a multi-select array. The dashboard validates it.
parameters.set(key, value);
} else if (complete && Object.hasOwn(auxiliaryParameters, key)) {
const parsed = parseAuxiliary(key, value);
if (parsed !== null) parameters.set(key, JSON.stringify(parsed));
}
}
return parameters;
}
export function dashboardUrlWithState(location, snapshot, tabs, state, { preserveExisting = true } = {}) {
const url = locationUrl(location);
if (!url) return null;
if (!preserveExisting) {
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
} else {
for (const key of [...url.searchParams.keys()]) {
if (reservedParameters.has(key) || key.startsWith(filterParameterPrefix)) url.searchParams.delete(key);
}
}
for (const [key, value] of serializeDashboardUrlState(snapshot, tabs, state)) {
url.searchParams.set(key, value);
}
return url;
}
SHA-256: 7548d0752f11d003255649bc7bdb57062c97f6b82014a86c349b06e86355e932