← Files DataARCHIVED FILE
templates/data-app/base/src/DataAppShell.jsx
52.3 KB · Sep 30, 2026 · 23:19 UTC
import { dataAppBuildState, buildBlocksAction } from "./build-state.js";
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { semanticColorResolver } from "./charting/chart-theme.js";
import { resolveChartSpec } from "./charting/chart-overrides.js";
import { copyChartImage } from "./chart-image.js";
import {
canonicalDashboardPath,
chartPermalink,
componentPermalink,
componentPermalinkId,
componentPermalinkShortId,
isComponentPermalinkTargetVisible,
readComponentPermalink,
validComponentId,
} from "./chart-permalink.js";
import { ChartEditor } from "./components/ChartEditor.jsx";
import { Dropdown } from "./components/Controls.jsx";
import { ChartExportDialog } from "./components/ChartExportDialog.jsx";
import { chartExportMetadata } from "./chart-export.js";
import { chartColorContext } from "./components/chart-color-utils.js";
import { DashboardAskProvider } from "./components/DashboardAsk.jsx";
import { DataAppHandoffDialog } from "./components/DataAppHandoffDialog.jsx";
import { DataAppThemeDrawer, DataAppTopbar } from "./components/DataAppChrome.jsx";
import { DataAppToast } from "./components/DataAppToast.jsx";
import { Icon } from "./components/Icon.jsx";
import { SourceSidebar } from "./components/SourceInspector.jsx";
import { Dialog, MenuItem, Tooltip, useInputModality } from "./components/ui.jsx";
import { dataAppActionHref, submitDataAppAction } from "./data-app-actions.js";
import { dataAppPromptLinkProps, openDataAppHandoff } from "./data-app-handoff.js";
import { DataAppBlockLayoutContext, DataAppContext } from "./DataAppContext.jsx";
import { canUseDashboardAsk } from "./dashboard-ask.js";
import { dashboardTabId, dashboardTabSnapshot, dashboardView, drillDashboardView } from "./dashboard-view-state.js";
import { dashboardUrlWithState, readDashboardUrlState, resolveDashboardUrlFilters } from "./dashboard-url-state.js";
import {
normalizeAppearance,
normalizePresentation,
editHistoryValue,
presentationBeforeEdits,
readLocalPresentation,
readViewerAppearance,
writeViewerAppearance,
} from "./presentation-state.js";
import { codexDataAppPromptUrl, currentDataAppReference, currentDataAppViewUrl } from "./runtime-environment.js";
import { reviewedComponentClipboard } from "./source-provenance.js";
import { applyDataAppTheme, dataAppThemeTokens } from "./theme-presets.js";
import { setDataAppAppearance } from "./theme-runtime.js";
import { useDataApp } from "./use-data-app.js";
import { useDataAppImageTools } from "./use-data-app-image-tools.js";
import { useDataAppContextTools } from "./use-data-app-context-tools.js";
import { editableTextTarget, useInlineEditing } from "./use-inline-editing.js";
import { usePresentationHistory, usePresentationPersistence } from "./use-presentation.js";
const defaultDashboardTabs = [{ id: "dashboard", label: "Dashboard" }];
// Use the same capture capability as quick copy/download, including resolved chart types.
function chartExportAction(component, onSelect) {
return <MenuItem icon="download" data-requires-chart-image onSelect={onSelect}>Export chart</MenuItem>;
}
function reconcileDashboardTabs(current, authored) {
const remainingById = new Map(authored.map((tab) => [tab.id, tab]));
const next = current.flatMap(({ id, label }) => {
const definition = remainingById.get(id);
if (!definition) return [];
remainingById.delete(id);
return [{ id: definition.id, label: definition.previousLabels?.includes(label) || defaultDashboardTabs.some(tab => tab.id === id && tab.label === label) ? definition.label : label }];
});
for (const tab of authored) {
if (!remainingById.has(tab.id)) continue;
next.push({ id: tab.id, label: tab.label });
remainingById.delete(tab.id);
}
return next;
}
// Keep opening the editor independent of the dashboard render. Its real header
// mounts synchronously; only data resolution and the expensive body are deferred.
const ChartEditorHost = forwardRef(function ChartEditorHost(
{ reviewedRows, resolveColor, chartStates, canEdit, onClose, onSave },
ref,
) {
const [request, setRequest] = useState(null);
useImperativeHandle(
ref,
() => ({
open(next, immediate = true) {
if (immediate) flushSync(() => setRequest(next));
else setRequest(next);
},
close() {
setRequest(null);
},
followPermalink(next, matches) {
setRequest((current) =>
current?.permalink && (!next?.detail || !matches(current.component.id, next.id)) ? null : current,
);
},
}),
[],
);
if (!request) return null;
const { component } = request;
return (
<ChartEditorDialog
key={component.id}
component={component}
getRows={() =>
component.displayRows ??
reviewedRows(component.queryId, [component.chart.x, component.chart.series].filter(Boolean))
}
resolveColor={resolveColor}
visibleSeries={chartStates[component.id]?.visibleSeries}
zoomRange={chartStates[component.id]?.zoomRange}
canEdit={canEdit}
onClose={() => onClose(request)}
onSave={(spec) => onSave(spec, request)}
/>
);
});
// Source data is resolved after the drawer has painted, without rerendering the dashboard.
const SourceSidebarHost = forwardRef(function SourceSidebarHost(
{ queries, reviewedRows, activeFilters, chartStates, snapshot },
ref,
) {
const [component, setComponent] = useState(null);
useImperativeHandle(
ref,
() => ({
open(next) {
flushSync(() => setComponent(next));
},
close() {
setComponent(null);
},
update(next) {
setComponent((current) => current?.id === next.id ? next : current);
},
}),
[],
);
const getSource = useCallback((queryId = component?.queryId) => {
if (!component) return null;
const selectedInlineFilters = Object.entries(chartStates[component.id]?.inlineFilters ?? {})
.filter(
([field, value]) =>
value !== "all" && reviewedRows(queryId, [field]).some((row) => row[field] === value),
)
.map(([field, value]) => ({
field,
value,
label:
field === "segment" ? "Product" : snapshot.filters?.find((filter) => filter.field === field)?.label ?? field,
}));
return {
query: queries[queryId],
rows:
component.sourceRowsByQuery?.[queryId] ??
(queryId === component.queryId ? component.sourceRows : undefined) ??
reviewedRows(queryId, [component.chart?.x, component.chart?.series].filter(Boolean)),
filters: [...activeFilters.filter((filter) => !Array.isArray(filter.queryIds) || filter.queryIds.includes(queryId)), ...(component.scopeFilters ?? []), ...selectedInlineFilters],
};
}, [component, queries, reviewedRows, activeFilters, chartStates, snapshot]);
return (
component && (
<SourceSidebar
key={component.id}
component={component}
queries={queries}
getSource={getSource}
onClose={() => setComponent(null)}
/>
)
);
});
function ChartEditorDialog({ component, ...props }) {
const [colorContext] = useState(() => {
const source = document.querySelector(`[data-component-id="${CSS.escape(component.id)}"]`);
return chartColorContext(component.chart, source ? getComputedStyle(source) : null);
});
return <ChartEditor DialogComponent={Dialog} SelectComponent={Dropdown} TooltipComponent={Tooltip}
component={component} style={colorContext} {...props} />;
}
export function DataAppShell({
snapshot,
hosted,
canEdit: ownerCanEdit = true,
onSnapshotChange,
initialPresentation = {},
initialRevision = 0,
queryDataStore,
children,
}) {
const build = dataAppBuildState(snapshot);
const canEdit = ownerCanEdit && !build.active;
const reportSurface = snapshot.surface === "report";
useInputModality();
const surfaceNoun = reportSurface ? "report" : "dashboard";
const [savedPresentation] = useState(() => {
const personal = readLocalPresentation(snapshot);
const normalized = normalizePresentation(
hosted
? {
...initialPresentation,
...(personal.filters ? { filters: personal.filters } : {}),
...(personal.assumptions ? { assumptions: personal.assumptions } : {}),
...(personal.tabViews ? { tabViews: personal.tabViews } : {}),
}
: personal,
);
if (reportSurface) {
delete normalized.tabs;
delete normalized.refreshSchedule;
}
return normalized;
});
const [initialDashboardHref] = useState(() => globalThis.location?.href);
const [initialUrlState] = useState(() =>
readDashboardUrlState(snapshot, savedPresentation.tabs ?? defaultDashboardTabs),
);
const sourceSidebarRef = useRef(null);
const chartEditorRef = useRef(null);
const [mode, setMode] = useState("view");
const [chartExport, setChartExport] = useState(null);
const [editSession, setEditSession] = useState(null);
const editSessionRef = useRef(editSession);
editSessionRef.current = editSession;
const [chartOverrides, setChartOverrides] = useState(savedPresentation.chartOverrides ?? {});
const [blockLayouts, setBlockLayouts] = useState(savedPresentation.blockLayouts ?? {});
const [hidden, setHidden] = useState(() => new Set(savedPresentation.hiddenBlocks ?? []));
const [actionStatus, setActionStatus] = useState("");
const [viewerTheme, setViewerTheme] = useState(null);
const [activeTheme, setActiveTheme] = useState(savedPresentation.theme ?? "original");
const [appearance, setAppearance] = useState(normalizeAppearance(savedPresentation.appearance));
const [viewerAppearance, setViewerAppearance] = useState(() => readViewerAppearance(snapshot));
const [chartStates, setChartStates] = useState(initialUrlState.charts);
const [sectionViews, setSectionViews] = useState(initialUrlState.sections);
const setSectionView = useCallback((id, values) => {
setSectionViews(current => ({ ...current, [id]: values }));
}, []);
const [themesOpen, setThemesOpen] = useState(false);
const [originalThemeTokens] = useState(() => {
if (typeof document === "undefined" || typeof getComputedStyle !== "function") {
return dataAppThemeTokens.map(() => "");
}
const computed = getComputedStyle(document.documentElement);
return dataAppThemeTokens.map((token) => computed.getPropertyValue(`--${token}`).trim());
});
const [appTitle, setAppTitle] = useState(savedPresentation.title ?? snapshot.title);
const [verification, setVerification] = useState(savedPresentation.verification);
const [pendingVerification, setPendingVerification] = useState(null);
const [narrativeEdits, setNarrativeEdits] = useState(savedPresentation.textEdits ?? {});
const [componentTitles, setComponentTitles] = useState(savedPresentation.componentTitles ?? {});
const [tabs, setTabs] = useState(savedPresentation.tabs ?? defaultDashboardTabs);
const [activeTabId, setActiveTabId] = useState(
() => initialUrlState.tab ?? (savedPresentation.tabs ?? defaultDashboardTabs)[0].id,
);
const [tabDefinitions, setTabDefinitions] = useState([]);
const tabDefinitionsRef = useRef(tabDefinitions);
tabDefinitionsRef.current = tabDefinitions;
const scopedTabs = tabDefinitions.some(tab => Array.isArray(tab.filterIds));
const scopedSnapshot = useMemo(() => dashboardTabSnapshot(snapshot, tabDefinitions, activeTabId),
[snapshot, tabDefinitions, activeTabId]);
const [tabViews, setTabViews] = useState(
initialUrlState.complete ? initialUrlState.tabViews : savedPresentation.tabViews ?? {},
);
const tabViewsRef = useRef(tabViews);
tabViewsRef.current = tabViews;
const [viewFocus, setViewFocus] = useState(initialUrlState.focus);
const [drillReturn, setDrillReturn] = useState(null);
const viewRef = useRef({});
const { queries, filters, setFilter, replaceFilters, reviewedRows, reviewedPeriodRows, reviewedAggregatePeriodRows, activeFilters } = useDataApp(snapshot, {
queryDataStore,
filterDefinitions: scopedSnapshot.filters,
visibleFilterIds: snapshot.report?.visibleFilterIds ?? snapshot.visibleReportFilters,
hosted,
canEdit,
onSnapshotChange,
initialFilters: resolveDashboardUrlFilters(snapshot, savedPresentation.filters, initialUrlState),
authoritativeInitialFilters: initialUrlState.filters,
});
viewRef.current = { tabId: activeTabId, filters, focus: viewFocus, returnTo: drillReturn };
const activeTabRef = useRef(activeTabId);
const tabsRef = useRef(tabs);
const filtersRef = useRef(filters);
activeTabRef.current = activeTabId;
tabsRef.current = tabs;
filtersRef.current = filters;
const [tabRegistrationReady, setTabRegistrationReady] = useState(false);
const dashboardBusyRef = useRef(false);
const [dashboardBusy, updateDashboardBusy] = useState(false);
const queriesBusy = queryDataStore?.pending(Object.keys(queries)) ?? false;
const setDashboardBusy = useCallback(value => {
dashboardBusyRef.current = Boolean(value);
updateDashboardBusy(Boolean(value));
}, []);
const initialAuthoredTabsRegistered = useRef(false);
const registerDashboardTabs = useCallback(
(definitions) => {
if (reportSurface || !Array.isArray(definitions)) return;
const uniqueIds = new Set();
const normalized = definitions.slice(0, 20).flatMap((entry) => {
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
const label = typeof entry?.label === "string" ? entry.label.trim() : "";
if (!id || id.length > 80 || !/^[a-zA-Z0-9_-]+$/u.test(id) || !label || label.length > 100 || uniqueIds.has(id))
return [];
uniqueIds.add(id);
return [{ id, label, ...(Array.isArray(entry.previousLabels) ? { previousLabels: entry.previousLabels } : {}) }];
});
const authored = normalized.length ? normalized : defaultDashboardTabs;
const bindings = authored.map(tab => ({ ...definitions.find(entry => entry.id === tab.id), ...tab }));
tabDefinitionsRef.current = bindings;
setTabDefinitions(current => JSON.stringify(current) === JSON.stringify(bindings) ? current : bindings);
if (!normalized.length) {
uniqueIds.clear();
uniqueIds.add("dashboard");
}
const nextTabs = reconcileDashboardTabs(tabsRef.current, authored);
if (!initialAuthoredTabsRegistered.current) {
initialAuthoredTabsRegistered.current = true;
const requested = initialDashboardHref ? new URL(initialDashboardHref).searchParams.get("tab") : null;
const tabId = dashboardTabId(bindings, requested ?? activeTabRef.current);
const scoped = dashboardTabSnapshot(snapshot, bindings, tabId);
const state = readDashboardUrlState(scoped, authored, initialDashboardHref);
const historyView = globalThis.history?.state?.dataAppView;
const restored = historyView && historyView.artifactId === snapshot.id && historyView.tabId === tabId ? historyView : null;
const saved = state.complete ? state.tabViews[tabId] ?? {}
: restored ?? savedPresentation.tabViews?.[tabId] ?? { filters: savedPresentation.filters };
const view = dashboardView(snapshot, bindings, tabId, saved, state);
setActiveTabId(tabId);
replaceFilters(view.filters);
setViewFocus(view.focus);
setDrillReturn(restored?.returnTo ?? null);
} else if (!uniqueIds.has(activeTabRef.current)) {
setActiveTabId(nextTabs[0].id);
}
setTabs((current) => {
const next = reconcileDashboardTabs(current, authored);
return next.length === current.length &&
next.every((tab, index) => tab.id === current[index].id && tab.label === current[index].label)
? current
: next;
});
setTabRegistrationReady(true);
},
[initialDashboardHref, replaceFilters, reportSurface, savedPresentation, snapshot],
);
const queryLoadingViewInitialized = useRef(false);
useEffect(() => {
if (reportSurface || initialAuthoredTabsRegistered.current) {
setTabRegistrationReady(true);
return;
}
if (queryDataStore && queryLoadingViewInitialized.current) return;
const state = readDashboardUrlState(snapshot, defaultDashboardTabs, initialDashboardHref);
setTabs(defaultDashboardTabs);
setActiveTabId(state.tab ?? "dashboard");
replaceFilters(resolveDashboardUrlFilters(snapshot, savedPresentation.filters, state));
setTabRegistrationReady(true);
queryLoadingViewInitialized.current = true;
}, [initialDashboardHref, replaceFilters, reportSurface, savedPresentation, snapshot, queryDataStore]);
const [linkedComponent, setLinkedComponent] = useState(() =>
hosted ? readComponentPermalink(globalThis.location) : null,
);
const componentTargets = useRef(new Map());
const handledComponentPermalink = useRef(null);
const unavailableComponentPermalink = useRef(null);
const componentTabProbe = useRef(null);
const componentHighlight = useRef(null);
const registerComponent = useCallback((component, element) => {
if (element) {
componentTargets.current.set(component.id, { component, element });
sourceSidebarRef.current?.update(component);
}
else componentTargets.current.delete(component.id);
}, []);
const componentMatchesPermalink = useCallback(
(authoredId, permalinkId) =>
authoredId === permalinkId ||
(validComponentId(authoredId) &&
(componentPermalinkId(globalThis.location, authoredId) === permalinkId ||
componentPermalinkShortId(globalThis.location, authoredId) === permalinkId)),
[],
);
const clearComponentHighlight = useCallback(() => {
if (!componentHighlight.current) return;
clearTimeout(componentHighlight.current.timeout);
componentHighlight.current.element.removeAttribute("data-permalink-target");
componentHighlight.current = null;
}, []);
const followComponentPermalink = useCallback(() => {
const nextComponent = readComponentPermalink(window.location);
clearComponentHighlight();
handledComponentPermalink.current = null;
unavailableComponentPermalink.current = null;
componentTabProbe.current = null;
chartEditorRef.current?.followPermalink(nextComponent, componentMatchesPermalink);
setLinkedComponent(nextComponent);
}, [clearComponentHighlight, componentMatchesPermalink]);
const [assumptions, setAssumptions] = useState(() => initialUrlState.complete
? { activationLift: 0, retentionLift: 0, ...initialUrlState.assumptions }
: savedPresentation.assumptions ?? { activationLift: 0, retentionLift: 0 });
// Personal view choices travel with the link, never the shared presentation.
const currentView = {
tab: activeTabId, filters, assumptions, sections: sectionViews, charts: chartStates,
focus: viewFocus,
tabViews: scopedTabs ? { ...tabViews, [activeTabId]: { filters, focus: viewFocus } } : {},
};
const currentViewRef = useRef(currentView);
currentViewRef.current = currentView;
const editingRoot = useInlineEditing(
canEdit && mode === "edit" && !editSession?.saving,
(id, value) => setNarrativeEdits((current) => ({ ...current, [id]: value })),
narrativeEdits,
);
useDataAppImageTools(editingRoot, componentTargets, {
surface: snapshot.surface, tabId: reportSurface ? null : activeTabId, filters,
});
const presentation = {
theme: activeTheme,
appearance,
title: appTitle,
hiddenBlocks: [...hidden],
componentTitles,
textEdits: narrativeEdits,
chartOverrides,
...(verification ? { verification } : {}),
...(Object.keys(blockLayouts).length ? { blockLayouts } : {}),
...(savedPresentation.description !== undefined ? { description: savedPresentation.description } : {}),
...(!reportSurface
? {
tabs,
...(savedPresentation.refreshSchedule ? { refreshSchedule: savedPresentation.refreshSchedule } : {}),
}
: {}),
...(!hosted ? { filters, assumptions } : {}),
};
const contextViewUrl = linkedViewUrl();
const contextReference = currentDataAppReference();
useDataAppContextTools({
snapshot: { ...snapshot, queries },
queryDataStore,
presentation: { ...presentation, filters, assumptions, filterDefinitions: scopedSnapshot.filters ?? [] },
view: currentView,
dataAppReference: contextReference.sourceUrl && contextViewUrl
? { ...contextReference, sourceUrl: contextViewUrl } : contextReference,
viewUrl: contextViewUrl,
canEdit,
getText: (id) => {
const matches = [...(editingRoot.current?.querySelectorAll("[data-editable-id], [data-component-id]") ?? [])]
.filter(element => (element.getAttribute("data-editable-id") === id || element.getAttribute("data-component-id") === id)
&& isComponentPermalinkTargetVisible(element));
if (matches.length > 1) throw new Error("The text ID is ambiguous in this view.");
return matches[0]?.innerText ?? null;
},
});
const acknowledgePresentation = useCallback((authoritative, action) => {
const nextVerification = authoritative.verification;
setVerification((current) =>
JSON.stringify(current) === JSON.stringify(nextVerification) ? current : nextVerification,
);
if (action) setPendingVerification((current) => (current === action ? null : current));
if (editSessionRef.current?.saving) {
setEditSession(null);
setMode("view");
}
}, []);
function restorePresentation(value) {
setActiveTheme(value.theme ?? "original");
setAppearance(normalizeAppearance(value.appearance));
setAppTitle(value.title ?? snapshot.title);
setHidden(new Set(value.hiddenBlocks ?? []));
setComponentTitles(value.componentTitles ?? {});
setNarrativeEdits(value.textEdits ?? {});
setChartOverrides(value.chartOverrides ?? {});
setBlockLayouts(value.blockLayouts ?? {});
setTabs(value.tabs ?? defaultDashboardTabs);
}
const editHistory = usePresentationHistory(presentation, canEdit && mode === "edit", restorePresentation);
const handlePresentationError = useCallback((message) => {
setActionStatus(message);
setEditSession((current) => current ? { ...current, saving: false } : current);
}, []);
const saveStatus = usePresentationPersistence({
snapshot,
hosted,
canEdit,
presentation: editSession && !editSession.saving
? presentationBeforeEdits(presentation, editSession.baseline) : presentation,
personalPresentation: { ...(hosted ? { filters, assumptions } : {}), ...(scopedTabs ? { tabViews } : {}) },
initialPresentation: savedPresentation,
initialRevision,
verificationAction: editSession?.saving ? pendingVerification : null,
onAcknowledged: acknowledgePresentation,
onError: handlePresentationError,
});
function beginEditing() {
if (!canEdit || editSession || saveStatus === "saving") return;
setEditSession({ baseline: JSON.parse(editHistoryValue(presentation)), saving: false });
setMode("edit");
}
function cancelEditing() {
if (!editSession || editSession.saving) return;
restorePresentation(editSession.baseline);
setPendingVerification(null);
setEditSession(null);
setMode("view");
}
function saveEditing() {
if (!canEdit || !editSession || editSession.saving) return;
// Text editors commit on blur. Flush that event before taking the value
// used by persistence, including keyboard activation of the Save control.
flushSync(() => document.activeElement?.blur?.());
setEditSession((current) => current ? { ...current, saving: true } : current);
}
const editingChanged = editSession && (pendingVerification !== null
|| editHistoryValue(presentation) !== JSON.stringify(editSession.baseline));
useEffect(() => {
if (editSession?.saving && !editingChanged) {
setEditSession(null);
setMode("view");
}
}, [editSession?.saving, editingChanged]);
useEffect(() => {
if (!editingChanged) return undefined;
const warn = (event) => { event.preventDefault(); event.returnValue = ""; };
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [editingChanged]);
const colorMetadata = snapshot._dataAppQueryLoading?.colors;
// Hydration does not change the complete reviewed palette. Owner edits clear
// this metadata and resume allocation from the current complete queries.
const colorQueries = colorMetadata ? undefined : snapshot.queries;
const resolveColor = useMemo(() => semanticColorResolver(colorQueries, colorMetadata),
[colorQueries, colorMetadata]);
const displayedTheme = viewerTheme ?? activeTheme;
const previousTheme = useRef(displayedTheme);
useEffect(() => {
if (displayedTheme !== "original" || previousTheme.current !== displayedTheme) applyDataAppTheme(displayedTheme);
previousTheme.current = displayedTheme;
}, [displayedTheme]);
useEffect(() => {
setDataAppAppearance(viewerAppearance || appearance);
}, [viewerAppearance, appearance, activeTheme]);
useEffect(() => {
if (!tabs.some(({ id }) => id === activeTabId)) setActiveTabId(tabs[0].id);
}, [activeTabId, tabs]);
useEffect(() => {
if (!tabRegistrationReady || typeof window === "undefined") return;
try {
const next = dashboardUrlWithState(window.location, scopedSnapshot, reportSurface ? [] : tabs, currentViewRef.current);
if (next && next.href !== window.location.href) {
window.history.replaceState(window.history.state, "", next);
}
} catch {
setActionStatus("This view has too many selections to fit in a shareable link. Reduce the selections before sharing.");
}
}, [activeTabId, filters, assumptions, sectionViews, chartStates, viewFocus, tabViews,
reportSurface, scopedSnapshot, tabs, tabRegistrationReady]);
useEffect(() => {
if (!scopedTabs || !tabRegistrationReady) return;
const view = { filters, focus: viewFocus };
if (!drillReturn) setTabViews(current => JSON.stringify(current[activeTabId]) === JSON.stringify(view)
? current : { ...current, [activeTabId]: view });
if (typeof window !== "undefined") window.history.replaceState({ ...window.history.state,
dataAppView: { artifactId: snapshot.id, ...viewRef.current } }, "");
}, [activeTabId, filters, viewFocus, drillReturn, scopedTabs, tabRegistrationReady, snapshot.id]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
function restoreDashboardUrlState(event) {
const definitions = tabDefinitionsRef.current;
const requested = new URL(window.location.href).searchParams.get("tab");
const tabId = dashboardTabId(definitions.length ? definitions : tabs, requested);
const scoped = dashboardTabSnapshot(snapshot, definitions, tabId);
const state = readDashboardUrlState(scoped, tabs, window.location);
const stored = event.state?.dataAppView;
const view = stored && stored.artifactId === snapshot.id && stored.tabId === tabId ? stored : null;
const next = dashboardView(snapshot, definitions, tabId,
state.complete ? state.tabViews[tabId] ?? {}
: view ?? tabViewsRef.current[tabId] ?? { filters: filtersRef.current }, state);
if (state.complete) {
setAssumptions({ activationLift: 0, retentionLift: 0, ...state.assumptions });
setSectionViews(state.sections);
setChartStates(state.charts);
setTabViews(state.tabViews);
}
setActiveTabId(tabId);
replaceFilters(next.filters);
setViewFocus(next.focus);
setDrillReturn(view?.returnTo ?? null);
if (view?.scrollY != null) requestAnimationFrame(() => window.scrollTo(0, view.scrollY));
}
window.addEventListener("popstate", restoreDashboardUrlState);
return () => window.removeEventListener("popstate", restoreDashboardUrlState);
}, [replaceFilters, reportSurface, snapshot, tabs]);
const navigateDashboardTab = useCallback((tabId, nextTabs = tabs, payload = null) => {
if (!nextTabs.some(tab => tab.id === tabId) || (tabId === activeTabRef.current && !payload)) return;
const definitions = tabDefinitionsRef.current;
const scoped = definitions.some(tab => Array.isArray(tab.filterIds));
const saved = tabViewsRef.current[tabId] ?? {};
const target = payload ? drillDashboardView(snapshot, definitions, tabId, saved, payload)
: scoped ? dashboardView(snapshot, definitions, tabId, saved) : { filters: filtersRef.current, focus: {} };
const origin = { ...viewRef.current, scrollY: globalThis.scrollY ?? 0 };
const returnTo = payload ? origin : null;
if (!reportSurface && typeof window !== "undefined") {
let next;
try {
next = dashboardUrlWithState(window.location, dashboardTabSnapshot(snapshot, definitions, tabId), nextTabs,
{ ...currentViewRef.current, tab: tabId, filters: target.filters, focus: target.focus,
tabViews: scoped ? { ...tabViewsRef.current, [activeTabRef.current]: { filters: origin.filters, focus: origin.focus }, [tabId]: target } : {} });
} catch (error) {
setActionStatus(error.message);
return;
}
window.history.replaceState({ ...window.history.state,
dataAppView: { artifactId: snapshot.id, ...origin } }, "");
if (next) {
next.pathname = canonicalDashboardPath(next.pathname);
window.history.pushState({ ...window.history.state,
dataAppView: { artifactId: snapshot.id, tabId, ...target, returnTo } }, "", next);
if (hosted) followComponentPermalink();
}
requestAnimationFrame(() => window.scrollTo(0, 0));
}
replaceFilters(target.filters);
setViewFocus(target.focus);
setDrillReturn(returnTo);
setActiveTabId(tabId);
}, [followComponentPermalink, hosted, replaceFilters, reportSurface, snapshot, tabs]);
const exploreDashboard = useCallback((tabId, payload) => navigateDashboardTab(tabId, tabs, payload), [navigateDashboardTab, tabs]);
const returnFromExploration = useCallback(() => {
if (drillReturn && typeof window !== "undefined") window.history.back();
}, [drillReturn]);
const setDashboardFocus = useCallback((focus) => {
setViewFocus(dashboardView(snapshot, tabDefinitionsRef.current, activeTabRef.current, { focus }).focus);
}, [snapshot]);
useEffect(() => {
if (!hosted || typeof window === "undefined") return undefined;
window.addEventListener("popstate", followComponentPermalink);
return () => {
window.removeEventListener("popstate", followComponentPermalink);
clearComponentHighlight();
handledComponentPermalink.current = null;
unavailableComponentPermalink.current = null;
componentTabProbe.current = null;
};
}, [clearComponentHighlight, followComponentPermalink, hosted]);
useEffect(() => {
if (!tabRegistrationReady || !hosted || !linkedComponent || dashboardBusyRef.current
|| queryDataStore?.pending(Object.keys(queries))) return;
const routeKey = `${linkedComponent.kind}\u0000${linkedComponent.id}\u0000${linkedComponent.detail}`;
if (handledComponentPermalink.current === routeKey) return;
const unavailableMessage =
linkedComponent.kind === "chart" ? "The linked chart is unavailable." : "The linked component is unavailable.";
function showUnavailable() {
if (unavailableComponentPermalink.current !== routeKey) {
unavailableComponentPermalink.current = routeKey;
setActionStatus(unavailableMessage);
}
}
const hiddenTarget =
hidden.has(linkedComponent.id) ||
[...hidden].slice(0, 500).some((id) => componentMatchesPermalink(id, linkedComponent.id));
if (hiddenTarget) {
componentTabProbe.current = null;
showUnavailable();
return;
}
const existingTabIds = new Set(tabs.slice(0, 50).map(({ id }) => id));
let probe = componentTabProbe.current;
if (!probe || probe.routeKey !== routeKey) {
probe = {
routeKey,
attemptedTabIds: new Set(),
originalTabId: activeTabId,
pendingTabId: null,
};
componentTabProbe.current = probe;
}
if (probe.pendingTabId && probe.pendingTabId !== activeTabId && existingTabIds.has(probe.pendingTabId)) {
return;
}
probe.pendingTabId = null;
const candidate =
componentTargets.current.get(linkedComponent.id) ??
[...componentTargets.current.values()]
.slice(0, 500)
.find(({ component }) => componentMatchesPermalink(component.id, linkedComponent.id));
// Authored tabs may retain their DOM while hiding inactive panels. A
// registered but invisible component still needs its owning tab selected.
const target = isComponentPermalinkTargetVisible(candidate?.element) ? candidate : null;
if (!target) {
probe.attemptedTabIds.add(activeTabId);
const dashboardTab = tabs.find(({ id }) => id === "dashboard" && !probe.attemptedTabIds.has(id));
const nextTab = dashboardTab ?? tabs.slice(0, 50).find(({ id }) => !probe.attemptedTabIds.has(id));
if (nextTab) {
probe.pendingTabId = nextTab.id;
setActiveTabId(nextTab.id);
return;
}
}
if (!target || (linkedComponent.kind === "chart" && !target.component.chart)) {
handledComponentPermalink.current = routeKey;
componentTabProbe.current = null;
showUnavailable();
if (probe.originalTabId !== activeTabId && existingTabIds.has(probe.originalTabId)) {
setActiveTabId(probe.originalTabId);
}
return;
}
handledComponentPermalink.current = routeKey;
componentTabProbe.current = null;
if (unavailableComponentPermalink.current === routeKey) {
unavailableComponentPermalink.current = null;
setActionStatus((current) => (current === unavailableMessage ? "" : current));
}
const { component, element } = target;
element.setAttribute("data-permalink-target", "true");
element.scrollIntoView({
block: "center",
behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ? "auto" : "smooth",
});
element.focus({ preventScroll: true });
if (linkedComponent.kind === "chart" && linkedComponent.detail && component.chart) {
chartEditorRef.current?.open(
{
type: "explore",
component,
permalink: true,
originalOverride: chartOverrides[component.id],
dirty: false,
resetVersion: 0,
draft: component.chart,
history: [component.chart],
historyIndex: 0,
},
false,
);
}
const timeout = setTimeout(() => {
element.removeAttribute("data-permalink-target");
if (componentHighlight.current?.element === element) componentHighlight.current = null;
}, 2400);
componentHighlight.current = { element, timeout };
}, [
activeTabId,
chartOverrides,
componentMatchesPermalink,
hidden,
hosted,
linkedComponent,
tabs,
tabRegistrationReady,
dashboardBusy,
queriesBusy,
]);
const visible = (id) => !hidden.has(id);
function updateChartState(id, changes) {
setChartStates((current) => ({
...current,
[id]: { ...(current[id] ?? {}), ...changes },
}));
}
function chartProps(id) {
return {
chartId: id,
resolveColor,
visibleSeries: chartStates[id]?.visibleSeries,
onVisibleSeriesChange: (visibleSeries) => updateChartState(id, { visibleSeries }),
zoomRange: chartStates[id]?.zoomRange,
onZoomChange: (zoomRange) => updateChartState(id, { zoomRange }),
};
}
function requestTextEdit(target) {
if (!canEdit || !target) return;
if (mode !== "edit") beginEditing();
requestAnimationFrame(() =>
requestAnimationFrame(() => {
if (!target.isConnected) return;
const editable = target.isContentEditable ? target : target.querySelector('[contenteditable="true"]');
editable?.focus();
}),
);
}
async function copyComponent(format, component) {
if (format === "link" || format === "detail-link" || format === "component-link") {
try {
const chartLink = format !== "component-link";
if (!hosted || (chartLink && !component.chart)) {
throw new Error("Only published components have shareable links.");
}
if (!navigator.clipboard?.writeText) throw new Error("Clipboard is unavailable in this browser.");
const componentId = componentPermalinkShortId(globalThis.location, component.id);
const permalink = chartLink
? chartPermalink(globalThis.location, componentId, {
detail: format === "detail-link",
})
: componentPermalink(globalThis.location, componentId);
const url = dashboardUrlWithState(permalink, scopedSnapshot, reportSurface ? [] : tabs,
currentViewRef.current, { preserveExisting: false }).toString();
await navigator.clipboard.writeText(url);
setActionStatus(`${component.title} link copied.`);
} catch (error) {
setActionStatus(error instanceof Error ? error.message : "Unable to copy the component link.");
}
return;
}
if (format === "image") {
try {
await copyChartImage(component.id, { description: component.description });
setActionStatus(`Chart image for “${component.title}” copied.`);
} catch (error) {
setActionStatus(error instanceof Error ? error.message : "Unable to copy the chart image.");
}
return;
}
const text = reviewedComponentClipboard(component, queries, (queryId) =>
component.sourceRowsByQuery?.[queryId]
?? (queryId === component.queryId ? component.sourceRows ?? component.displayRows : undefined)
?? reviewedRows(queryId));
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard is unavailable in this browser.");
await navigator.clipboard.writeText(text);
setActionStatus(`${format} for “${component.title}” copied.`);
} catch (error) {
setActionStatus(error instanceof Error ? error.message : "Unable to copy reviewed data.");
}
}
function viewDataAppReference(viewUrl) {
const reference = currentDataAppReference();
if (!reference.sourceUrl) return reference;
const url = viewUrl ?? dashboardUrlWithState(reference.sourceUrl, scopedSnapshot, reportSurface ? [] : tabs,
currentViewRef.current, { preserveExisting: false });
return { ...reference, sourceUrl: url?.toString() ?? reference.sourceUrl };
}
function linkedViewUrl(strict = false) {
const source = currentDataAppViewUrl();
if (!source) return null;
try {
return dashboardUrlWithState(source, scopedSnapshot, reportSurface ? [] : tabs,
currentViewRef.current, { preserveExisting: false })?.toString() ?? null;
} catch (error) {
if (strict) throw error;
return null; // An oversized view cannot be represented by a partial link.
}
}
function actionContext(options = {}) {
const viewUrl = linkedViewUrl(true);
return {
...options,
surface: snapshot.surface,
canEdit,
snapshot,
title: appTitle,
dataAppReference: viewDataAppReference(viewUrl),
viewUrl,
presentation: {
...presentation,
filters,
assumptions,
filterDefinitions: scopedSnapshot.filters ?? [],
},
};
}
function actionHref(action, options, destination) {
if (buildBlocksAction(snapshot, action)) return null;
try { return dataAppActionHref(action, actionContext(options), undefined, destination); }
catch { return null; } // Click handling reports an unrepresentable view; never offer a partial link.
}
async function runAction(action, options = {}) {
if (action === "handoff-preference-error") {
setActionStatus("Your ChatGPT opening preference couldn't be saved in this browser.");
return false;
}
if (buildBlocksAction(snapshot, action)) {
setActionStatus("This action is available when ChatGPT finishes the update.");
return false;
}
try {
if (action === "copy-link") {
await navigator.clipboard.writeText(viewDataAppReference().sourceUrl
?? dashboardUrlWithState(globalThis.location, scopedSnapshot, reportSurface ? [] : tabs,
currentViewRef.current, { preserveExisting: false })?.toString() ?? "");
setActionStatus("Link copied.");
return true;
}
const accepted = await submitDataAppAction(action, actionContext(options));
if (!accepted) setActionStatus(`This host cannot complete that ${surfaceNoun} action.`);
return accepted;
} catch (error) {
setActionStatus(
error instanceof Error ? error.message : `${surfaceNoun === "report" ? "Report" : "Dashboard"} action failed.`,
);
return false;
}
}
async function requestReportFollowUp(action, followUp) {
if (snapshot.surface !== "report"
|| !["report-investigate", "report-investigate-update", "report-correct"].includes(action)
|| (!canEdit && (action !== "report-investigate" || followUp?.editorOnly))) {
setActionStatus("You cannot change this report.");
return false;
}
const current = { ...followUp,
text: narrativeEdits[followUp?.narrativeId] ?? followUp?.text };
return runAction(action, { followUp: current });
}
function reportFollowUpHref(followUp) {
const current = { ...followUp,
text: narrativeEdits[followUp?.narrativeId] ?? followUp?.text };
return dataAppPromptLinkProps(destination => actionHref("report-investigate", { followUp: current }, destination));
}
function openAuthoredReportFollowUp(event) {
if (event.defaultPrevented) return;
// Older authored ReportTaskLink copies forward only href and target. Route
// those existing reader links through the same chooser without rewriting them.
const link = event.target.closest?.("a.report-task-link");
if (!link || !event.currentTarget.contains(link)) return;
const url = new URL(link.href);
const nativePrompt = url.protocol === "codex:" && (
(url.hostname === "new" && /^\/?$/u.test(url.pathname))
|| (url.hostname === "threads" && /^\/(?:new|[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})$/iu.test(url.pathname))
);
const prompt = nativePrompt
? url.searchParams.get("prompt")
: url.origin === "https://chatgpt.com" ? url.searchParams.get("q") : null;
if (!prompt) return;
event.preventDefault();
openDataAppHandoff(destination =>
codexDataAppPromptUrl(prompt, currentDataAppReference(), undefined, undefined, destination).toString());
}
function closeChartDialog(dialog) {
if (dialog?.permalink) {
const currentRoute = readComponentPermalink(globalThis.location);
if (
currentRoute?.kind === "chart" &&
currentRoute.detail &&
componentMatchesPermalink(dialog.component.id, currentRoute.id)
) {
const url = new URL(window.location.href);
url.pathname = new URL(chartPermalink(window.location, currentRoute.id)).pathname;
window.history.replaceState(window.history.state, "", url);
handledComponentPermalink.current = null;
componentTabProbe.current = null;
if (componentHighlight.current) {
clearTimeout(componentHighlight.current.timeout);
componentHighlight.current.element.removeAttribute("data-permalink-target");
componentHighlight.current = null;
}
setLinkedComponent(readComponentPermalink(url));
}
}
chartEditorRef.current?.close();
}
function saveChartDialog(spec, dialog) {
if (!canEdit || dialog?.type !== "explore") return;
const previous = chartOverrides[dialog.component.id] ?? dialog.component.chart;
if (
previous.y !== spec.y ||
previous.series !== spec.series ||
JSON.stringify(previous.fields ?? []) !== JSON.stringify(spec.fields ?? [])
) {
updateChartState(dialog.component.id, { visibleSeries: undefined });
}
setChartOverrides((current) => ({ ...current, [dialog.component.id]: spec }));
}
const actions = {
editMode: canEdit && mode === "edit",
canEdit,
published: hosted,
onOpen: (type, component) => {
const next = {
type,
component,
...(type === "explore"
? {
originalOverride: chartOverrides[component.id],
dirty: false,
resetVersion: 0,
draft: component.chart,
history: [component.chart],
historyIndex: 0,
}
: {}),
};
if (type !== "explore") {
chartEditorRef.current?.close();
sourceSidebarRef.current?.open(component);
return;
}
sourceSidebarRef.current?.close();
chartEditorRef.current?.open(next);
},
onHide: (id) => setHidden((current) => new Set([...current, id])),
onCopy: copyComponent,
additionalActions: (component) => component.chart && chartExportAction(component, () => {
const bounds = componentTargets.current.get(component.id)?.element.getBoundingClientRect();
const chart = resolveChartSpec(component.chart, chartOverrides[component.id]);
setChartExport({
component: { ...component, chart },
rows: component.displayRows ?? reviewedRows(component.queryId, [chart.x, chart.series].filter(Boolean)),
originalSize: { width: bounds?.width ?? 1000, height: bounds?.height ?? 600 },
provenance: chartExportMetadata(queries[component.queryId], [
...activeFilters.filter(filter => !filter.queryIds || filter.queryIds.includes(component.queryId)),
...(component.scopeFilters ?? []),
]),
visibleSeries: chartStates[component.id]?.visibleSeries,
zoomRange: chartStates[component.id]?.zoomRange,
});
}),
onRegisterComponent: registerComponent,
titleOverrides: componentTitles,
onTitleChange: (value, id) => setComponentTitles((current) => ({ ...current, [id]: value })),
};
const setBlockLayout = useCallback(
(regionId, layout) => {
if (!canEdit) return;
setBlockLayouts((current) => ({ ...current, [regionId]: layout }));
},
[canEdit],
);
const blockLayoutContext = useMemo(() => ({ blockLayouts, setBlockLayout }), [blockLayouts, setBlockLayout]);
const shellContext = useMemo(
() => ({
snapshot: scopedSnapshot,
hosted,
canEdit,
queries,
queryDataStore,
filters,
setFilter,
reviewedRows,
reviewedPeriodRows,
reviewedAggregatePeriodRows,
activeFilters,
mode,
appTitle,
setAppTitle,
assumptions,
setAssumptions,
sectionViews,
setSectionView,
chartOverrides,
chartStates,
visible,
updateChartState,
chartProps,
resolveColor,
surfaceNoun,
componentActions: actions,
chartExportActive: Boolean(chartExport),
activeTabId,
registerDashboardTabs,
setDashboardBusy, exploreDashboard, returnFromExploration, setDashboardFocus, viewFocus, canReturnFromExploration: Boolean(drillReturn),
hiddenBlockIds: hidden,
narrativeEdits,
reportFollowUpHref,
requestReportFollowUp,
requestTextEdit,
setNarrativeEdit: (id, value) => setNarrativeEdits((current) => ({ ...current, [id]: value })),
}),
[
scopedSnapshot,
hosted,
canEdit,
queries,
queryDataStore,
filters,
setFilter,
reviewedRows,
reviewedPeriodRows,
reviewedAggregatePeriodRows,
activeFilters,
mode,
appTitle,
assumptions,
sectionViews,
setSectionView,
chartOverrides,
chartStates,
chartExport,
resolveColor,
hidden,
activeTabId,
registerDashboardTabs,
setDashboardBusy, exploreDashboard, returnFromExploration, setDashboardFocus, viewFocus, drillReturn,
componentTitles,
activeTheme,
appearance,
viewerAppearance,
narrativeEdits,
],
);
return (
<DataAppContext.Provider value={shellContext}>
<DataAppBlockLayoutContext.Provider value={blockLayoutContext}>
<DashboardAskProvider
canEdit={ownerCanEdit}
enabled={canUseDashboardAsk({ canEdit: ownerCanEdit, mode })}
explorationEnabled={mode === "view"}
dashboardTitle={appTitle}
onStatus={setActionStatus}
>
<div className="dashboard-root">
<DataAppTopbar
title={appTitle}
buildStatus={snapshot.buildStatus}
generatedAt={snapshot.generatedAt}
reportAsOf={snapshot.report?.asOf}
status={snapshot.status}
surface={snapshot.surface}
mode={mode}
onModeChange={(next) => { if (next === "edit") beginEditing(); }}
onSave={saveEditing}
onCancel={cancelEditing}
saving={Boolean(editSession?.saving)}
editHistory={editHistory}
onTitleChange={setAppTitle}
onAction={runAction}
getActionHref={actionHref}
onOpenThemes={() => setThemesOpen(true)}
published={hosted}
canEdit={ownerCanEdit}
verification={verification}
pendingVerification={pendingVerification}
onVerificationReminderError={setActionStatus}
onVerificationChange={
hosted && canEdit && !reportSurface
? (verified) => {
if (mode !== "edit" || !editSession || editSession.saving) return;
setPendingVerification(verified === Boolean(verification) ? null : verified ? "verify" : "remove");
}
: undefined
}
saveStatus={saveStatus}
hiddenCount={hidden.size}
onRestoreHidden={() => setHidden(new Set())}
tabs={snapshot.surface === "report" || !tabRegistrationReady ? [] : tabs}
activeTabId={activeTabId}
onTabChange={navigateDashboardTab}
onReorderTabs={setTabs}
/>
<DataAppThemeDrawer
open={themesOpen}
activeTheme={displayedTheme}
originalTokens={originalThemeTokens}
appearance={viewerAppearance || appearance}
onAppearanceChange={(value) => {
if (canEdit) {
setAppearance(value);
setViewerAppearance("");
writeViewerAppearance(snapshot, "");
} else {
setViewerAppearance(value);
writeViewerAppearance(snapshot, value);
}
}}
onClose={() => setThemesOpen(false)}
onApply={(themeId) => {
applyDataAppTheme(themeId);
if (build.active) setViewerTheme(themeId);
else if (canEdit) { setViewerTheme(null); setActiveTheme(themeId); }
setThemesOpen(false);
}}
onPreview={applyDataAppTheme}
onPreviewEnd={() => applyDataAppTheme(displayedTheme)}
/>
<DataAppToast message={actionStatus} onDismiss={() => setActionStatus("")} />
{hosted && <DataAppHandoffDialog onStatus={setActionStatus} />}
{chartExport && <ChartExportDialog {...chartExport} resolveColor={resolveColor} onClose={() => setChartExport(null)} />}
<main
inert={editSession?.saving || undefined}
ref={editingRoot}
className={`page${snapshot.surface === "report" ? " report-page" : ""}`}
data-data-app-content={snapshot.surface}
data-dashboard-page={activeTabId}
onClick={hosted ? openAuthoredReportFollowUp : undefined}
onDoubleClick={
canEdit
? (event) => {
const target = editableTextTarget(event.currentTarget, event.target);
if (target) requestTextEdit(target);
}
: undefined
}
>
{children}
<ChartEditorHost
ref={chartEditorRef}
reviewedRows={reviewedRows}
resolveColor={resolveColor}
chartStates={chartStates}
canEdit={canEdit}
onClose={closeChartDialog}
onSave={saveChartDialog}
/>
<SourceSidebarHost
ref={sourceSidebarRef}
queries={queries}
reviewedRows={reviewedRows}
activeFilters={activeFilters}
chartStates={chartStates}
snapshot={snapshot}
/>
</main>
</div>
</DashboardAskProvider>
</DataAppBlockLayoutContext.Provider>
</DataAppContext.Provider>
);
}
SHA-256: 004aa988679e158e668b8dd8c151869e9b6106cd31eeb33de0b51fc51c107259