@malloy-publisher/sdk 0.0.227 → 0.0.229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ServerProvider-BnQo3hhW.es.js → ServerProvider-BdQK_q3C.es.js} +780 -715
- package/dist/ServerProvider-n4SO1thp.cjs.js +1 -0
- package/dist/client/api.d.ts +79 -1
- package/dist/client/index.cjs.js +1 -1
- package/dist/client/index.es.js +1 -1
- package/dist/components/Materializations/CreateEnvMaterializationDialog.d.ts +14 -0
- package/dist/components/Materializations/EnvironmentMaterializations.d.ts +7 -0
- package/dist/components/Materializations/MaterializationDetailDialog.d.ts +2 -1
- package/dist/components/Materializations/MaterializationRunsList.d.ts +5 -1
- package/dist/components/Materializations/ScheduleCard.d.ts +20 -0
- package/dist/components/Materializations/SectionLabel.d.ts +10 -0
- package/dist/components/Materializations/SetScheduleDialog.d.ts +11 -0
- package/dist/components/Materializations/TriggerChip.d.ts +10 -0
- package/dist/components/Materializations/cron.d.ts +23 -0
- package/dist/components/Materializations/index.d.ts +1 -0
- package/dist/components/Materializations/utils.d.ts +9 -0
- package/dist/components/Notebook/Notebook.d.ts +6 -1
- package/dist/components/Notebook/NotebookCell.d.ts +3 -1
- package/dist/components/RenderedResult/ResultContainer.d.ts +3 -1
- package/dist/components/RenderedResult/renderLogs.d.ts +6 -0
- package/dist/components/ResultsDialog.d.ts +3 -1
- package/dist/constants/docLinks.d.ts +6 -0
- package/dist/{core-CuGbFNhB.cjs.js → core-BJUrppec.cjs.js} +1 -1
- package/dist/{core-vVgoO8IR.es.js → core-Coi3caGs.es.js} +1 -1
- package/dist/index-DWcuaQOW.cjs.js +309 -0
- package/dist/{index-Br214Q_W.es.js → index-mbDUet0T.es.js} +34625 -25301
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +54 -52
- package/dist/utils/queryClient.d.ts +5 -0
- package/package.json +3 -1
- package/src/components/Environment/AddPackageDialog.tsx +2 -1
- package/src/components/Environment/Environment.tsx +8 -0
- package/src/components/Home/Home.tsx +5 -4
- package/src/components/Materializations/CreateEnvMaterializationDialog.tsx +176 -0
- package/src/components/Materializations/EnvironmentMaterializations.tsx +264 -0
- package/src/components/Materializations/ManifestView.tsx +2 -3
- package/src/components/Materializations/MaterializationDetailDialog.tsx +121 -95
- package/src/components/Materializations/MaterializationRunsList.tsx +37 -0
- package/src/components/Materializations/Materializations.tsx +86 -1
- package/src/components/Materializations/ScheduleCard.tsx +228 -0
- package/src/components/Materializations/SectionLabel.tsx +27 -0
- package/src/components/Materializations/SetScheduleDialog.tsx +159 -0
- package/src/components/Materializations/TriggerChip.tsx +41 -0
- package/src/components/Materializations/cron.ts +121 -0
- package/src/components/Materializations/index.ts +1 -0
- package/src/components/Materializations/utils.ts +14 -0
- package/src/components/Model/ModelCell.tsx +4 -0
- package/src/components/Notebook/Notebook.tsx +14 -1
- package/src/components/Notebook/NotebookCell.tsx +103 -2
- package/src/components/Package/Package.tsx +14 -3
- package/src/components/QueryResult/QueryResult.tsx +7 -1
- package/src/components/RenderedResult/RenderedResult.tsx +153 -51
- package/src/components/RenderedResult/ResultContainer.tsx +40 -1
- package/src/components/RenderedResult/renderLogs.spec.ts +55 -0
- package/src/components/RenderedResult/renderLogs.ts +40 -0
- package/src/components/ResultsDialog.tsx +4 -0
- package/src/constants/docLinks.ts +17 -0
- package/src/index.ts +1 -0
- package/src/utils/queryClient.ts +14 -0
- package/dist/ServerProvider-CCKhmHeT.cjs.js +0 -1
- package/dist/index-CK7FeJKR.cjs.js +0 -307
|
@@ -69,6 +69,13 @@ export interface MaterializationMetadata {
|
|
|
69
69
|
forceRefresh?: boolean;
|
|
70
70
|
sourceNames?: string[];
|
|
71
71
|
mode?: "auto" | "orchestrated";
|
|
72
|
+
/**
|
|
73
|
+
* What initiated the run. `SCHEDULER` = the standalone scheduler fired the
|
|
74
|
+
* package's `materialization.schedule` cron; `ON_DEMAND` (default) = an
|
|
75
|
+
* explicit API/UI create. Recorded on the run so a scheduled rebuild is
|
|
76
|
+
* distinguishable from a manual one.
|
|
77
|
+
*/
|
|
78
|
+
trigger?: "ON_DEMAND" | "SCHEDULER";
|
|
72
79
|
sourcesBuilt?: number;
|
|
73
80
|
sourcesReused?: number;
|
|
74
81
|
}
|
|
@@ -79,6 +86,13 @@ export function parseMetadata(
|
|
|
79
86
|
return (materialization.metadata ?? {}) as MaterializationMetadata;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/** Human label for how a run was initiated. */
|
|
90
|
+
export function triggerLabel(
|
|
91
|
+
meta: MaterializationMetadata,
|
|
92
|
+
): "Scheduled" | "Manual" {
|
|
93
|
+
return meta.trigger === "SCHEDULER" ? "Scheduled" : "Manual";
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Human-readable elapsed time between two ISO timestamps (to now if open). */
|
|
83
97
|
export function formatDuration(
|
|
84
98
|
startedAt?: string | null,
|
|
@@ -5,6 +5,7 @@ import React, { useEffect } from "react";
|
|
|
5
5
|
import { useQueryWithApiError } from "../../hooks/useQueryWithApiError";
|
|
6
6
|
import { usePublisherTheme } from "../../theme/ThemeContext";
|
|
7
7
|
import { parseResourceUri } from "../../utils/formatting";
|
|
8
|
+
import { CHART_RESULT_QUERY_OPTIONS } from "../../utils/queryClient";
|
|
8
9
|
import { highlight } from "../highlighter";
|
|
9
10
|
import ResultContainer from "../RenderedResult/ResultContainer";
|
|
10
11
|
import ResultsDialog from "../ResultsDialog";
|
|
@@ -56,6 +57,7 @@ export function ModelCell({
|
|
|
56
57
|
},
|
|
57
58
|
),
|
|
58
59
|
enabled: runOnDemand ? hasRun : true, // Execute on demand or always
|
|
60
|
+
...CHART_RESULT_QUERY_OPTIONS,
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
const { mode } = usePublisherTheme();
|
|
@@ -179,6 +181,7 @@ export function ModelCell({
|
|
|
179
181
|
result={queryData.data.result}
|
|
180
182
|
maxHeight={600}
|
|
181
183
|
maxResultSize={maxResultSize}
|
|
184
|
+
renderLogs={queryData.data.renderLogs}
|
|
182
185
|
/>
|
|
183
186
|
)}
|
|
184
187
|
</CleanMetricCard>
|
|
@@ -188,6 +191,7 @@ export function ModelCell({
|
|
|
188
191
|
open={resultsDialogOpen}
|
|
189
192
|
onClose={() => setResultsDialogOpen(false)}
|
|
190
193
|
result={queryData?.data?.result || ""}
|
|
194
|
+
renderLogs={queryData?.data?.renderLogs}
|
|
191
195
|
title={`Query: ${queryName}`}
|
|
192
196
|
/>
|
|
193
197
|
</CleanNotebookCell>
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import "@malloydata/malloy-explorer/styles.css";
|
|
2
2
|
import * as Malloy from "@malloydata/malloy-interfaces";
|
|
3
3
|
import { Box, Paper, Stack, Typography } from "@mui/material";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
MouseEvent,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
5
12
|
import { RawNotebook, Source } from "../../client";
|
|
6
13
|
import {
|
|
7
14
|
getDimensionKey,
|
|
@@ -37,6 +44,10 @@ interface NotebookProps {
|
|
|
37
44
|
maxResultSize?: number;
|
|
38
45
|
/** Optional retrieval function for semantic search filters */
|
|
39
46
|
retrievalFn?: RetrievalFunction;
|
|
47
|
+
/** Optional SPA navigation handler for links inside the notebook. When
|
|
48
|
+
* omitted, in-notebook links fall back to plain absolute anchors, so no
|
|
49
|
+
* react-router context is required to render a notebook. */
|
|
50
|
+
onNavigate?: (to: string, event?: MouseEvent) => void;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
// Requires PackageProvider
|
|
@@ -44,6 +55,7 @@ export default function Notebook({
|
|
|
44
55
|
resourceUri,
|
|
45
56
|
maxResultSize = 0,
|
|
46
57
|
retrievalFn,
|
|
58
|
+
onNavigate,
|
|
47
59
|
}: NotebookProps) {
|
|
48
60
|
const { apiClients } = useServer();
|
|
49
61
|
const {
|
|
@@ -593,6 +605,7 @@ export default function Notebook({
|
|
|
593
605
|
resourceUri={resourceUri}
|
|
594
606
|
maxResultSize={maxResultSize}
|
|
595
607
|
isExecuting={isExecuting}
|
|
608
|
+
onNavigate={onNavigate}
|
|
596
609
|
/>
|
|
597
610
|
))}
|
|
598
611
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import Markdown from "markdown-to-jsx";
|
|
19
19
|
import React, { useEffect, useState } from "react";
|
|
20
20
|
import { usePublisherTheme } from "../../theme/ThemeContext";
|
|
21
|
+
import { parseResourceUri } from "../../utils/formatting";
|
|
21
22
|
import { highlight } from "../highlighter";
|
|
22
23
|
import { ModelExplorerDialog } from "../Model/ModelExplorerDialog";
|
|
23
24
|
import { createEmbeddedQueryResult } from "../QueryResult/QueryResult";
|
|
@@ -36,6 +37,83 @@ interface NotebookCellProps {
|
|
|
36
37
|
index: number;
|
|
37
38
|
maxResultSize?: number;
|
|
38
39
|
isExecuting?: boolean;
|
|
40
|
+
onNavigate?: (to: string, event?: React.MouseEvent) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface NotebookMarkdownLinkProps {
|
|
44
|
+
href?: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
children?: React.ReactNode;
|
|
47
|
+
envName: string;
|
|
48
|
+
pkgName: string;
|
|
49
|
+
sourceDir: string;
|
|
50
|
+
onNavigate?: (to: string, event?: React.MouseEvent) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Links inside a rendered notebook/README are authored relative to the source
|
|
54
|
+
// file (e.g. `spielberg.malloynb`). A plain browser anchor resolves them
|
|
55
|
+
// against the current page URL, which drops the package segment for a README
|
|
56
|
+
// shown at the package route. Resolve relative links against the source file's
|
|
57
|
+
// own directory instead and route them through the SPA. External, hash, and
|
|
58
|
+
// absolute links stay as normal anchors.
|
|
59
|
+
function NotebookMarkdownLink({
|
|
60
|
+
href,
|
|
61
|
+
title,
|
|
62
|
+
children,
|
|
63
|
+
envName,
|
|
64
|
+
pkgName,
|
|
65
|
+
sourceDir,
|
|
66
|
+
onNavigate,
|
|
67
|
+
}: NotebookMarkdownLinkProps) {
|
|
68
|
+
const hasScheme = !!href && /^[a-z][a-z0-9+.-]*:/i.test(href);
|
|
69
|
+
const isInternalRelative =
|
|
70
|
+
!!href && !hasScheme && !href.startsWith("/") && !href.startsWith("#");
|
|
71
|
+
|
|
72
|
+
if (!isInternalRelative || !href) {
|
|
73
|
+
// External, absolute, or hash link: render a normal anchor. Only allow
|
|
74
|
+
// safe schemes so a crafted `javascript:`/`data:` href in a package
|
|
75
|
+
// README (packages can come from untrusted git/S3 sources) cannot run.
|
|
76
|
+
const isUnsafeScheme =
|
|
77
|
+
hasScheme && !/^(https?|mailto|tel):/i.test(href ?? "");
|
|
78
|
+
return (
|
|
79
|
+
<a
|
|
80
|
+
href={isUnsafeScheme ? undefined : href}
|
|
81
|
+
title={title}
|
|
82
|
+
{...(hasScheme
|
|
83
|
+
? { target: "_blank", rel: "noopener noreferrer" }
|
|
84
|
+
: {})}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</a>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve the relative href against the source file's directory (empty for a
|
|
92
|
+
// package-root README) via a dummy origin, which normalizes any `./`/`../`
|
|
93
|
+
// and clamps escapes at the package root, then prefix the package path.
|
|
94
|
+
const resolved = new URL(href, `https://malloy.invalid/${sourceDir}`);
|
|
95
|
+
const packageRelative =
|
|
96
|
+
resolved.pathname.slice(1) + resolved.search + resolved.hash;
|
|
97
|
+
const to = `/${envName}/${pkgName}/${packageRelative}`;
|
|
98
|
+
// With a host router (onNavigate) intercept and route through the SPA.
|
|
99
|
+
// Without one (router-free SDK embedding) the absolute href navigates on
|
|
100
|
+
// its own, so rendering a notebook needs no react-router context.
|
|
101
|
+
return (
|
|
102
|
+
<a
|
|
103
|
+
href={to}
|
|
104
|
+
title={title}
|
|
105
|
+
onClick={
|
|
106
|
+
onNavigate
|
|
107
|
+
? (event) => {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
onNavigate(to, event);
|
|
110
|
+
}
|
|
111
|
+
: undefined
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</a>
|
|
116
|
+
);
|
|
39
117
|
}
|
|
40
118
|
|
|
41
119
|
export function NotebookCell({
|
|
@@ -46,6 +124,7 @@ export function NotebookCell({
|
|
|
46
124
|
index,
|
|
47
125
|
maxResultSize,
|
|
48
126
|
isExecuting,
|
|
127
|
+
onNavigate,
|
|
49
128
|
}: NotebookCellProps) {
|
|
50
129
|
const [codeDialogOpen, setCodeDialogOpen] = React.useState<boolean>(false);
|
|
51
130
|
const [embeddingDialogOpen, setEmbeddingDialogOpen] =
|
|
@@ -61,6 +140,28 @@ export function NotebookCell({
|
|
|
61
140
|
|
|
62
141
|
const [copyMessage, setCopyMessage] = useState("");
|
|
63
142
|
|
|
143
|
+
const { environmentName, packageName, modelPath } =
|
|
144
|
+
parseResourceUri(resourceUri);
|
|
145
|
+
// Directory of the source file within the package (empty for a package-root
|
|
146
|
+
// README), used to resolve relative links the author wrote against it.
|
|
147
|
+
const sourceDir =
|
|
148
|
+
modelPath && modelPath.includes("/")
|
|
149
|
+
? modelPath.slice(0, modelPath.lastIndexOf("/") + 1)
|
|
150
|
+
: "";
|
|
151
|
+
const markdownOptions = {
|
|
152
|
+
overrides: {
|
|
153
|
+
a: {
|
|
154
|
+
component: NotebookMarkdownLink,
|
|
155
|
+
props: {
|
|
156
|
+
envName: environmentName,
|
|
157
|
+
pkgName: packageName,
|
|
158
|
+
sourceDir,
|
|
159
|
+
onNavigate,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
64
165
|
// Regex to extract imported names from import statements
|
|
65
166
|
const IMPORT_NAMES_REGEX = /import\s*\{([^}]+)\}\s*from\s*['"`][^'"`]+['"`]/;
|
|
66
167
|
|
|
@@ -190,7 +291,7 @@ export function NotebookCell({
|
|
|
190
291
|
alignItems="flex-start"
|
|
191
292
|
justifyContent="space-between"
|
|
192
293
|
>
|
|
193
|
-
<Markdown>{cell.text}</Markdown>
|
|
294
|
+
<Markdown options={markdownOptions}>{cell.text}</Markdown>
|
|
194
295
|
<Tooltip title="Click to copy link">
|
|
195
296
|
<LinkOutlinedIcon
|
|
196
297
|
sx={{
|
|
@@ -204,7 +305,7 @@ export function NotebookCell({
|
|
|
204
305
|
</Tooltip>
|
|
205
306
|
</Stack>
|
|
206
307
|
) : (
|
|
207
|
-
<Markdown>{cell.text}</Markdown>
|
|
308
|
+
<Markdown options={markdownOptions}>{cell.text}</Markdown>
|
|
208
309
|
)}
|
|
209
310
|
<Snackbar
|
|
210
311
|
open={copyMessage !== ""}
|
|
@@ -215,7 +215,12 @@ export default function Package({
|
|
|
215
215
|
icon={<ContentTypeIcon type="report" />}
|
|
216
216
|
tint={MALLOY_BRAND.teal}
|
|
217
217
|
label={notebook.path}
|
|
218
|
-
onClick={(event) =>
|
|
218
|
+
onClick={(event) =>
|
|
219
|
+
onClick(
|
|
220
|
+
`/${environmentName}/${packageName}/${notebook.path}`,
|
|
221
|
+
event,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
219
224
|
/>
|
|
220
225
|
))}
|
|
221
226
|
{notebooks.length === 0 && <EmptyRow label="No notebooks" />}
|
|
@@ -248,7 +253,7 @@ export default function Package({
|
|
|
248
253
|
// The `pages/` prefix lets the router branch
|
|
249
254
|
// off the existing model-path catch-all.
|
|
250
255
|
onClickPackageFile(
|
|
251
|
-
|
|
256
|
+
`/${environmentName}/${packageName}/pages/${page.path}`,
|
|
252
257
|
event,
|
|
253
258
|
);
|
|
254
259
|
} else {
|
|
@@ -293,7 +298,12 @@ export default function Package({
|
|
|
293
298
|
icon={<ContentTypeIcon type="model" />}
|
|
294
299
|
tint={MALLOY_BRAND.orange}
|
|
295
300
|
label={model.path}
|
|
296
|
-
onClick={(event) =>
|
|
301
|
+
onClick={(event) =>
|
|
302
|
+
onClick(
|
|
303
|
+
`/${environmentName}/${packageName}/${model.path}`,
|
|
304
|
+
event,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
297
307
|
/>
|
|
298
308
|
))}
|
|
299
309
|
{models.length === 0 && <EmptyRow label="No models" />}
|
|
@@ -332,6 +342,7 @@ export default function Package({
|
|
|
332
342
|
<Notebook
|
|
333
343
|
resourceUri={readmeResourceUri}
|
|
334
344
|
retrievalFn={retrievalFn}
|
|
345
|
+
onNavigate={onClick}
|
|
335
346
|
/>
|
|
336
347
|
</Box>
|
|
337
348
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Suspense } from "react";
|
|
2
2
|
import { useQueryWithApiError } from "../../hooks/useQueryWithApiError";
|
|
3
3
|
import { parseResourceUri } from "../../utils/formatting";
|
|
4
|
+
import { CHART_RESULT_QUERY_OPTIONS } from "../../utils/queryClient";
|
|
4
5
|
import { ApiErrorDisplay } from "../ApiErrorDisplay";
|
|
5
6
|
import { Loading } from "../Loading";
|
|
6
7
|
import ResultContainer from "../RenderedResult/ResultContainer";
|
|
@@ -90,6 +91,7 @@ export default function QueryResult({
|
|
|
90
91
|
versionId: versionId,
|
|
91
92
|
},
|
|
92
93
|
),
|
|
94
|
+
...CHART_RESULT_QUERY_OPTIONS,
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
return (
|
|
@@ -99,7 +101,11 @@ export default function QueryResult({
|
|
|
99
101
|
)}
|
|
100
102
|
{isSuccess && (
|
|
101
103
|
<Suspense fallback={<div>Loading...</div>}>
|
|
102
|
-
<ResultContainer
|
|
104
|
+
<ResultContainer
|
|
105
|
+
result={data.data.result}
|
|
106
|
+
maxHeight={height}
|
|
107
|
+
renderLogs={data.data.renderLogs}
|
|
108
|
+
/>
|
|
103
109
|
</Suspense>
|
|
104
110
|
)}
|
|
105
111
|
{isError && (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import React, { Suspense, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import React, { Suspense, useEffect, useLayoutEffect, useRef } from "react";
|
|
3
3
|
import { buildMalloyExplicitTheme } from "../../theme/buildMalloyExplicitTheme";
|
|
4
4
|
import { buildTableCssVars } from "../../theme/buildTableCssVars";
|
|
5
5
|
import { buildVegaThemeOverride } from "../../theme/buildVegaThemeOverride";
|
|
@@ -20,6 +20,10 @@ interface MalloyVizHandle {
|
|
|
20
20
|
setResult: (result: unknown) => void;
|
|
21
21
|
render: (element: HTMLElement) => void;
|
|
22
22
|
remove: () => void;
|
|
23
|
+
// Fires once the chart has painted (or immediately if it already has). We
|
|
24
|
+
// use it to swap a freshly-rendered chart in only when it's ready, so the
|
|
25
|
+
// previously-painted chart never blanks out mid-render.
|
|
26
|
+
onReady: (callback: () => void) => void;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
declare global {
|
|
@@ -67,6 +71,13 @@ const createRenderer = async (
|
|
|
67
71
|
return renderer.createViz() as MalloyVizHandle;
|
|
68
72
|
};
|
|
69
73
|
|
|
74
|
+
// Warm the renderer chunk as soon as this module loads so the first chart
|
|
75
|
+
// paint doesn't have to wait on the dynamic import resolving (the async
|
|
76
|
+
// import is what widened the clear-then-repaint gap into a visible flicker).
|
|
77
|
+
if (typeof window !== "undefined") {
|
|
78
|
+
void import("@malloydata/render");
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Pull a per-chart Theme override out of a parsed Malloy result by reading
|
|
72
83
|
* `# theme.*` annotations on the model and the query. Returns `undefined`
|
|
@@ -243,39 +254,59 @@ function RenderedResultInner({
|
|
|
243
254
|
}: RenderedResultProps) {
|
|
244
255
|
const ref = useRef<HTMLDivElement>(null);
|
|
245
256
|
const hasMeasuredRef = useRef(false);
|
|
257
|
+
// The chart currently painted into the container, held across renders. A
|
|
258
|
+
// new render paints into a fresh offscreen stage and only swaps in (and
|
|
259
|
+
// disposes this one) once it's ready, so the container never blanks between
|
|
260
|
+
// charts. That up-front clear + cleanup dispose was the flicker.
|
|
261
|
+
const liveRef = useRef<{ viz: MalloyVizHandle; node: HTMLElement } | null>(
|
|
262
|
+
null,
|
|
263
|
+
);
|
|
264
|
+
// Bumped on every render-effect run and on unmount. A slow async render that
|
|
265
|
+
// resolves after a newer one has started (or after unmount) checks this and
|
|
266
|
+
// bails, so overlapping renders can't leave two charts or leak a viz.
|
|
267
|
+
const renderGenRef = useRef(0);
|
|
246
268
|
const { theme: baseTheme, layers, mode } = usePublisherTheme();
|
|
247
269
|
|
|
270
|
+
// Dispose the last live viz on unmount only. Deliberately NOT done in the
|
|
271
|
+
// render effect's cleanup: a re-run must keep the old chart until the new
|
|
272
|
+
// one has painted, and the new render disposes it during the swap.
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
return () => {
|
|
275
|
+
renderGenRef.current += 1;
|
|
276
|
+
liveRef.current?.viz.remove();
|
|
277
|
+
liveRef.current = null;
|
|
278
|
+
};
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
248
281
|
useLayoutEffect(() => {
|
|
249
282
|
if (!ref.current || !result) return;
|
|
250
283
|
|
|
251
284
|
injectRendererOverrides();
|
|
252
285
|
|
|
253
|
-
let isMounted = true;
|
|
254
|
-
// Track the active viz instance so the cleanup can dispose it even
|
|
255
|
-
// when unmount races with the async setup (see bug #3 from the code
|
|
256
|
-
// review: viz constructed but never `remove()`'d on rapid navigation).
|
|
257
|
-
let viz: MalloyVizHandle | undefined;
|
|
258
286
|
const element = ref.current;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// the per-chart override resolves below, so per-chart annotations
|
|
267
|
-
// affect both Vega and table styles.
|
|
268
|
-
applyTableCssVars(element, baseTheme);
|
|
269
|
-
|
|
270
|
-
hasMeasuredRef.current = false;
|
|
271
|
-
|
|
287
|
+
const myGen = (renderGenRef.current += 1);
|
|
288
|
+
let cancelled = false;
|
|
289
|
+
const isCurrent = () => !cancelled && myGen === renderGenRef.current;
|
|
290
|
+
// Created inside the async body; hoisted so cleanup can tear them down
|
|
291
|
+
// if this render is superseded before it swaps itself into `liveRef`.
|
|
292
|
+
let stage: HTMLDivElement | undefined;
|
|
293
|
+
let viz: MalloyVizHandle | undefined;
|
|
272
294
|
let observer: MutationObserver | null = null;
|
|
273
295
|
let measureTimeout: NodeJS.Timeout | null = null;
|
|
296
|
+
// Safety net so a render that never signals ready (an async renderer
|
|
297
|
+
// error that only reaches onError) can't leave a previous chart showing
|
|
298
|
+
// stale data forever; see the setTimeout below.
|
|
299
|
+
let readyFallback: ReturnType<typeof setTimeout> | null = null;
|
|
300
|
+
|
|
301
|
+
hasMeasuredRef.current = false;
|
|
274
302
|
|
|
275
|
-
|
|
276
|
-
|
|
303
|
+
// Measure the rendered chart's natural height off `root` (the stage that
|
|
304
|
+
// wraps the renderer output) and report it up. Same grandchild/dashboard
|
|
305
|
+
// HACK as before, just anchored on the stage wrapper.
|
|
306
|
+
const measureRenderedSize = (root: HTMLElement) => {
|
|
307
|
+
if (hasMeasuredRef.current || cancelled || !root.firstElementChild)
|
|
277
308
|
return;
|
|
278
|
-
const child =
|
|
309
|
+
const child = root.firstElementChild as HTMLElement;
|
|
279
310
|
const grandchild = child.firstElementChild as HTMLElement;
|
|
280
311
|
if (!grandchild) return;
|
|
281
312
|
const greatgrandchild = grandchild.firstElementChild as HTMLElement;
|
|
@@ -315,52 +346,123 @@ function RenderedResultInner({
|
|
|
315
346
|
? resolveTheme([...layers, perChart], mode)
|
|
316
347
|
: baseTheme;
|
|
317
348
|
|
|
318
|
-
|
|
319
|
-
if (isMounted) applyTableCssVars(element, effectiveTheme);
|
|
349
|
+
if (!isCurrent()) return;
|
|
320
350
|
|
|
321
351
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
352
|
+
viz = await createRenderer(effectiveTheme, onDrill);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.error("Failed to create renderer:", error);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!isCurrent()) {
|
|
358
|
+
// Superseded during the dynamic import / construction.
|
|
359
|
+
viz.remove();
|
|
360
|
+
viz = undefined;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Render into a fresh stage appended to the container. While a
|
|
365
|
+
// previous chart is still on screen, keep the stage laid out (so the
|
|
366
|
+
// fill chart actually measures a size and `onReady` fires) but hidden
|
|
367
|
+
// and overlaid; reveal it and drop the old chart only once painted.
|
|
368
|
+
const previous = liveRef.current;
|
|
369
|
+
stage = document.createElement("div");
|
|
370
|
+
stage.style.width = "100%";
|
|
371
|
+
stage.style.height = "100%";
|
|
372
|
+
// Theme the chart's table/dashboard chrome on the stage itself, not on
|
|
373
|
+
// the shared container: during a swap the outgoing chart is still a
|
|
374
|
+
// child of the container, so writing the new per-chart CSS vars there
|
|
375
|
+
// would repaint the old chart's chrome to the new theme before it is
|
|
376
|
+
// swapped out. Scoping the vars to this stage keeps each chart stable.
|
|
377
|
+
applyTableCssVars(stage, effectiveTheme);
|
|
378
|
+
if (previous) {
|
|
379
|
+
element.style.position = "relative";
|
|
380
|
+
stage.style.position = "absolute";
|
|
381
|
+
stage.style.inset = "0";
|
|
382
|
+
stage.style.visibility = "hidden";
|
|
383
|
+
}
|
|
384
|
+
element.appendChild(stage);
|
|
385
|
+
|
|
386
|
+
// Fallback measurement if `onReady` never fires: settle on DOM
|
|
387
|
+
// mutations, then measure once.
|
|
388
|
+
const stageNode = stage;
|
|
389
|
+
observer = new MutationObserver(() => {
|
|
390
|
+
if (measureTimeout) clearTimeout(measureTimeout);
|
|
391
|
+
measureTimeout = setTimeout(() => {
|
|
392
|
+
measureRenderedSize(stageNode);
|
|
393
|
+
observer?.disconnect();
|
|
394
|
+
}, 100);
|
|
395
|
+
});
|
|
396
|
+
observer.observe(stage, {
|
|
397
|
+
childList: true,
|
|
398
|
+
subtree: true,
|
|
399
|
+
attributes: true,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const activeViz = viz;
|
|
403
|
+
let promoted = false;
|
|
404
|
+
const promote = () => {
|
|
405
|
+
if (promoted || !isCurrent()) return;
|
|
406
|
+
promoted = true;
|
|
407
|
+
if (readyFallback) {
|
|
408
|
+
clearTimeout(readyFallback);
|
|
409
|
+
readyFallback = null;
|
|
329
410
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
});
|
|
411
|
+
// The new chart has painted; drop the outgoing one now.
|
|
412
|
+
if (previous) {
|
|
413
|
+
previous.viz.remove();
|
|
414
|
+
if (previous.node.parentNode === element) {
|
|
415
|
+
element.removeChild(previous.node);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
stageNode.style.position = "";
|
|
419
|
+
stageNode.style.inset = "";
|
|
420
|
+
stageNode.style.visibility = "";
|
|
421
|
+
element.style.position = "";
|
|
422
|
+
liveRef.current = { viz: activeViz, node: stageNode };
|
|
423
|
+
measureRenderedSize(stageNode);
|
|
424
|
+
};
|
|
345
425
|
|
|
426
|
+
try {
|
|
346
427
|
// The renderer accepts a Malloy Result; we don't import that type
|
|
347
428
|
// in the SDK to avoid pinning to the malloy core types here.
|
|
348
429
|
viz.setResult(parsed);
|
|
349
|
-
viz.render(
|
|
430
|
+
viz.render(stage);
|
|
431
|
+
viz.onReady(promote);
|
|
432
|
+
// If onReady never fires (an async render error that only reaches
|
|
433
|
+
// the renderer's onError), force the swap after a bounded wait so
|
|
434
|
+
// the outcome becomes visible instead of the previous chart
|
|
435
|
+
// lingering with stale data indefinitely.
|
|
436
|
+
readyFallback = setTimeout(promote, 10000);
|
|
350
437
|
} catch (error) {
|
|
351
438
|
console.error("Error rendering visualization:", error);
|
|
352
439
|
observer?.disconnect();
|
|
353
|
-
viz
|
|
440
|
+
viz.remove();
|
|
354
441
|
viz = undefined;
|
|
442
|
+
if (stageNode.parentNode === element) {
|
|
443
|
+
element.removeChild(stageNode);
|
|
444
|
+
}
|
|
355
445
|
}
|
|
356
446
|
})();
|
|
357
447
|
|
|
358
448
|
return () => {
|
|
359
|
-
|
|
449
|
+
cancelled = true;
|
|
360
450
|
observer?.disconnect();
|
|
361
451
|
if (measureTimeout) clearTimeout(measureTimeout);
|
|
362
|
-
|
|
363
|
-
|
|
452
|
+
if (readyFallback) clearTimeout(readyFallback);
|
|
453
|
+
// If this render built a stage but never swapped it into `liveRef`
|
|
454
|
+
// (superseded, or unmounted mid-render), tear it down so it can't
|
|
455
|
+
// leak a viz or leave an orphan node behind.
|
|
456
|
+
if (stage && liveRef.current?.node !== stage) {
|
|
457
|
+
viz?.remove();
|
|
458
|
+
if (stage.parentNode === element) {
|
|
459
|
+
element.removeChild(stage);
|
|
460
|
+
}
|
|
461
|
+
// This render may have set the container position:relative for its
|
|
462
|
+
// overlay stage but never promoted; reset it so no stray relative
|
|
463
|
+
// lingers on the container.
|
|
464
|
+
element.style.position = "";
|
|
465
|
+
}
|
|
364
466
|
};
|
|
365
467
|
}, [result, onDrill, onSizeChange, baseTheme, layers, mode]);
|
|
366
468
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Warning } from "@mui/icons-material";
|
|
2
|
-
import
|
|
2
|
+
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
|
3
|
+
import { Box, Button, IconButton, Tooltip, Typography } from "@mui/material";
|
|
3
4
|
import { lazy, Suspense, useRef, useState } from "react";
|
|
5
|
+
import { LogMessage } from "../../client";
|
|
4
6
|
import { Loading } from "../Loading";
|
|
7
|
+
import { summarizeRenderLogs } from "./renderLogs";
|
|
5
8
|
|
|
6
9
|
const RenderedResult = lazy(() => import("../RenderedResult/RenderedResult"));
|
|
7
10
|
|
|
@@ -12,6 +15,9 @@ interface ResultContainerProps {
|
|
|
12
15
|
// this is to prevent performance issues with large results.
|
|
13
16
|
// the default is 0, which means no warning will be shown.
|
|
14
17
|
maxResultSize?: number;
|
|
18
|
+
// Render tag findings from the query response. Callers whose result did not
|
|
19
|
+
// come from a query response have none to pass.
|
|
20
|
+
renderLogs?: LogMessage[];
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
// ResultContainer is a component that renders a result, with a toggle button to expand/collapse the result.
|
|
@@ -22,10 +28,12 @@ export default function ResultContainer({
|
|
|
22
28
|
result,
|
|
23
29
|
maxHeight,
|
|
24
30
|
maxResultSize = 0,
|
|
31
|
+
renderLogs,
|
|
25
32
|
}: ResultContainerProps) {
|
|
26
33
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
27
34
|
const [measuredHeight, setMeasuredHeight] = useState(maxHeight);
|
|
28
35
|
const [userAcknowledged, setUserAcknowledged] = useState(false);
|
|
36
|
+
const renderLogSummary = summarizeRenderLogs(renderLogs);
|
|
29
37
|
|
|
30
38
|
if (!result) {
|
|
31
39
|
return null;
|
|
@@ -90,6 +98,37 @@ export default function ResultContainer({
|
|
|
90
98
|
/>
|
|
91
99
|
</Suspense>
|
|
92
100
|
)}
|
|
101
|
+
{renderLogSummary && (
|
|
102
|
+
// Overlaid rather than stacked, so the note cannot change the height
|
|
103
|
+
// the container just measured. The button is what makes the message
|
|
104
|
+
// reachable: an icon alone is aria-hidden and cannot take focus.
|
|
105
|
+
<Box sx={{ position: "absolute", top: 4, right: 4, zIndex: 1 }}>
|
|
106
|
+
<Tooltip
|
|
107
|
+
title={renderLogSummary.title}
|
|
108
|
+
slotProps={{ tooltip: { sx: { whiteSpace: "pre-line" } } }}
|
|
109
|
+
>
|
|
110
|
+
<IconButton
|
|
111
|
+
size="small"
|
|
112
|
+
aria-label="Render tag warnings"
|
|
113
|
+
sx={{
|
|
114
|
+
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
|
115
|
+
"&:hover": {
|
|
116
|
+
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
117
|
+
},
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<InfoOutlinedIcon
|
|
121
|
+
fontSize="small"
|
|
122
|
+
color={
|
|
123
|
+
renderLogSummary.severity === "error"
|
|
124
|
+
? "error"
|
|
125
|
+
: "warning"
|
|
126
|
+
}
|
|
127
|
+
/>
|
|
128
|
+
</IconButton>
|
|
129
|
+
</Tooltip>
|
|
130
|
+
</Box>
|
|
131
|
+
)}
|
|
93
132
|
</Box>
|
|
94
133
|
);
|
|
95
134
|
}
|