@marimo-team/islands 0.18.3 → 0.18.4
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/{formats-4m4HuHTj.js → formats-Bi_tbdwB.js} +20 -21
- package/dist/main.js +251 -122
- package/dist/style.css +1 -1
- package/dist/{vega-component-HUc7bIGs.js → vega-component-BFcH2SqR.js} +7 -7
- package/package.json +1 -1
- package/src/components/data-table/context-menu.tsx +7 -3
- package/src/components/data-table/filter-pills.tsx +2 -1
- package/src/components/data-table/filters.ts +11 -2
- package/src/components/editor/cell/CreateCellButton.tsx +5 -3
- package/src/components/editor/cell/collapse.tsx +2 -2
- package/src/components/editor/notebook-banner.tsx +1 -1
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +67 -47
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +2 -57
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +23 -19
- package/src/plugins/impl/anywidget/model.ts +68 -41
- package/src/plugins/impl/data-frames/utils/__tests__/operators.test.ts +2 -0
- package/src/plugins/impl/data-frames/utils/operators.ts +1 -0
- package/src/plugins/impl/vega/vega.css +5 -0
- package/src/plugins/layout/StatPlugin.tsx +43 -23
- package/src/utils/__tests__/data-views.test.ts +495 -13
- package/src/utils/data-views.ts +134 -16
- package/src/utils/json/base64.ts +8 -0
|
@@ -2,7 +2,7 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
|
|
|
2
2
|
import { t as require_react } from "./react-BSzAiXXz.js";
|
|
3
3
|
import { t as require_compiler_runtime } from "./compiler-runtime-CNX0xYDF.js";
|
|
4
4
|
import "./Combination-DnWHe36P.js";
|
|
5
|
-
import {
|
|
5
|
+
import { S as CircleQuestionMark, a as AlertTitle, m as asRemoteURL, n as useDeepCompareMemoize, o as isValid, r as Alert, t as arrow } from "./formats-Bi_tbdwB.js";
|
|
6
6
|
import "./clsx-D2KVTYnW.js";
|
|
7
7
|
import { l as Events } from "./button-XnD6ylpt.js";
|
|
8
8
|
import { o as Objects, s as Logger } from "./hotkeys-CwkyZ6ZF.js";
|
|
@@ -509,8 +509,8 @@ async function resolveVegaSpecData(e) {
|
|
|
509
509
|
} catch {
|
|
510
510
|
return e2;
|
|
511
511
|
}
|
|
512
|
-
let
|
|
513
|
-
return w[E2.pathname] =
|
|
512
|
+
let D = await vegaLoadData(E2.href, e2.data.format);
|
|
513
|
+
return w[E2.pathname] = D, {
|
|
514
514
|
...e2,
|
|
515
515
|
data: { name: E2.pathname }
|
|
516
516
|
};
|
|
@@ -540,17 +540,17 @@ var VegaComponent = (e) => {
|
|
|
540
540
|
spec: P,
|
|
541
541
|
embedOptions: A
|
|
542
542
|
}), w[5] = D, w[6] = A, w[7] = O, w[8] = P, w[9] = E, w[10] = T, w[11] = I) : I = w[11], I;
|
|
543
|
-
}, LoadedVegaComponent = ({ value: e, setValue: w, chartSelection: T, fieldSelection:
|
|
543
|
+
}, LoadedVegaComponent = ({ value: e, setValue: w, chartSelection: T, fieldSelection: O, spec: A, embedOptions: N }) => {
|
|
544
544
|
let { theme: L } = useTheme(), R = (0, import_react.useRef)(null), z = (0, import_react.useRef)(void 0), [B, V] = (0, import_react.useState)(), H = (0, import_react.useMemo)(() => N && "actions" in N ? N.actions : {
|
|
545
545
|
source: false,
|
|
546
546
|
compiled: false
|
|
547
|
-
}, [N]), U = useDeepCompareMemoize(
|
|
547
|
+
}, [N]), U = useDeepCompareMemoize(A), W = (0, import_react.useMemo)(() => makeSelectable(fixRelativeUrl(U), {
|
|
548
548
|
chartSelection: T,
|
|
549
|
-
fieldSelection:
|
|
549
|
+
fieldSelection: O
|
|
550
550
|
}), [
|
|
551
551
|
U,
|
|
552
552
|
T,
|
|
553
|
-
|
|
553
|
+
O
|
|
554
554
|
]), G = (0, import_react.useMemo)(() => getSelectionParamNames(W), [W]), K = useEvent_default((T2) => {
|
|
555
555
|
w({
|
|
556
556
|
...e,
|
package/package.json
CHANGED
|
@@ -95,11 +95,11 @@ export const CellContextMenu = <TData,>({
|
|
|
95
95
|
const column = cell.column;
|
|
96
96
|
const canFilter = column.getCanFilter() && column.columnDef.meta?.filterType;
|
|
97
97
|
|
|
98
|
-
const handleFilterCell = () => {
|
|
98
|
+
const handleFilterCell = (operator: "in" | "not_in") => {
|
|
99
99
|
column.setFilterValue(
|
|
100
100
|
Filter.select({
|
|
101
101
|
options: [cell.getValue()],
|
|
102
|
-
operator
|
|
102
|
+
operator,
|
|
103
103
|
}),
|
|
104
104
|
);
|
|
105
105
|
};
|
|
@@ -119,10 +119,14 @@ export const CellContextMenu = <TData,>({
|
|
|
119
119
|
{canFilter && (
|
|
120
120
|
<>
|
|
121
121
|
<ContextMenuSeparator />
|
|
122
|
-
<ContextMenuItem onClick={handleFilterCell}>
|
|
122
|
+
<ContextMenuItem onClick={() => handleFilterCell("in")}>
|
|
123
123
|
<FilterIcon className="mo-dropdown-icon h-3 w-3" />
|
|
124
124
|
Filter by this value
|
|
125
125
|
</ContextMenuItem>
|
|
126
|
+
<ContextMenuItem onClick={() => handleFilterCell("not_in")}>
|
|
127
|
+
<FilterIcon className="mo-dropdown-icon h-3 w-3" />
|
|
128
|
+
Remove rows with this value
|
|
129
|
+
</ContextMenuItem>
|
|
126
130
|
</>
|
|
127
131
|
)}
|
|
128
132
|
</ContextMenuContent>
|
|
@@ -96,7 +96,8 @@ function formatValue(value: ColumnFilterValue, timeFormatter: DateFormatter) {
|
|
|
96
96
|
const stringifiedOptions = value.options.map((o) =>
|
|
97
97
|
stringifyUnknownValue({ value: o }),
|
|
98
98
|
);
|
|
99
|
-
|
|
99
|
+
const operator = value.operator === "in" ? "is in" : "not in";
|
|
100
|
+
return `${operator} [${stringifiedOptions.join(", ")}]`;
|
|
100
101
|
}
|
|
101
102
|
if (value.type === "text") {
|
|
102
103
|
return `contains "${value.text}"`;
|
|
@@ -7,6 +7,7 @@ import type { ConditionType } from "@/plugins/impl/data-frames/schema";
|
|
|
7
7
|
import type { ColumnId } from "@/plugins/impl/data-frames/types";
|
|
8
8
|
import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators";
|
|
9
9
|
import { assertNever } from "@/utils/assertNever";
|
|
10
|
+
import { Logger } from "@/utils/Logger";
|
|
10
11
|
|
|
11
12
|
declare module "@tanstack/react-table" {
|
|
12
13
|
//allows us to define custom properties for our columns
|
|
@@ -192,12 +193,20 @@ export function filterToFilterCondition(
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
return [];
|
|
195
|
-
case "select":
|
|
196
|
+
case "select": {
|
|
197
|
+
let operator = filter.operator;
|
|
198
|
+
if (filter.operator !== "in" && filter.operator !== "not_in") {
|
|
199
|
+
Logger.warn("Invalid operator for select filter", {
|
|
200
|
+
operator: filter.operator,
|
|
201
|
+
});
|
|
202
|
+
operator = "in"; // default to in operator
|
|
203
|
+
}
|
|
196
204
|
return {
|
|
197
205
|
column_id: columnId,
|
|
198
|
-
operator
|
|
206
|
+
operator,
|
|
199
207
|
value: filter.options,
|
|
200
208
|
};
|
|
209
|
+
}
|
|
201
210
|
|
|
202
211
|
default:
|
|
203
212
|
assertNever(filter);
|
|
@@ -46,7 +46,7 @@ export const CreateCellButton = ({
|
|
|
46
46
|
<div>{baseTooltipContent}</div>
|
|
47
47
|
<div className="text-xs text-muted-foreground font-medium pt-1 -mt-2 border-t border-border">
|
|
48
48
|
{<MinimalHotkeys shortcut={shortcut} className="inline" />}{" "}
|
|
49
|
-
<span>
|
|
49
|
+
<span>for other cell types</span>
|
|
50
50
|
</div>
|
|
51
51
|
</div>
|
|
52
52
|
);
|
|
@@ -81,7 +81,9 @@ export const CreateCellButton = ({
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const handleButtonClick = (e: React.MouseEvent) => {
|
|
84
|
-
|
|
84
|
+
const hasModifier =
|
|
85
|
+
oneClickShortcut === "shift" ? e.shiftKey : e.metaKey || e.ctrlKey;
|
|
86
|
+
if (!hasModifier) {
|
|
85
87
|
e.preventDefault();
|
|
86
88
|
e.stopPropagation();
|
|
87
89
|
addPythonCell();
|
|
@@ -123,7 +125,7 @@ export const CreateCellButton = ({
|
|
|
123
125
|
>
|
|
124
126
|
<Tooltip content={finalTooltipContent}>
|
|
125
127
|
<PlusIcon
|
|
126
|
-
strokeWidth={
|
|
128
|
+
strokeWidth={3}
|
|
127
129
|
size={14}
|
|
128
130
|
className="opacity-60 hover:opacity-90"
|
|
129
131
|
/>
|
|
@@ -44,9 +44,9 @@ export const CollapseToggle: React.FC<Props> = (props) => {
|
|
|
44
44
|
|
|
45
45
|
const Arrow = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
|
46
46
|
return isCollapsed ? (
|
|
47
|
-
<ChevronRightIcon className="w-5 h-5 shrink-0" />
|
|
47
|
+
<ChevronRightIcon className="w-5 h-5 shrink-0 opacity-60" strokeWidth={2} />
|
|
48
48
|
) : (
|
|
49
|
-
<ChevronDownIcon className="w-5 h-5 shrink-0" />
|
|
49
|
+
<ChevronDownIcon className="w-5 h-5 shrink-0 opacity-60" strokeWidth={2} />
|
|
50
50
|
);
|
|
51
51
|
};
|
|
52
52
|
|
|
@@ -26,7 +26,7 @@ export const NotebookBanner: React.FC<Props> = ({ width }) => {
|
|
|
26
26
|
<div
|
|
27
27
|
className={cn(
|
|
28
28
|
"flex flex-col gap-4 mb-5 print:hidden",
|
|
29
|
-
width === "columns" && "
|
|
29
|
+
width === "columns" && "w-full max-w-[80vw]",
|
|
30
30
|
)}
|
|
31
31
|
>
|
|
32
32
|
{banners.map((banner) => (
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
3
|
|
|
4
4
|
import type { AnyWidget, Experimental } from "@anywidget/types";
|
|
5
|
-
import {
|
|
5
|
+
import { isEqual } from "lodash-es";
|
|
6
6
|
import { useEffect, useMemo, useRef } from "react";
|
|
7
|
+
import useEvent from "react-use-event-hook";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { MarimoIncomingMessageEvent } from "@/core/dom/events";
|
|
9
10
|
import { asRemoteURL } from "@/core/runtime/config";
|
|
@@ -17,10 +18,13 @@ import { createPlugin } from "@/plugins/core/builder";
|
|
|
17
18
|
import { rpc } from "@/plugins/core/rpc";
|
|
18
19
|
import type { IPluginProps } from "@/plugins/types";
|
|
19
20
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
decodeFromWire,
|
|
22
|
+
isWireFormat,
|
|
23
|
+
serializeBuffersToBase64,
|
|
24
|
+
type WireFormat,
|
|
25
|
+
} from "@/utils/data-views";
|
|
26
|
+
import { prettyError } from "@/utils/errors";
|
|
27
|
+
import type { Base64String } from "@/utils/json/base64";
|
|
24
28
|
import { Logger } from "@/utils/Logger";
|
|
25
29
|
import { ErrorBanner } from "../common/error-banner";
|
|
26
30
|
import { MODEL_MANAGER, Model } from "./model";
|
|
@@ -29,44 +33,56 @@ interface Data {
|
|
|
29
33
|
jsUrl: string;
|
|
30
34
|
jsHash: string;
|
|
31
35
|
css?: string | null;
|
|
32
|
-
bufferPaths?: (string | number)[][] | null;
|
|
33
|
-
initialValue: T;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
type T = Record<string,
|
|
38
|
+
type T = Record<string, unknown>;
|
|
37
39
|
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
39
40
|
type PluginFunctions = {
|
|
40
|
-
send_to_widget: <T>(req: {
|
|
41
|
+
send_to_widget: <T>(req: {
|
|
42
|
+
content: unknown;
|
|
43
|
+
buffers: Base64String[];
|
|
44
|
+
}) => Promise<null | undefined>;
|
|
41
45
|
};
|
|
42
46
|
|
|
43
|
-
export const AnyWidgetPlugin = createPlugin<T
|
|
47
|
+
export const AnyWidgetPlugin = createPlugin<WireFormat<T>>("marimo-anywidget")
|
|
44
48
|
.withData(
|
|
45
49
|
z.object({
|
|
46
50
|
jsUrl: z.string(),
|
|
47
51
|
jsHash: z.string(),
|
|
48
52
|
css: z.string().nullish(),
|
|
49
|
-
bufferPaths: z
|
|
50
|
-
.array(z.array(z.union([z.string(), z.number()])))
|
|
51
|
-
.nullish(),
|
|
52
|
-
initialValue: z.object({}).passthrough(),
|
|
53
53
|
}),
|
|
54
54
|
)
|
|
55
55
|
.withFunctions<PluginFunctions>({
|
|
56
56
|
send_to_widget: rpc
|
|
57
|
-
.input(
|
|
57
|
+
.input(
|
|
58
|
+
z.object({
|
|
59
|
+
content: z.unknown(),
|
|
60
|
+
buffers: z.array(z.string().transform((v) => v as Base64String)),
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
58
63
|
.output(z.null().optional()),
|
|
59
64
|
})
|
|
60
65
|
.renderer((props) => <AnyWidgetSlot {...props} />);
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const { css, jsUrl, jsHash
|
|
67
|
+
const AnyWidgetSlot = (
|
|
68
|
+
props: IPluginProps<WireFormat<T>, Data, PluginFunctions>,
|
|
69
|
+
) => {
|
|
70
|
+
const { css, jsUrl, jsHash } = props.data;
|
|
66
71
|
|
|
72
|
+
// Decode wire format { state, bufferPaths, buffers } to state with DataViews
|
|
67
73
|
const valueWithBuffers = useMemo(() => {
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
if (isWireFormat(props.value)) {
|
|
75
|
+
const decoded = decodeFromWire(props.value);
|
|
76
|
+
Logger.debug("AnyWidget decoded wire format:", {
|
|
77
|
+
bufferPaths: props.value.bufferPaths,
|
|
78
|
+
buffersCount: props.value.buffers?.length,
|
|
79
|
+
decodedKeys: Object.keys(decoded),
|
|
80
|
+
});
|
|
81
|
+
return decoded;
|
|
82
|
+
}
|
|
83
|
+
Logger.warn("AnyWidget value is not wire format:", props.value);
|
|
84
|
+
return props.value;
|
|
85
|
+
}, [props.value]);
|
|
70
86
|
|
|
71
87
|
// JS is an ESM file with a render function on it
|
|
72
88
|
// export function render({ model, el }) {
|
|
@@ -135,6 +151,12 @@ const AnyWidgetSlot = (props: Props) => {
|
|
|
135
151
|
};
|
|
136
152
|
}, [css, props.host]);
|
|
137
153
|
|
|
154
|
+
// Wrap setValue to serialize DataViews back to base64 before sending
|
|
155
|
+
// Structure matches ipywidgets protocol: { state, bufferPaths, buffers }
|
|
156
|
+
const wrappedSetValue = useEvent((partialValue: Partial<T>) =>
|
|
157
|
+
props.setValue(serializeBuffersToBase64(partialValue)),
|
|
158
|
+
);
|
|
159
|
+
|
|
138
160
|
if (error) {
|
|
139
161
|
return <ErrorBanner error={error} />;
|
|
140
162
|
}
|
|
@@ -162,6 +184,7 @@ const AnyWidgetSlot = (props: Props) => {
|
|
|
162
184
|
key={key}
|
|
163
185
|
{...props}
|
|
164
186
|
widget={module.default}
|
|
187
|
+
setValue={wrappedSetValue}
|
|
165
188
|
value={valueWithBuffers}
|
|
166
189
|
/>
|
|
167
190
|
);
|
|
@@ -191,10 +214,19 @@ async function runAnyWidgetModule(
|
|
|
191
214
|
const widget =
|
|
192
215
|
typeof widgetDef === "function" ? await widgetDef() : widgetDef;
|
|
193
216
|
await widget.initialize?.({ model, experimental });
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
try {
|
|
218
|
+
const unsub = await widget.render?.({ model, el, experimental });
|
|
219
|
+
return () => {
|
|
220
|
+
unsub?.();
|
|
221
|
+
};
|
|
222
|
+
} catch (error) {
|
|
223
|
+
Logger.error("Error rendering anywidget", error);
|
|
224
|
+
el.classList.add("text-error");
|
|
225
|
+
el.innerHTML = `Error rendering anywidget: ${prettyError(error)}`;
|
|
226
|
+
return () => {
|
|
227
|
+
// No-op
|
|
228
|
+
};
|
|
229
|
+
}
|
|
198
230
|
}
|
|
199
231
|
|
|
200
232
|
function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
|
|
@@ -218,6 +250,13 @@ function hasModelId(message: unknown): message is { model_id: string } {
|
|
|
218
250
|
);
|
|
219
251
|
}
|
|
220
252
|
|
|
253
|
+
interface Props
|
|
254
|
+
extends Omit<IPluginProps<T, Data, PluginFunctions>, "setValue"> {
|
|
255
|
+
widget: AnyWidget;
|
|
256
|
+
value: T;
|
|
257
|
+
setValue: (value: Partial<T>) => void;
|
|
258
|
+
}
|
|
259
|
+
|
|
221
260
|
const LoadedSlot = ({
|
|
222
261
|
value,
|
|
223
262
|
setValue,
|
|
@@ -228,15 +267,9 @@ const LoadedSlot = ({
|
|
|
228
267
|
}: Props & { widget: AnyWidget }) => {
|
|
229
268
|
const htmlRef = useRef<HTMLDivElement>(null);
|
|
230
269
|
|
|
270
|
+
// value is already decoded from wire format
|
|
231
271
|
const model = useRef<Model<T>>(
|
|
232
|
-
new Model(
|
|
233
|
-
// Merge the initial value with the current value
|
|
234
|
-
// since we only send partial updates to the backend
|
|
235
|
-
{ ...data.initialValue, ...value },
|
|
236
|
-
setValue,
|
|
237
|
-
functions.send_to_widget,
|
|
238
|
-
getDirtyFields(value, data.initialValue),
|
|
239
|
-
),
|
|
272
|
+
new Model(value, setValue, functions.send_to_widget, new Set()),
|
|
240
273
|
);
|
|
241
274
|
|
|
242
275
|
// Listen to incoming messages
|
|
@@ -289,16 +322,3 @@ export const visibleForTesting = {
|
|
|
289
322
|
isAnyWidgetModule,
|
|
290
323
|
getDirtyFields,
|
|
291
324
|
};
|
|
292
|
-
|
|
293
|
-
export function resolveInitialValue(
|
|
294
|
-
raw: Record<string, any>,
|
|
295
|
-
bufferPaths: readonly (readonly (string | number)[])[],
|
|
296
|
-
) {
|
|
297
|
-
const out = structuredClone(raw);
|
|
298
|
-
for (const bufferPath of bufferPaths) {
|
|
299
|
-
const base64String: Base64String = get(raw, bufferPath);
|
|
300
|
-
const bytes = byteStringToBinary(typedAtob(base64String));
|
|
301
|
-
set(out, bufferPath, new DataView(bytes.buffer));
|
|
302
|
-
}
|
|
303
|
-
return out;
|
|
304
|
-
}
|
|
@@ -5,11 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { TestUtils } from "@/__tests__/test-helpers";
|
|
6
6
|
import type { UIElementId } from "@/core/cells/ids";
|
|
7
7
|
import { MarimoIncomingMessageEvent } from "@/core/dom/events";
|
|
8
|
-
import {
|
|
9
|
-
getDirtyFields,
|
|
10
|
-
resolveInitialValue,
|
|
11
|
-
visibleForTesting,
|
|
12
|
-
} from "../AnyWidgetPlugin";
|
|
8
|
+
import { getDirtyFields, visibleForTesting } from "../AnyWidgetPlugin";
|
|
13
9
|
import { Model } from "../model";
|
|
14
10
|
|
|
15
11
|
const { LoadedSlot } = visibleForTesting;
|
|
@@ -132,6 +128,7 @@ describe("LoadedSlot", () => {
|
|
|
132
128
|
message: {
|
|
133
129
|
method: "update",
|
|
134
130
|
state: { count: 10 },
|
|
131
|
+
buffer_paths: [],
|
|
135
132
|
},
|
|
136
133
|
buffers: [],
|
|
137
134
|
},
|
|
@@ -183,55 +180,3 @@ describe("LoadedSlot", () => {
|
|
|
183
180
|
});
|
|
184
181
|
});
|
|
185
182
|
});
|
|
186
|
-
|
|
187
|
-
describe("resolveInitialValue", () => {
|
|
188
|
-
it("should convert base64 strings to DataView at specified paths", () => {
|
|
189
|
-
const result = resolveInitialValue(
|
|
190
|
-
{
|
|
191
|
-
a: 10,
|
|
192
|
-
b: "aGVsbG8=", // "hello" in base64
|
|
193
|
-
c: [1, "d29ybGQ="], // "world" in base64
|
|
194
|
-
d: {
|
|
195
|
-
foo: "bWFyaW1vCg==", // "marimo" in base64
|
|
196
|
-
baz: 20,
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
[["b"], ["c", 1], ["d", "foo"]],
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
expect(result).toMatchInlineSnapshot(`
|
|
203
|
-
{
|
|
204
|
-
"a": 10,
|
|
205
|
-
"b": DataView [
|
|
206
|
-
104,
|
|
207
|
-
101,
|
|
208
|
-
108,
|
|
209
|
-
108,
|
|
210
|
-
111,
|
|
211
|
-
],
|
|
212
|
-
"c": [
|
|
213
|
-
1,
|
|
214
|
-
DataView [
|
|
215
|
-
119,
|
|
216
|
-
111,
|
|
217
|
-
114,
|
|
218
|
-
108,
|
|
219
|
-
100,
|
|
220
|
-
],
|
|
221
|
-
],
|
|
222
|
-
"d": {
|
|
223
|
-
"baz": 20,
|
|
224
|
-
"foo": DataView [
|
|
225
|
-
109,
|
|
226
|
-
97,
|
|
227
|
-
114,
|
|
228
|
-
105,
|
|
229
|
-
109,
|
|
230
|
-
111,
|
|
231
|
-
10,
|
|
232
|
-
],
|
|
233
|
-
},
|
|
234
|
-
}
|
|
235
|
-
`);
|
|
236
|
-
});
|
|
237
|
-
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
vi,
|
|
10
10
|
} from "vitest";
|
|
11
11
|
import { TestUtils } from "@/__tests__/test-helpers";
|
|
12
|
+
import type { Base64String } from "@/utils/json/base64";
|
|
12
13
|
import {
|
|
13
14
|
type AnyWidgetMessage,
|
|
14
15
|
handleWidgetMessage,
|
|
@@ -23,7 +24,10 @@ describe("Model", () => {
|
|
|
23
24
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
25
|
let onChange: (value: any) => void;
|
|
25
26
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
-
let sendToWidget: (req: {
|
|
27
|
+
let sendToWidget: (req: {
|
|
28
|
+
content: unknown;
|
|
29
|
+
buffers: Base64String[];
|
|
30
|
+
}) => Promise<null | undefined>;
|
|
27
31
|
|
|
28
32
|
beforeEach(() => {
|
|
29
33
|
onChange = vi.fn();
|
|
@@ -72,7 +76,7 @@ describe("Model", () => {
|
|
|
72
76
|
});
|
|
73
77
|
});
|
|
74
78
|
|
|
75
|
-
it("should
|
|
79
|
+
it("should clear dirty fields after save", () => {
|
|
76
80
|
model.set("foo", "new value");
|
|
77
81
|
model.save_changes();
|
|
78
82
|
|
|
@@ -83,14 +87,13 @@ describe("Model", () => {
|
|
|
83
87
|
model.set("bar", 456);
|
|
84
88
|
model.save_changes();
|
|
85
89
|
|
|
90
|
+
// After clearing, only the newly changed field is sent
|
|
86
91
|
expect(onChange).toHaveBeenCalledWith({
|
|
87
|
-
foo: "new value",
|
|
88
92
|
bar: 456,
|
|
89
93
|
});
|
|
90
94
|
});
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
it.skip("should clear dirty fields after save", () => {
|
|
96
|
+
it("should not call onChange when no dirty fields", () => {
|
|
94
97
|
model.set("foo", "new value");
|
|
95
98
|
model.save_changes();
|
|
96
99
|
model.save_changes(); // Second save should not call onChange
|
|
@@ -144,21 +147,16 @@ describe("Model", () => {
|
|
|
144
147
|
const callback = vi.fn();
|
|
145
148
|
model.send({ test: true }, callback);
|
|
146
149
|
|
|
147
|
-
expect(sendToWidget).toHaveBeenCalledWith({
|
|
150
|
+
expect(sendToWidget).toHaveBeenCalledWith({
|
|
151
|
+
content: {
|
|
152
|
+
state: { test: true },
|
|
153
|
+
bufferPaths: [],
|
|
154
|
+
},
|
|
155
|
+
buffers: [],
|
|
156
|
+
});
|
|
148
157
|
await TestUtils.nextTick(); // flush
|
|
149
158
|
expect(callback).toHaveBeenCalledWith(null);
|
|
150
159
|
});
|
|
151
|
-
|
|
152
|
-
it("should warn when buffers are provided", () => {
|
|
153
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
|
154
|
-
// noop
|
|
155
|
-
});
|
|
156
|
-
model.send({ test: true }, null, [new ArrayBuffer(8)]);
|
|
157
|
-
|
|
158
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
159
|
-
"buffers not supported in marimo anywidget.send",
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
160
|
});
|
|
163
161
|
|
|
164
162
|
describe("widget_manager", () => {
|
|
@@ -228,7 +226,11 @@ describe("Model", () => {
|
|
|
228
226
|
it("should handle update messages", () => {
|
|
229
227
|
model.receiveCustomMessage({
|
|
230
228
|
method: "update",
|
|
231
|
-
state: {
|
|
229
|
+
state: {
|
|
230
|
+
foo: "updated",
|
|
231
|
+
bar: 789,
|
|
232
|
+
},
|
|
233
|
+
buffer_paths: [],
|
|
232
234
|
});
|
|
233
235
|
|
|
234
236
|
expect(model.get("foo")).toBe("updated");
|
|
@@ -333,7 +335,9 @@ describe("ModelManager", () => {
|
|
|
333
335
|
|
|
334
336
|
const updateMessage: AnyWidgetMessage = {
|
|
335
337
|
method: "update",
|
|
336
|
-
state: {
|
|
338
|
+
state: {
|
|
339
|
+
count: 1,
|
|
340
|
+
},
|
|
337
341
|
buffer_paths: [],
|
|
338
342
|
};
|
|
339
343
|
|