@marimo-team/islands 0.18.2 → 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/{constants-DWBOe162.js → constants-D_G8vnDk.js} +5 -4
- package/dist/{formats-7RSCCoSI.js → formats-Bi_tbdwB.js} +21 -22
- package/dist/{glide-data-editor-D-Ia_Jsv.js → glide-data-editor-DXF8E-QD.js} +2 -2
- package/dist/main.js +280 -148
- package/dist/style.css +1 -1
- package/dist/{types-Dunk85GC.js → types-DclGb0Yh.js} +1 -1
- package/dist/{vega-component-kU4hFYYJ.js → vega-component-BFcH2SqR.js} +8 -8
- package/package.json +1 -1
- package/src/components/app-config/user-config-form.tsx +14 -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/chrome/components/contribute-snippet-button.tsx +22 -103
- package/src/components/editor/controls/duplicate-shortcut-banner.tsx +50 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +25 -2
- package/src/components/editor/notebook-banner.tsx +1 -1
- package/src/components/editor/notebook-cell.tsx +4 -3
- package/src/components/editor/output/__tests__/ansi-reduce.test.ts +6 -6
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +3 -3
- package/src/components/pages/home-page.tsx +6 -0
- package/src/components/scratchpad/scratchpad.tsx +2 -1
- package/src/core/constants.ts +10 -0
- package/src/core/layout/useTogglePresenting.ts +69 -25
- package/src/core/state/__mocks__/mocks.ts +1 -0
- package/src/hooks/__tests__/useDuplicateShortcuts.test.ts +449 -0
- package/src/hooks/useDuplicateShortcuts.ts +145 -0
- package/src/plugins/impl/NumberPlugin.tsx +1 -1
- package/src/plugins/impl/__tests__/NumberPlugin.test.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/NavigationMenuPlugin.tsx +24 -22
- package/src/plugins/layout/StatPlugin.tsx +43 -23
- package/src/utils/__tests__/data-views.test.ts +495 -13
- package/src/utils/__tests__/json-parser.test.ts +1 -1
- package/src/utils/data-views.ts +134 -16
- package/src/utils/json/base64.ts +8 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* Copyright 2024 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type {
|
|
5
|
+
HotkeyAction,
|
|
6
|
+
HotkeyGroup,
|
|
7
|
+
HotkeyProvider,
|
|
8
|
+
} from "@/core/hotkeys/hotkeys";
|
|
9
|
+
|
|
10
|
+
export interface DuplicateGroup {
|
|
11
|
+
key: string;
|
|
12
|
+
actions: { action: HotkeyAction; name: string }[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DuplicateShortcutsResult {
|
|
16
|
+
/** All groups of duplicate shortcuts */
|
|
17
|
+
duplicates: DuplicateGroup[];
|
|
18
|
+
/** Check if a specific action has duplicate shortcuts */
|
|
19
|
+
hasDuplicate: (action: HotkeyAction) => boolean;
|
|
20
|
+
/** Get all actions that share the same shortcut as the given action */
|
|
21
|
+
getDuplicatesFor: (action: HotkeyAction) => HotkeyAction[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes a keyboard shortcut key for comparison.
|
|
26
|
+
* - Converts to lowercase
|
|
27
|
+
* - Replaces + with - for consistent comparison
|
|
28
|
+
* - Trims whitespace
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeShortcutKey(key: string): string {
|
|
31
|
+
return key.toLowerCase().replaceAll("+", "-").trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detects duplicate keyboard shortcuts in a hotkey provider.
|
|
36
|
+
* Returns information about which shortcuts are duplicated and provides utilities
|
|
37
|
+
* to check if specific actions have duplicates.
|
|
38
|
+
*
|
|
39
|
+
* This is a pure function that can be tested independently of React.
|
|
40
|
+
*
|
|
41
|
+
* @param hotkeys - The hotkey provider to check for duplicates
|
|
42
|
+
* @param ignoreGroup - Optional group to exclude from duplicate detection (e.g., "Markdown")
|
|
43
|
+
*/
|
|
44
|
+
export function findDuplicateShortcuts(
|
|
45
|
+
hotkeys: HotkeyProvider,
|
|
46
|
+
ignoreGroup?: HotkeyGroup,
|
|
47
|
+
): DuplicateShortcutsResult {
|
|
48
|
+
// Get all groups to check for ignored actions
|
|
49
|
+
const groups = hotkeys.getHotkeyGroups();
|
|
50
|
+
const ignoredActions = ignoreGroup
|
|
51
|
+
? new Set(groups[ignoreGroup] || [])
|
|
52
|
+
: new Set();
|
|
53
|
+
|
|
54
|
+
// Group actions by their key binding
|
|
55
|
+
const keyMap = new Map<string, { action: HotkeyAction; name: string }[]>();
|
|
56
|
+
|
|
57
|
+
for (const action of hotkeys.iterate()) {
|
|
58
|
+
// Skip actions in ignored groups
|
|
59
|
+
if (ignoredActions.has(action)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hotkey = hotkeys.getHotkey(action);
|
|
64
|
+
|
|
65
|
+
// Skip empty keys (not set)
|
|
66
|
+
if (!hotkey.key || hotkey.key.trim() === "") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const normalizedKey = normalizeShortcutKey(hotkey.key);
|
|
71
|
+
|
|
72
|
+
if (!keyMap.has(normalizedKey)) {
|
|
73
|
+
keyMap.set(normalizedKey, []);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const existing = keyMap.get(normalizedKey);
|
|
77
|
+
if (existing) {
|
|
78
|
+
existing.push({
|
|
79
|
+
action,
|
|
80
|
+
name: hotkey.name,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Filter to only groups with duplicates (more than one action per key)
|
|
86
|
+
const duplicates: DuplicateGroup[] = [];
|
|
87
|
+
const duplicateActionSet = new Set<HotkeyAction>();
|
|
88
|
+
|
|
89
|
+
for (const [key, actions] of keyMap.entries()) {
|
|
90
|
+
if (actions.length > 1) {
|
|
91
|
+
duplicates.push({ key, actions });
|
|
92
|
+
for (const { action } of actions) {
|
|
93
|
+
duplicateActionSet.add(action);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Helper to check if an action has duplicates
|
|
99
|
+
const hasDuplicate = (action: HotkeyAction): boolean => {
|
|
100
|
+
return duplicateActionSet.has(action);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Helper to get all duplicates for a specific action
|
|
104
|
+
const getDuplicatesFor = (action: HotkeyAction): HotkeyAction[] => {
|
|
105
|
+
const hotkey = hotkeys.getHotkey(action);
|
|
106
|
+
if (!hotkey.key || hotkey.key.trim() === "") {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const normalizedKey = normalizeShortcutKey(hotkey.key);
|
|
111
|
+
|
|
112
|
+
const group = duplicates.find((d) => d.key === normalizedKey);
|
|
113
|
+
if (!group || group.actions.length <= 1) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return group.actions
|
|
118
|
+
.filter((a) => a.action !== action)
|
|
119
|
+
.map((a) => a.action);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
duplicates,
|
|
124
|
+
hasDuplicate,
|
|
125
|
+
getDuplicatesFor,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Hook to detect duplicate keyboard shortcuts.
|
|
131
|
+
* Returns information about which shortcuts are duplicated and provides utilities
|
|
132
|
+
* to check if specific actions have duplicates.
|
|
133
|
+
*
|
|
134
|
+
* @param hotkeys - The hotkey provider to check for duplicates
|
|
135
|
+
* @param ignoreGroup - Optional group to exclude from duplicate detection (e.g., "Markdown")
|
|
136
|
+
*/
|
|
137
|
+
export function useDuplicateShortcuts(
|
|
138
|
+
hotkeys: HotkeyProvider,
|
|
139
|
+
ignoreGroup?: HotkeyGroup,
|
|
140
|
+
): DuplicateShortcutsResult {
|
|
141
|
+
return useMemo(
|
|
142
|
+
() => findDuplicateShortcuts(hotkeys, ignoreGroup),
|
|
143
|
+
[hotkeys, ignoreGroup],
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -79,7 +79,7 @@ const NumberComponent = (props: NumberComponentProps): JSX.Element => {
|
|
|
79
79
|
// This needs to be `?? NaN` since `?? undefined` makes uncontrolled component
|
|
80
80
|
// and can lead to leaving the old value in forms (https://github.com/marimo-team/marimo/issues/7352)
|
|
81
81
|
// We out NaNs later
|
|
82
|
-
value={value ?? NaN}
|
|
82
|
+
value={value ?? Number.NaN}
|
|
83
83
|
step={props.step}
|
|
84
84
|
onChange={handleChange}
|
|
85
85
|
id={id}
|
|
@@ -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
|
|