@marimo-team/frontend 0.23.1-dev9 → 0.23.2-dev1
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/assets/{JsonOutput-BY31ccA7.js → JsonOutput-CavtrueA.js} +1 -1
- package/dist/assets/{MarimoErrorOutput--Yd2Aw0J.js → MarimoErrorOutput-Bmp8DLLo.js} +1 -1
- package/dist/assets/RenderHTML-CM3WMmA8.js +1 -0
- package/dist/assets/{add-connection-dialog-CjvNOKgb.js → add-connection-dialog-BGZvJkor.js} +1 -1
- package/dist/assets/{agent-panel-C24uwabG.js → agent-panel-BvL9Lu9c.js} +1 -1
- package/dist/assets/{cell-editor-zW0u82sK.js → cell-editor-B40o_zx_.js} +1 -1
- package/dist/assets/{chat-display-DsHMZa9F.js → chat-display-M_nvYuHH.js} +1 -1
- package/dist/assets/{chat-panel-o9D3upnX.js → chat-panel-BMOW93uQ.js} +1 -1
- package/dist/assets/{chat-ui-BYS03y86.js → chat-ui-DyeimpVh.js} +1 -1
- package/dist/assets/{column-preview-Dwv5a_zE.js → column-preview-AfcgbFG_.js} +1 -1
- package/dist/assets/{command-palette-BYbKGSF3.js → command-palette-BgvdyU3B.js} +1 -1
- package/dist/assets/{documentation-panel-CA2pWMgB.js → documentation-panel-DUPcsi8P.js} +1 -1
- package/dist/assets/{edit-page-CMUN3ESy.js → edit-page-DD4uEDmX.js} +4 -4
- package/dist/assets/{error-panel-CbqfK1HJ.js → error-panel-DQOeSv5-.js} +1 -1
- package/dist/assets/{file-explorer-panel-CbS8z-JR.js → file-explorer-panel-B67zjs2X.js} +1 -1
- package/dist/assets/{form-DLyXacSF.js → form-BJ6VFU8l.js} +1 -1
- package/dist/assets/{hooks-kZJc1iBf.js → hooks-DvwShzDb.js} +1 -1
- package/dist/assets/index-ThWddW3f.js +42 -0
- package/dist/assets/{layout-tmN-U1zs.js → layout-erv8pLIP.js} +1 -1
- package/dist/assets/{panels-CLfdzLPR.js → panels-1u-RE72f.js} +1 -1
- package/dist/assets/{run-page-DPuH6QY4.js → run-page-DfWH_1mz.js} +1 -1
- package/dist/assets/{scratchpad-panel-BsMm0GQP.js → scratchpad-panel-CnaiXtoJ.js} +1 -1
- package/dist/assets/{session-panel-CTDzGShO.js → session-panel-C68GBFwH.js} +1 -1
- package/dist/assets/{snippets-panel-CWof0wHk.js → snippets-panel-BmIdR0lc.js} +1 -1
- package/dist/assets/state-D1n-olwf.js +3 -0
- package/dist/assets/{useNotebookActions-DHBEqrc_.js → useNotebookActions-Ch1o32Jw.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +4 -4
- package/src/core/islands/__tests__/bridge.test.ts +2 -12
- package/src/core/islands/__tests__/islands-harness.test.ts +348 -0
- package/src/core/islands/__tests__/parse.test.ts +466 -24
- package/src/core/islands/__tests__/test-utils.tsx +263 -0
- package/src/core/islands/bootstrap.ts +265 -0
- package/src/core/islands/bridge.ts +154 -75
- package/src/core/islands/components/IslandControls.tsx +103 -0
- package/src/core/islands/components/__tests__/IslandControls.test.tsx +185 -0
- package/src/core/islands/components/__tests__/useIslandControls.test.ts +208 -0
- package/src/core/islands/components/output-wrapper.tsx +76 -93
- package/src/core/islands/components/useIslandControls.ts +60 -0
- package/src/core/islands/components/web-components.tsx +168 -40
- package/src/core/islands/constants.ts +28 -0
- package/src/core/islands/main.ts +7 -205
- package/src/core/islands/parse.ts +73 -26
- package/src/core/islands/worker-factory.ts +86 -0
- package/src/plugins/core/RenderHTML.tsx +9 -0
- package/src/plugins/core/__test__/RenderHTML.test.ts +27 -0
- package/src/plugins/core/__test__/trusted-url.test.ts +48 -0
- package/src/plugins/core/registerReactComponent.tsx +11 -8
- package/src/plugins/core/trusted-url.ts +20 -0
- package/src/plugins/impl/ButtonPlugin.tsx +4 -6
- package/src/plugins/impl/CodeEditorPlugin.tsx +15 -18
- package/src/plugins/impl/DataEditorPlugin.tsx +8 -14
- package/src/plugins/impl/DataTablePlugin.tsx +8 -9
- package/src/plugins/impl/FileUploadPlugin.tsx +39 -43
- package/src/plugins/impl/FormPlugin.tsx +2 -6
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +27 -1
- package/src/plugins/impl/anywidget/widget-binding.ts +13 -0
- package/src/plugins/impl/chat/ChatPlugin.tsx +17 -20
- package/src/plugins/impl/data-explorer/DataExplorerPlugin.tsx +5 -8
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +21 -0
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +119 -0
- package/src/plugins/impl/panel/PanelPlugin.tsx +31 -10
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +60 -0
- package/src/plugins/impl/plotly/__tests__/PlotlyPlugin.test.tsx +50 -0
- package/src/plugins/impl/plotly/__tests__/selection.test.ts +82 -0
- package/src/plugins/impl/plotly/selection.ts +62 -3
- package/src/plugins/impl/vega/VegaPlugin.tsx +5 -8
- package/src/plugins/layout/NavigationMenuPlugin.tsx +2 -6
- package/dist/assets/RenderHTML-CbuarQqA.js +0 -1
- package/dist/assets/index-Bm25ctN7.js +0 -42
- package/dist/assets/state-BvnlMKdT.js +0 -3
|
@@ -3,7 +3,6 @@ import "../vega/vega.css";
|
|
|
3
3
|
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
6
|
import { createPlugin } from "@/plugins/core/builder";
|
|
8
7
|
import type { DataExplorerState } from "./ConnectedDataExplorerComponent";
|
|
9
8
|
|
|
@@ -21,11 +20,9 @@ export const DataExplorerPlugin = createPlugin<DataExplorerState>(
|
|
|
21
20
|
}),
|
|
22
21
|
)
|
|
23
22
|
.renderer((props) => (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/>
|
|
30
|
-
</TooltipProvider>
|
|
23
|
+
<LazyDataExplorerComponent
|
|
24
|
+
{...props.data}
|
|
25
|
+
value={props.value}
|
|
26
|
+
setValue={props.setValue}
|
|
27
|
+
/>
|
|
31
28
|
));
|
|
@@ -5,12 +5,14 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { useEventListener } from "@/hooks/useEventListener";
|
|
7
7
|
import { createPlugin } from "@/plugins/core/builder";
|
|
8
|
+
import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
8
9
|
import { MODEL_MANAGER, type Model } from "@/plugins/impl/anywidget/model";
|
|
9
10
|
import type { ModelState, WidgetModelId } from "@/plugins/impl/anywidget/types";
|
|
10
11
|
import type { IPluginProps } from "@/plugins/types";
|
|
11
12
|
import { downloadBlob } from "@/utils/download";
|
|
12
13
|
import { Logger } from "@/utils/Logger";
|
|
13
14
|
import { MplCommWebSocket } from "./mpl-websocket-shim";
|
|
15
|
+
import { Functions } from "@/utils/functions";
|
|
14
16
|
|
|
15
17
|
const MPL_SCOPE_CLASS = "mpl-interactive-figure";
|
|
16
18
|
|
|
@@ -73,6 +75,11 @@ async function ensureMplJs(jsUrl: string): Promise<void> {
|
|
|
73
75
|
if (window.mpl) {
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
78
|
+
if (!isTrustedVirtualFileUrl(jsUrl)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Refusing to load mpl.js from untrusted URL: ${String(jsUrl)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
76
83
|
if (mplJsLoading) {
|
|
77
84
|
return mplJsLoading;
|
|
78
85
|
}
|
|
@@ -148,6 +155,12 @@ function patchToolbarImages(
|
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
function injectCss(container: HTMLElement, cssUrl: string): () => void {
|
|
158
|
+
if (!isTrustedVirtualFileUrl(cssUrl)) {
|
|
159
|
+
Logger.error(
|
|
160
|
+
`Refusing to load mpl CSS from untrusted URL: ${String(cssUrl)}`,
|
|
161
|
+
);
|
|
162
|
+
return Functions.NOOP;
|
|
163
|
+
}
|
|
151
164
|
const link = document.createElement("link");
|
|
152
165
|
link.rel = "stylesheet";
|
|
153
166
|
link.href = cssUrl;
|
|
@@ -307,3 +320,11 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
307
320
|
// Must match _MPL_SCOPE in from_mpl_interactive.py
|
|
308
321
|
return <div ref={containerRef} className={MPL_SCOPE_CLASS} />;
|
|
309
322
|
};
|
|
323
|
+
|
|
324
|
+
export const visibleForTesting = {
|
|
325
|
+
ensureMplJs,
|
|
326
|
+
injectCss,
|
|
327
|
+
resetMplJsLoading: () => {
|
|
328
|
+
mplJsLoading = null;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { visibleForTesting } from "../MplInteractivePlugin";
|
|
5
|
+
|
|
6
|
+
const { ensureMplJs, injectCss, resetMplJsLoading } = visibleForTesting;
|
|
7
|
+
|
|
8
|
+
describe("MplInteractivePlugin URL validation", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Reset module-level script-loading state and any stubs.
|
|
11
|
+
delete (window as { mpl?: unknown }).mpl;
|
|
12
|
+
resetMplJsLoading();
|
|
13
|
+
// Remove any scripts the tests added to document.head.
|
|
14
|
+
for (const el of document.head.querySelectorAll(
|
|
15
|
+
"script[data-test-mpl],link[data-test-mpl]",
|
|
16
|
+
)) {
|
|
17
|
+
el.remove();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("ensureMplJs", () => {
|
|
26
|
+
it("rejects the PoC attack URL without creating a <script>", async () => {
|
|
27
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
28
|
+
await expect(ensureMplJs("http://127.0.0.1:8820/poc.js")).rejects.toThrow(
|
|
29
|
+
/untrusted/i,
|
|
30
|
+
);
|
|
31
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
"https://evil.example.com/x.js",
|
|
36
|
+
"//evil.example.com/x.js",
|
|
37
|
+
"javascript:alert(1)",
|
|
38
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
39
|
+
"./@file/x.js?redirect=http://evil.com",
|
|
40
|
+
])("rejects %s", async (url) => {
|
|
41
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
42
|
+
await expect(ensureMplJs(url)).rejects.toThrow(/untrusted/i);
|
|
43
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is a no-op when window.mpl is already present", async () => {
|
|
47
|
+
(window as { mpl?: unknown }).mpl = {};
|
|
48
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
49
|
+
// Even a malicious URL should be ignored — short-circuit happens first.
|
|
50
|
+
await expect(
|
|
51
|
+
ensureMplJs("http://evil.example.com/x.js"),
|
|
52
|
+
).resolves.toBeUndefined();
|
|
53
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("creates a <script src> for a trusted virtual file URL", async () => {
|
|
57
|
+
const appendSpy = vi
|
|
58
|
+
.spyOn(document.head, "append")
|
|
59
|
+
.mockImplementation((...nodes) => {
|
|
60
|
+
// Simulate a successful load so ensureMplJs resolves.
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
if (node instanceof HTMLScriptElement) {
|
|
63
|
+
queueMicrotask(() => node.onload?.(new Event("load")));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await expect(ensureMplJs("./@file/123-mpl.js")).resolves.toBeUndefined();
|
|
69
|
+
|
|
70
|
+
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
71
|
+
const appended = appendSpy.mock.calls[0][0] as HTMLScriptElement;
|
|
72
|
+
expect(appended.tagName).toBe("SCRIPT");
|
|
73
|
+
expect(appended.src).toContain("@file/123-mpl.js");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("injectCss", () => {
|
|
78
|
+
it("refuses to append <link> for the PoC attack CSS URL", () => {
|
|
79
|
+
const container = document.createElement("div");
|
|
80
|
+
const loggerSpy = vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
81
|
+
|
|
82
|
+
const cleanup = injectCss(container, "http://127.0.0.1:8820/x.css");
|
|
83
|
+
|
|
84
|
+
expect(container.querySelector("link")).toBeNull();
|
|
85
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining("untrusted"),
|
|
87
|
+
);
|
|
88
|
+
// Cleanup must be safe to call even when nothing was appended.
|
|
89
|
+
expect(() => cleanup()).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
"https://evil.example.com/x.css",
|
|
94
|
+
"javascript:alert(1)",
|
|
95
|
+
"data:text/css,body{background:red}",
|
|
96
|
+
])("refuses to append <link> for %s", (url) => {
|
|
97
|
+
const container = document.createElement("div");
|
|
98
|
+
vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
99
|
+
|
|
100
|
+
injectCss(container, url);
|
|
101
|
+
|
|
102
|
+
expect(container.querySelector("link")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("appends a <link> for a trusted virtual file URL", () => {
|
|
106
|
+
const container = document.createElement("div");
|
|
107
|
+
|
|
108
|
+
const cleanup = injectCss(container, "./@file/456-mpl.css");
|
|
109
|
+
|
|
110
|
+
const link = container.querySelector("link");
|
|
111
|
+
expect(link).not.toBeNull();
|
|
112
|
+
expect(link?.rel).toBe("stylesheet");
|
|
113
|
+
expect(link?.getAttribute("href")).toBe("./@file/456-mpl.css");
|
|
114
|
+
|
|
115
|
+
cleanup();
|
|
116
|
+
expect(container.querySelector("link")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@/hooks/useEventListener";
|
|
11
11
|
import { createPlugin } from "@/plugins/core/builder";
|
|
12
12
|
import { rpc } from "@/plugins/core/rpc";
|
|
13
|
+
import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
13
14
|
import type { IPluginProps } from "@/plugins/types";
|
|
14
15
|
import { Logger } from "@/utils/Logger";
|
|
15
16
|
import { EventBuffer, extractBuffers, MessageSchema } from "./utils";
|
|
@@ -64,7 +65,7 @@ declare global {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
interface PanelData {
|
|
67
|
-
|
|
68
|
+
extensionUrl: string | null;
|
|
68
69
|
docs_json: Record<string, unknown>;
|
|
69
70
|
render_json: {
|
|
70
71
|
roots: Record<string, string>;
|
|
@@ -85,7 +86,7 @@ type PluginFunctions = {
|
|
|
85
86
|
export const PanelPlugin = createPlugin<T>("marimo-panel")
|
|
86
87
|
.withData(
|
|
87
88
|
z.object({
|
|
88
|
-
|
|
89
|
+
extensionUrl: z.string().nullable(),
|
|
89
90
|
docs_json: z.record(z.string(), z.unknown()),
|
|
90
91
|
render_json: z
|
|
91
92
|
.object({
|
|
@@ -110,9 +111,34 @@ function isBokehLoaded() {
|
|
|
110
111
|
return window.Bokeh != null;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Append a `<script src>` for the bokeh/panel extension.
|
|
116
|
+
*
|
|
117
|
+
* The URL must be a marimo virtual file path; anything else (e.g. an
|
|
118
|
+
* attacker-controlled URL injected via a raw `<marimo-panel>` element in a
|
|
119
|
+
* markdown cell) is refused.
|
|
120
|
+
*/
|
|
121
|
+
export function loadPanelExtension(extensionUrl: string | null): boolean {
|
|
122
|
+
if (!extensionUrl) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (!isTrustedVirtualFileUrl(extensionUrl)) {
|
|
126
|
+
Logger.error(
|
|
127
|
+
`Refusing to load Panel extension from untrusted URL: ${String(
|
|
128
|
+
extensionUrl,
|
|
129
|
+
)}`,
|
|
130
|
+
);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const script = document.createElement("script");
|
|
134
|
+
script.src = extensionUrl;
|
|
135
|
+
document.head.append(script);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
const PanelSlot = (props: Props) => {
|
|
114
140
|
const { data, functions, host } = props;
|
|
115
|
-
const {
|
|
141
|
+
const { extensionUrl, docs_json: docsJson, render_json: renderJson } = data;
|
|
116
142
|
const ref = useRef<HTMLDivElement>(null);
|
|
117
143
|
const rootModelIdRef = useRef<string | null>(null);
|
|
118
144
|
const receiverRef = useRef<InstanceType<
|
|
@@ -173,12 +199,7 @@ const PanelSlot = (props: Props) => {
|
|
|
173
199
|
return;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
|
-
|
|
177
|
-
if (extension) {
|
|
178
|
-
const script = document.createElement("script");
|
|
179
|
-
script.innerHTML = extension;
|
|
180
|
-
document.head.append(script);
|
|
181
|
-
}
|
|
202
|
+
loadPanelExtension(extensionUrl);
|
|
182
203
|
|
|
183
204
|
// Check if Bokeh is loaded every 10ms
|
|
184
205
|
const checkBokeh = setInterval(() => {
|
|
@@ -189,7 +210,7 @@ const PanelSlot = (props: Props) => {
|
|
|
189
210
|
}, 10);
|
|
190
211
|
|
|
191
212
|
return () => clearInterval(checkBokeh);
|
|
192
|
-
}, [
|
|
213
|
+
}, [extensionUrl, setLoaded]);
|
|
193
214
|
|
|
194
215
|
// Listen for incoming messages
|
|
195
216
|
useEventListener(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { loadPanelExtension } from "../PanelPlugin";
|
|
5
|
+
|
|
6
|
+
describe("loadPanelExtension", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
for (const el of document.head.querySelectorAll("script")) {
|
|
9
|
+
el.remove();
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does nothing and returns false for null URL", () => {
|
|
18
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
19
|
+
expect(loadPanelExtension(null)).toBe(false);
|
|
20
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("refuses to load the PoC attack URL", () => {
|
|
24
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
25
|
+
const loggerSpy = vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
26
|
+
|
|
27
|
+
expect(loadPanelExtension("http://127.0.0.1:8820/poc.js")).toBe(false);
|
|
28
|
+
|
|
29
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
30
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
31
|
+
expect.stringContaining("untrusted"),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
"https://evil.example.com/x.js",
|
|
37
|
+
"//evil.example.com/x.js",
|
|
38
|
+
// An attacker embedding inline JS as a data URL — what the old plugin
|
|
39
|
+
// would have executed verbatim via script.innerHTML.
|
|
40
|
+
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
41
|
+
"javascript:alert(1)",
|
|
42
|
+
"./@file/x.js#http://evil.com",
|
|
43
|
+
])("refuses to load %s", (url) => {
|
|
44
|
+
const appendSpy = vi.spyOn(document.head, "append");
|
|
45
|
+
vi.spyOn(Logger, "error").mockImplementation(() => {});
|
|
46
|
+
|
|
47
|
+
expect(loadPanelExtension(url)).toBe(false);
|
|
48
|
+
expect(appendSpy).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("appends a <script src> for a trusted virtual file URL", () => {
|
|
52
|
+
expect(loadPanelExtension("./@file/42-bokeh.js")).toBe(true);
|
|
53
|
+
|
|
54
|
+
const script = document.head.querySelector("script");
|
|
55
|
+
expect(script).not.toBeNull();
|
|
56
|
+
expect(script?.src).toContain("@file/42-bokeh.js");
|
|
57
|
+
// Must NOT populate innerHTML — that was the original vulnerability sink.
|
|
58
|
+
expect(script?.innerHTML).toBe("");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -112,6 +112,56 @@ describe("PlotlyPlugin", () => {
|
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
it("clicking a box element triggers onClick", async () => {
|
|
116
|
+
const setValue = vi.fn<Setter<unknown>>();
|
|
117
|
+
|
|
118
|
+
render(
|
|
119
|
+
<Suspense fallback={null}>
|
|
120
|
+
<PlotlyComponent
|
|
121
|
+
figure={{
|
|
122
|
+
data: [{ type: "box" }],
|
|
123
|
+
layout: {},
|
|
124
|
+
frames: null,
|
|
125
|
+
}}
|
|
126
|
+
value={undefined}
|
|
127
|
+
setValue={setValue}
|
|
128
|
+
host={document.createElement("div")}
|
|
129
|
+
config={{}}
|
|
130
|
+
/>
|
|
131
|
+
</Suspense>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(capturedPlotProps).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
capturedPlotProps?.onClick?.({
|
|
140
|
+
points: [
|
|
141
|
+
{
|
|
142
|
+
data: { type: "box" },
|
|
143
|
+
x: "Group A",
|
|
144
|
+
y: 3,
|
|
145
|
+
pointIndex: 0,
|
|
146
|
+
pointNumber: 0,
|
|
147
|
+
curveNumber: 0,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(setValue).toHaveBeenCalledTimes(1);
|
|
154
|
+
const updater = setValue.mock.calls[0][0] as (value: unknown) => unknown;
|
|
155
|
+
expect(updater({})).toEqual({
|
|
156
|
+
selections: [],
|
|
157
|
+
points: [
|
|
158
|
+
{ x: "Group A", y: 3, curveNumber: 0, pointNumber: 0, pointIndex: 0 },
|
|
159
|
+
],
|
|
160
|
+
indices: [0],
|
|
161
|
+
range: undefined,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
115
165
|
it("clicking a violin element triggers onClick", async () => {
|
|
116
166
|
const setValue = vi.fn<Setter<unknown>>();
|
|
117
167
|
|
|
@@ -102,6 +102,14 @@ describe("shouldHandleClickSelection", () => {
|
|
|
102
102
|
expect(shouldHandleClickSelection([heatmapPoint])).toBe(true);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
it("accepts box clicks", () => {
|
|
106
|
+
const boxPoint = createPlotDatum({
|
|
107
|
+
data: { type: "box" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(shouldHandleClickSelection([boxPoint])).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
105
113
|
it("accepts violin clicks", () => {
|
|
106
114
|
const violinPoint = createPlotDatum({
|
|
107
115
|
data: { type: "violin" },
|
|
@@ -126,6 +134,22 @@ describe("shouldHandleClickSelection", () => {
|
|
|
126
134
|
expect(shouldHandleClickSelection([linePoint])).toBe(true);
|
|
127
135
|
});
|
|
128
136
|
|
|
137
|
+
it("accepts funnel clicks", () => {
|
|
138
|
+
const funnelPoint = createPlotDatum({
|
|
139
|
+
data: { type: "funnel" },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(shouldHandleClickSelection([funnelPoint])).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("accepts funnelarea clicks", () => {
|
|
146
|
+
const funnelAreaPoint = createPlotDatum({
|
|
147
|
+
data: { type: "funnelarea" },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(shouldHandleClickSelection([funnelAreaPoint])).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
129
153
|
it("accepts waterfall clicks", () => {
|
|
130
154
|
const waterfallPoint = createPlotDatum({
|
|
131
155
|
data: { type: "waterfall" },
|
|
@@ -214,6 +238,64 @@ describe("extractPoints", () => {
|
|
|
214
238
|
expect(extractPoints([point])).toEqual([{ x: 1, y: 2, z: 3 }]);
|
|
215
239
|
});
|
|
216
240
|
|
|
241
|
+
it("returns funnel-specific fields for funnel traces", () => {
|
|
242
|
+
const point = createPlotDatum({
|
|
243
|
+
x: 1000,
|
|
244
|
+
y: "Visit",
|
|
245
|
+
label: "Visit",
|
|
246
|
+
value: 1000,
|
|
247
|
+
percentInitial: 1.0,
|
|
248
|
+
percentPrevious: 1.0,
|
|
249
|
+
percentTotal: 1.0,
|
|
250
|
+
curveNumber: 0,
|
|
251
|
+
pointIndex: 0,
|
|
252
|
+
pointNumber: 0,
|
|
253
|
+
data: { type: "funnel" },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(extractPoints([point])).toEqual([
|
|
257
|
+
{
|
|
258
|
+
x: 1000,
|
|
259
|
+
y: "Visit",
|
|
260
|
+
label: "Visit",
|
|
261
|
+
value: 1000,
|
|
262
|
+
percentInitial: 1.0,
|
|
263
|
+
percentPrevious: 1.0,
|
|
264
|
+
percentTotal: 1.0,
|
|
265
|
+
curveNumber: 0,
|
|
266
|
+
pointIndex: 0,
|
|
267
|
+
pointNumber: 0,
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns funnelarea-specific fields without x/y for funnelarea traces", () => {
|
|
273
|
+
const point = createPlotDatum({
|
|
274
|
+
label: "Stage A",
|
|
275
|
+
value: 500,
|
|
276
|
+
percentInitial: 0.5,
|
|
277
|
+
percentPrevious: 0.8,
|
|
278
|
+
percentTotal: 0.5,
|
|
279
|
+
curveNumber: 0,
|
|
280
|
+
pointNumber: 1,
|
|
281
|
+
x: 99,
|
|
282
|
+
y: 99,
|
|
283
|
+
data: { type: "funnelarea" },
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(extractPoints([point])).toEqual([
|
|
287
|
+
{
|
|
288
|
+
label: "Stage A",
|
|
289
|
+
value: 500,
|
|
290
|
+
percentInitial: 0.5,
|
|
291
|
+
percentPrevious: 0.8,
|
|
292
|
+
percentTotal: 0.5,
|
|
293
|
+
curveNumber: 0,
|
|
294
|
+
pointNumber: 1,
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
217
299
|
it("returns x/y/pointIndex for waterfall clicks", () => {
|
|
218
300
|
const point = createPlotDatum({
|
|
219
301
|
x: "Revenue",
|
|
@@ -24,6 +24,32 @@ const SUNBURST_DATA_KEYS: (keyof Plotly.SunburstPlotDatum)[] = [
|
|
|
24
24
|
"value",
|
|
25
25
|
] as const;
|
|
26
26
|
|
|
27
|
+
// Fields emitted by go.Funnel click events: includes x/y coordinates plus
|
|
28
|
+
// funnel-specific percent metrics.
|
|
29
|
+
const FUNNEL_DATA_KEYS: string[] = [
|
|
30
|
+
"curveNumber",
|
|
31
|
+
"pointIndex",
|
|
32
|
+
"pointNumber",
|
|
33
|
+
"x",
|
|
34
|
+
"y",
|
|
35
|
+
"label",
|
|
36
|
+
"value",
|
|
37
|
+
"percentInitial",
|
|
38
|
+
"percentPrevious",
|
|
39
|
+
"percentTotal",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
// Fields emitted by go.FunnelArea click events: sector-based, no x/y.
|
|
43
|
+
const FUNNEL_AREA_DATA_KEYS: string[] = [
|
|
44
|
+
"curveNumber",
|
|
45
|
+
"pointNumber",
|
|
46
|
+
"label",
|
|
47
|
+
"value",
|
|
48
|
+
"percentInitial",
|
|
49
|
+
"percentPrevious",
|
|
50
|
+
"percentTotal",
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
27
53
|
const LINE_CLICK_TRACE_TYPES = new Set(["scatter", "scattergl"]);
|
|
28
54
|
|
|
29
55
|
const STANDARD_POINT_KEYS: string[] = [
|
|
@@ -256,10 +282,13 @@ export function shouldHandleClickSelection(
|
|
|
256
282
|
const type = getTraceSource(point).type;
|
|
257
283
|
return (
|
|
258
284
|
type === "bar" ||
|
|
285
|
+
type === "box" ||
|
|
286
|
+
type === "funnel" ||
|
|
287
|
+
type === "funnelarea" ||
|
|
259
288
|
type === "heatmap" ||
|
|
260
289
|
type === "histogram" ||
|
|
261
|
-
type === "waterfall" ||
|
|
262
290
|
type === "violin" ||
|
|
291
|
+
type === "waterfall" ||
|
|
263
292
|
isLinePoint(point)
|
|
264
293
|
);
|
|
265
294
|
});
|
|
@@ -329,13 +358,43 @@ export function extractPoints(
|
|
|
329
358
|
let parser: PlotlyTemplateParser | undefined;
|
|
330
359
|
|
|
331
360
|
return points.map((point) => {
|
|
361
|
+
const trace = getTraceSource(point);
|
|
362
|
+
|
|
363
|
+
// FunnelArea: sector-based chart with no x/y coordinates.
|
|
364
|
+
// Pick funnel-area-specific keys, then merge any hovertemplate-parsed
|
|
365
|
+
// fields (e.g. customdata columns) so user-defined fields are preserved.
|
|
366
|
+
if (trace.type === "funnelarea") {
|
|
367
|
+
const base = pick(point, FUNNEL_AREA_DATA_KEYS);
|
|
368
|
+
const ht = Array.isArray(trace.hovertemplate)
|
|
369
|
+
? trace.hovertemplate[0]
|
|
370
|
+
: trace.hovertemplate;
|
|
371
|
+
if (!ht) {
|
|
372
|
+
return base;
|
|
373
|
+
}
|
|
374
|
+
parser = parser ? parser.update(ht) : createParser(ht);
|
|
375
|
+
return { ...base, ...parser.parse(point) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Funnel: bar-like chart with x/y plus per-stage percent metrics.
|
|
379
|
+
// Pick funnel-specific keys, then merge hovertemplate-parsed fields so
|
|
380
|
+
// callers get both percentInitial et al. and any user-defined columns.
|
|
381
|
+
if (trace.type === "funnel") {
|
|
382
|
+
const base = pick(point, FUNNEL_DATA_KEYS);
|
|
383
|
+
const ht = Array.isArray(trace.hovertemplate)
|
|
384
|
+
? trace.hovertemplate[0]
|
|
385
|
+
: trace.hovertemplate;
|
|
386
|
+
if (!ht) {
|
|
387
|
+
return base;
|
|
388
|
+
}
|
|
389
|
+
parser = parser ? parser.update(ht) : createParser(ht);
|
|
390
|
+
return { ...base, ...parser.parse(point) };
|
|
391
|
+
}
|
|
392
|
+
|
|
332
393
|
const standardPointFields = withInferredXY(
|
|
333
394
|
point,
|
|
334
395
|
pick(point, STANDARD_POINT_KEYS),
|
|
335
396
|
);
|
|
336
397
|
|
|
337
|
-
const trace = getTraceSource(point);
|
|
338
|
-
|
|
339
398
|
// Get the first hovertemplate
|
|
340
399
|
const hovertemplate = Array.isArray(trace.hovertemplate)
|
|
341
400
|
? trace.hovertemplate[0]
|
|
@@ -8,7 +8,6 @@ import type { Data, VegaComponentState } from "./vega-component";
|
|
|
8
8
|
|
|
9
9
|
import "./vega.css";
|
|
10
10
|
import React, { type JSX } from "react";
|
|
11
|
-
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
12
11
|
|
|
13
12
|
const LazyVegaComponent = React.lazy(() => import("./vega-component"));
|
|
14
13
|
|
|
@@ -29,13 +28,11 @@ export class VegaPlugin implements IPlugin<VegaComponentState, Data> {
|
|
|
29
28
|
|
|
30
29
|
render(props: IPluginProps<VegaComponentState, Data>): JSX.Element {
|
|
31
30
|
return (
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/>
|
|
38
|
-
</TooltipProvider>
|
|
31
|
+
<LazyVegaComponent
|
|
32
|
+
value={props.value}
|
|
33
|
+
setValue={props.setValue}
|
|
34
|
+
{...props.data}
|
|
35
|
+
/>
|
|
39
36
|
);
|
|
40
37
|
}
|
|
41
38
|
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
NavigationMenuTrigger,
|
|
12
12
|
navigationMenuTriggerStyle,
|
|
13
13
|
} from "@/components/ui/navigation";
|
|
14
|
-
import { Tooltip
|
|
14
|
+
import { Tooltip } from "@/components/ui/tooltip";
|
|
15
15
|
import { renderHTML } from "@/plugins/core/RenderHTML";
|
|
16
16
|
import { cn } from "@/utils/cn";
|
|
17
17
|
import { appendQueryParams } from "@/utils/urls";
|
|
@@ -67,11 +67,7 @@ export class NavigationMenuPlugin implements IStatelessPlugin<Data> {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
render(props: IStatelessPluginProps<Data>): JSX.Element {
|
|
70
|
-
return
|
|
71
|
-
<TooltipProvider>
|
|
72
|
-
<NavMenuComponent {...props.data} />
|
|
73
|
-
</TooltipProvider>
|
|
74
|
-
);
|
|
70
|
+
return <NavMenuComponent {...props.data} />;
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
73
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as v}from"./chunk-LvLJmgfZ.js";import{i as j,p as y,u as w}from"./useEvent-D91BmmQi.js";import{t as R}from"./react-Bj1aDYRI.js";import{bn as S,gn as _,ii as c,it as H,m as I,ri as M,vt as N}from"./cells-BqYYXi6G.js";import{t as E}from"./compiler-runtime-B3qBwwSJ.js";import{_ as T}from"./useEventListener-DGjKht0c.js";import{o as z}from"./utils-8btzWeZg.js";import{t as C}from"./jsx-runtime-Blw4afVn.js";import{t as F}from"./tooltip-DmqhBBs6.js";import{t as L}from"./copy-icon-Ci08KCdY.js";import{t as V}from"./usePress-BXMIcLWP.js";import{n as k}from"./useDebounce-LVL1r3-M.js";import{t as q}from"./useRunCells-DFYAOTWd.js";var W=E(),m=v(R(),1),o=v(C(),1);const A=r=>{let t=(0,W.c)(16),e,n,i;t[0]===r?(e=t[1],n=t[2],i=t[3]):({href:n,children:e,...i}=r,t[0]=r,t[1]=e,t[2]=n,t[3]=i);let a=(0,m.useRef)(null),s;t[4]===n?s=t[5]:(s=()=>{let u=new URL(globalThis.location.href);u.hash=n,globalThis.history.pushState({},"",u.toString()),globalThis.dispatchEvent(new HashChangeEvent("hashchange"));let x=n.slice(1),g=document.getElementById(x);g&&g.scrollIntoView({behavior:"smooth",block:"start"})},t[4]=n,t[5]=s);let l=s,f;t[6]===l?f=t[7]:(f={onPress:()=>{l()}},t[6]=l,t[7]=f);let{pressProps:h}=V(f),d;t[8]===l?d=t[9]:(d=u=>{u.preventDefault(),l()},t[8]=l,t[9]=d);let b=d,p;return t[10]!==e||t[11]!==b||t[12]!==n||t[13]!==h||t[14]!==i?(p=(0,o.jsx)("a",{ref:a,href:n,...h,onClick:b,...i,children:e}),t[10]=e,t[11]=b,t[12]=n,t[13]=h,t[14]=i,t[15]=p):p=t[15],p};async function D(r){let t=I().inOrderIds.at(0);if(t)try{let e=await _.request({document:r,cellId:t});if(!e||e.options.length===0)return;let n=r.split(".").pop()??r,i=e.options[0],a=e.options.find(s=>s.name===n)??i;a!=null&&a.completion_info&&j.set(S,{documentation:a.completion_info})}catch(e){T.debug(`Doc lookup failed for "${r}"`,e)}}var O=E();const P=r=>{let t=(0,O.c)(8),{qualifiedName:e,children:n}=r,i;t[0]===e?i=t[1]:(i=()=>{D(e)},t[0]=e,t[1]=i);let a=k(i,100),s;t[2]===a?s=t[3]:(s=()=>a.cancel(),t[2]=a,t[3]=s);let l;return t[4]!==n||t[5]!==a||t[6]!==s?(l=(0,o.jsx)("span",{onMouseEnter:a,onMouseLeave:s,children:n}),t[4]=n,t[5]=a,t[6]=s,t[7]=l):l=t[7],l};var $=y(r=>{let t=r(q),e=r(z);if(t||e)return!1;let n=!0;try{n=H()==="read"}catch{return!0}return!n});function B(){return w($)}var U=E(),Z=r=>{if(r instanceof c.Element&&!/^[A-Za-z][\w-]*$/.test(r.name))return m.createElement(m.Fragment)},G=(r,t)=>{if(t instanceof c.Element&&t.name==="body"){if((0,m.isValidElement)(r)&&"props"in r){let e=r.props.children;return(0,o.jsx)(o.Fragment,{children:e})}return}},J=(r,t)=>{if(t instanceof c.Element&&t.name==="html"){if((0,m.isValidElement)(r)&&"props"in r){let e=r.props.children;return(0,o.jsx)(o.Fragment,{children:e})}return}},K=r=>{if(r instanceof c.Element&&r.attribs&&r.name==="iframe"){let t=document.createElement("iframe");return Object.entries(r.attribs).forEach(([e,n])=>{e.startsWith('"')&&e.endsWith('"')&&(e=e.slice(1,-1)),t.setAttribute(e,n)}),(0,o.jsx)("div",{dangerouslySetInnerHTML:{__html:t.outerHTML}})}},Q=r=>{if(r instanceof c.Element&&r.name==="script"){let t=r.attribs.src;if(!t)return;if(!document.querySelector(`script[src="${t}"]`)){let e=document.createElement("script");e.src=t,document.head.append(e)}return(0,o.jsx)(o.Fragment,{})}},X=(r,t)=>{if(t instanceof c.Element&&t.name==="a"){let e=t.attribs.href;if(e!=null&&e.startsWith("#")&&!e.startsWith("#code/")){let n=null;return(0,m.isValidElement)(r)&&"props"in r&&(n=r.props.children),(0,o.jsx)(A,{href:e,...t.attribs,children:n})}}},Y=(r,t,e)=>{var n,i;if(t instanceof c.Element&&t.name==="div"&&((i=(n=t.attribs)==null?void 0:n.class)!=null&&i.includes("codehilite")))return(0,o.jsx)(rt,{children:r},e)},tt=(r,t)=>{var e;if(t instanceof c.Element&&((e=t.attribs)!=null&&e["data-marimo-doc"])){let n=t.attribs["data-marimo-doc"];return(0,o.jsx)(P,{qualifiedName:n,children:r})}},et=(r,t)=>{var e;if(t instanceof c.Element&&((e=t.attribs)!=null&&e["data-tooltip"])){let n=t.attribs["data-tooltip"];return(0,o.jsx)(F,{content:n,children:r})}},rt=r=>{let t=(0,U.c)(3),{children:e}=r,n=(0,m.useRef)(null),i;t[0]===Symbol.for("react.memo_cache_sentinel")?(i=(0,o.jsx)("div",{className:"absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",children:(0,o.jsx)(L,{tooltip:!1,className:"p-1",value:()=>{var l;let s=(l=n.current)==null?void 0:l.firstChild;return s&&s.textContent||""}})}),t[0]=i):i=t[0];let a;return t[1]===e?a=t[2]:(a=(0,o.jsxs)("div",{className:"relative group codehilite-wrapper",ref:n,children:[e,i]}),t[1]=e,t[2]=a),a};const nt=({html:r,additionalReplacements:t=[],alwaysSanitizeHtml:e=!0})=>(0,o.jsx)(it,{html:r,alwaysSanitizeHtml:e,additionalReplacements:t});var it=({html:r,additionalReplacements:t=[],alwaysSanitizeHtml:e})=>{let n=B();return at({html:(0,m.useMemo)(()=>e||n?N(r):r,[r,e,n]),additionalReplacements:t})};function at({html:r,additionalReplacements:t=[]}){let e=[Z,K,Q,...t],n=[Y,X,tt,et,G,J];return M(r,{replace:(i,a)=>{for(let s of e){let l=s(i,a);if(l)return l}return i},transform:(i,a,s)=>{for(let l of n){let f=l(i,a,s);if(f)return f}return i}})}export{nt as t};
|