@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.
Files changed (43) hide show
  1. package/dist/{constants-DWBOe162.js → constants-D_G8vnDk.js} +5 -4
  2. package/dist/{formats-7RSCCoSI.js → formats-Bi_tbdwB.js} +21 -22
  3. package/dist/{glide-data-editor-D-Ia_Jsv.js → glide-data-editor-DXF8E-QD.js} +2 -2
  4. package/dist/main.js +280 -148
  5. package/dist/style.css +1 -1
  6. package/dist/{types-Dunk85GC.js → types-DclGb0Yh.js} +1 -1
  7. package/dist/{vega-component-kU4hFYYJ.js → vega-component-BFcH2SqR.js} +8 -8
  8. package/package.json +1 -1
  9. package/src/components/app-config/user-config-form.tsx +14 -1
  10. package/src/components/data-table/context-menu.tsx +7 -3
  11. package/src/components/data-table/filter-pills.tsx +2 -1
  12. package/src/components/data-table/filters.ts +11 -2
  13. package/src/components/editor/cell/CreateCellButton.tsx +5 -3
  14. package/src/components/editor/cell/collapse.tsx +2 -2
  15. package/src/components/editor/chrome/components/contribute-snippet-button.tsx +22 -103
  16. package/src/components/editor/controls/duplicate-shortcut-banner.tsx +50 -0
  17. package/src/components/editor/controls/keyboard-shortcuts.tsx +25 -2
  18. package/src/components/editor/notebook-banner.tsx +1 -1
  19. package/src/components/editor/notebook-cell.tsx +4 -3
  20. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +6 -6
  21. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +3 -3
  22. package/src/components/pages/home-page.tsx +6 -0
  23. package/src/components/scratchpad/scratchpad.tsx +2 -1
  24. package/src/core/constants.ts +10 -0
  25. package/src/core/layout/useTogglePresenting.ts +69 -25
  26. package/src/core/state/__mocks__/mocks.ts +1 -0
  27. package/src/hooks/__tests__/useDuplicateShortcuts.test.ts +449 -0
  28. package/src/hooks/useDuplicateShortcuts.ts +145 -0
  29. package/src/plugins/impl/NumberPlugin.tsx +1 -1
  30. package/src/plugins/impl/__tests__/NumberPlugin.test.tsx +1 -1
  31. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +67 -47
  32. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +2 -57
  33. package/src/plugins/impl/anywidget/__tests__/model.test.ts +23 -19
  34. package/src/plugins/impl/anywidget/model.ts +68 -41
  35. package/src/plugins/impl/data-frames/utils/__tests__/operators.test.ts +2 -0
  36. package/src/plugins/impl/data-frames/utils/operators.ts +1 -0
  37. package/src/plugins/impl/vega/vega.css +5 -0
  38. package/src/plugins/layout/NavigationMenuPlugin.tsx +24 -22
  39. package/src/plugins/layout/StatPlugin.tsx +43 -23
  40. package/src/utils/__tests__/data-views.test.ts +495 -13
  41. package/src/utils/__tests__/json-parser.test.ts +1 -1
  42. package/src/utils/data-views.ts +134 -16
  43. package/src/utils/json/base64.ts +8 -0
@@ -2,14 +2,14 @@
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
4
  import type { AnyModel } from "@anywidget/types";
5
- import { dequal } from "dequal";
6
5
  import { debounce } from "lodash-es";
7
6
  import { z } from "zod";
8
7
  import { getRequestClient } from "@/core/network/requests";
9
8
  import { assertNever } from "@/utils/assertNever";
10
9
  import { Deferred } from "@/utils/Deferred";
11
- import { updateBufferPaths } from "@/utils/data-views";
10
+ import { decodeFromWire, serializeBuffersToBase64 } from "@/utils/data-views";
12
11
  import { throwNotImplemented } from "@/utils/functions";
12
+ import type { Base64String } from "@/utils/json/base64";
13
13
  import { Logger } from "@/utils/Logger";
14
14
 
15
15
  export type EventHandler = (...args: any[]) => void;
@@ -63,28 +63,30 @@ export const MODEL_MANAGER = new ModelManager();
63
63
 
64
64
  export class Model<T extends Record<string, any>> implements AnyModel<T> {
65
65
  private ANY_CHANGE_EVENT = "change";
66
- private dirtyFields;
66
+ private dirtyFields: Map<keyof T, unknown>;
67
67
  public static _modelManager: ModelManager = MODEL_MANAGER;
68
68
  private data: T;
69
69
  private onChange: (value: Partial<T>) => void;
70
70
  private sendToWidget: (req: {
71
- content?: any;
72
- buffers?: ArrayBuffer[] | ArrayBufferView[];
71
+ content: unknown;
72
+ buffers: Base64String[];
73
73
  }) => Promise<null | undefined>;
74
74
 
75
75
  constructor(
76
76
  data: T,
77
77
  onChange: (value: Partial<T>) => void,
78
78
  sendToWidget: (req: {
79
- content?: any;
80
- buffers?: ArrayBuffer[] | ArrayBufferView[];
79
+ content: unknown;
80
+ buffers: Base64String[];
81
81
  }) => Promise<null | undefined>,
82
82
  initialDirtyFields: Set<keyof T>,
83
83
  ) {
84
84
  this.data = data;
85
85
  this.onChange = onChange;
86
86
  this.sendToWidget = sendToWidget;
87
- this.dirtyFields = new Set(initialDirtyFields);
87
+ this.dirtyFields = new Map(
88
+ [...initialDirtyFields].map((key) => [key, this.data[key]]),
89
+ );
88
90
  }
89
91
 
90
92
  private listeners: Record<string, Set<EventHandler>> = {};
@@ -106,12 +108,16 @@ export class Model<T extends Record<string, any>> implements AnyModel<T> {
106
108
  send(
107
109
  content: any,
108
110
  callbacks?: any,
109
- buffers?: ArrayBuffer[] | ArrayBufferView[],
111
+ _buffers?: ArrayBuffer[] | ArrayBufferView[],
110
112
  ): void {
111
- if (buffers) {
112
- Logger.warn("buffers not supported in marimo anywidget.send");
113
- }
114
- this.sendToWidget({ content, buffers }).then(callbacks);
113
+ const { state, bufferPaths, buffers } = serializeBuffersToBase64(content);
114
+ this.sendToWidget({
115
+ content: {
116
+ state: state,
117
+ bufferPaths: bufferPaths,
118
+ },
119
+ buffers: buffers,
120
+ }).then(callbacks);
115
121
  }
116
122
 
117
123
  widget_manager = {
@@ -134,7 +140,7 @@ export class Model<T extends Record<string, any>> implements AnyModel<T> {
134
140
 
135
141
  set<K extends keyof T>(key: K, value: T[K]): void {
136
142
  this.data = { ...this.data, [key]: value };
137
- this.dirtyFields.add(key);
143
+ this.dirtyFields.set(key, value);
138
144
  this.emit(`change:${key as K & string}`, value);
139
145
  this.emitAnyChange();
140
146
  }
@@ -143,24 +149,25 @@ export class Model<T extends Record<string, any>> implements AnyModel<T> {
143
149
  if (this.dirtyFields.size === 0) {
144
150
  return;
145
151
  }
146
- const partialData: Partial<T> = {};
147
- this.dirtyFields.forEach((key) => {
148
- partialData[key] = this.data[key];
149
- });
150
- // We don't clear the dirty fields here, because we want
151
- // to send all fields that different from the initial value (have ever been changed).
152
- // This is less performant, but more correct, because the backend
153
- // stores the last value sent, and not a merge of the values.
154
- // When the backend knows to merge the partial updates, then we can clear
155
- // the dirty fields.
156
- // this.dirtyFields.clear();
152
+ // Only send the dirty fields, not the entire state.
153
+ const partialData = Object.fromEntries(
154
+ this.dirtyFields.entries(),
155
+ ) as Partial<T>;
156
+
157
+ // Clear the dirty fields to avoid sending again.
158
+ this.dirtyFields.clear();
157
159
  this.onChange(partialData);
158
160
  }
159
161
 
160
162
  updateAndEmitDiffs(value: T): void {
163
+ if (value == null) {
164
+ return;
165
+ }
166
+
161
167
  Object.keys(value).forEach((key) => {
162
168
  const k = key as keyof T;
163
- if (!dequal(this.data[k], value[k])) {
169
+ // Shallow equal since these can be large objects
170
+ if (this.data[k] !== value[k]) {
164
171
  this.set(k, value[k]);
165
172
  }
166
173
  });
@@ -170,13 +177,22 @@ export class Model<T extends Record<string, any>> implements AnyModel<T> {
170
177
  * When receiving a message from the backend.
171
178
  * We want to notify all listeners with `msg:custom`
172
179
  */
173
- receiveCustomMessage(message: any, buffers: readonly DataView[] = []): void {
180
+ receiveCustomMessage(
181
+ message: unknown,
182
+ buffers: readonly DataView[] = [],
183
+ ): void {
174
184
  const response = AnyWidgetMessageSchema.safeParse(message);
175
185
  if (response.success) {
176
186
  const data = response.data;
177
187
  switch (data.method) {
178
188
  case "update":
179
- this.updateAndEmitDiffs(data.state as T);
189
+ this.updateAndEmitDiffs(
190
+ decodeFromWire<T>({
191
+ state: data.state as T,
192
+ bufferPaths: data.buffer_paths ?? [],
193
+ buffers,
194
+ }),
195
+ );
180
196
  break;
181
197
  case "custom":
182
198
  this.listeners["msg:custom"]?.forEach((cb) =>
@@ -184,7 +200,19 @@ export class Model<T extends Record<string, any>> implements AnyModel<T> {
184
200
  );
185
201
  break;
186
202
  case "open":
187
- this.updateAndEmitDiffs(data.state as T);
203
+ this.updateAndEmitDiffs(
204
+ decodeFromWire<T>({
205
+ state: data.state as T,
206
+ bufferPaths: data.buffer_paths ?? [],
207
+ buffers,
208
+ }),
209
+ );
210
+ break;
211
+ case "echo_update":
212
+ // We don't need to do anything with this message
213
+ break;
214
+ default:
215
+ Logger.error("[anywidget] Unknown message method", data.method);
188
216
  break;
189
217
  }
190
218
  } else {
@@ -269,7 +297,7 @@ export async function handleWidgetMessage({
269
297
 
270
298
  if (msg.method === "custom") {
271
299
  const model = await modelManager.get(modelId);
272
- model.receiveCustomMessage(msg);
300
+ model.receiveCustomMessage(msg, buffers);
273
301
  return;
274
302
  }
275
303
 
@@ -279,24 +307,23 @@ export async function handleWidgetMessage({
279
307
  }
280
308
 
281
309
  const { method, state, buffer_paths = [] } = msg;
282
- const stateWithBuffers = updateBufferPaths(state, buffer_paths, buffers);
310
+ const stateWithBuffers = decodeFromWire({
311
+ state,
312
+ bufferPaths: buffer_paths,
313
+ buffers,
314
+ });
283
315
 
284
316
  if (method === "open") {
285
317
  const handleDataChange = (changeData: Record<string, any>) => {
286
- if (buffer_paths) {
287
- Logger.warn(
288
- "Changed data with buffer paths may not be supported",
289
- changeData,
290
- );
291
- // TODO: we may want to extract/undo DataView, to get back buffers and buffer_paths
292
- }
318
+ const { state, buffers, bufferPaths } =
319
+ serializeBuffersToBase64(changeData);
293
320
  getRequestClient().sendModelValue({
294
321
  modelId: modelId,
295
322
  message: {
296
- state: changeData,
297
- bufferPaths: [],
323
+ state,
324
+ bufferPaths,
298
325
  },
299
- buffers: [],
326
+ buffers,
300
327
  });
301
328
  };
302
329
 
@@ -100,12 +100,14 @@ describe("isConditionValueValid", () => {
100
100
  );
101
101
  expect(isConditionValueValid("contains", "test")).toBe(true);
102
102
  expect(isConditionValueValid("in", ["test"])).toBe(true);
103
+ expect(isConditionValueValid("not_in", ["test"])).toBe(true);
103
104
  });
104
105
 
105
106
  it("should return false if the value is not valid according to the schema for the given operator", () => {
106
107
  expect(isConditionValueValid("==", "not a number")).toBe(false);
107
108
  expect(isConditionValueValid("contains", 123)).toBe(false);
108
109
  expect(isConditionValueValid("in", "not an array")).toBe(false);
110
+ expect(isConditionValueValid("not_in", "not an array")).toBe(false);
109
111
  });
110
112
 
111
113
  it("should return true if the operator does not require a value", () => {
@@ -58,6 +58,7 @@ export const STRING_OPERATORS = {
58
58
  starts_with: [Schema.string],
59
59
  ends_with: [Schema.string],
60
60
  in: [Schema.stringMultiColumnValues],
61
+ not_in: [Schema.stringMultiColumnValues],
61
62
  is_null: [],
62
63
  is_not_null: [],
63
64
  };
@@ -8,6 +8,11 @@
8
8
  @media (min-width: 500px) {
9
9
  min-width: 300px;
10
10
  }
11
+
12
+ /* For vega embeds in slots, reset the styles to let the user set the width */
13
+ @container style(--slot: true) {
14
+ min-width: unset;
15
+ }
11
16
  }
12
17
 
13
18
  .vega-embed > .chart-wrapper {
@@ -113,28 +113,30 @@ const NavMenuComponent = ({
113
113
  const renderMenuItem = (item: MenuItem | MenuItemGroup) => {
114
114
  if ("items" in item) {
115
115
  return orientation === "horizontal" ? (
116
- <NavigationMenuItem key={item.label}>
117
- <NavigationMenuTrigger>
118
- {renderHTML({ html: item.label })}
119
- </NavigationMenuTrigger>
120
- <NavigationMenuContent>
121
- <NavigationMenuList>
122
- <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
123
- {item.items.map((subItem) => (
124
- <ListItem
125
- key={subItem.label}
126
- label={subItem.label}
127
- href={preserveQueryParams(subItem.href)}
128
- target={target(subItem.href)}
129
- >
130
- {subItem.description &&
131
- renderHTML({ html: subItem.description })}
132
- </ListItem>
133
- ))}
134
- </ul>
135
- </NavigationMenuList>
136
- </NavigationMenuContent>
137
- </NavigationMenuItem>
116
+ <NavigationMenu orientation="horizontal" key={item.label}>
117
+ <NavigationMenuList>
118
+ <NavigationMenuItem>
119
+ <NavigationMenuTrigger>
120
+ {renderHTML({ html: item.label })}
121
+ </NavigationMenuTrigger>
122
+ <NavigationMenuContent>
123
+ <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
124
+ {item.items.map((subItem) => (
125
+ <ListItem
126
+ key={subItem.label}
127
+ label={subItem.label}
128
+ href={preserveQueryParams(subItem.href)}
129
+ target={target(subItem.href)}
130
+ >
131
+ {subItem.description &&
132
+ renderHTML({ html: subItem.description })}
133
+ </ListItem>
134
+ ))}
135
+ </ul>
136
+ </NavigationMenuContent>
137
+ </NavigationMenuItem>
138
+ </NavigationMenuList>
139
+ </NavigationMenu>
138
140
  ) : (
139
141
  <NavigationMenuItem key={item.label}>
140
142
  <div
@@ -4,8 +4,11 @@ import { TriangleIcon } from "lucide-react";
4
4
  import type { JSX } from "react";
5
5
  import { useLocale } from "react-aria";
6
6
  import { z } from "zod";
7
+ import { getMimeValues } from "@/components/data-table/mime-cell";
7
8
  import { cn } from "@/utils/cn";
9
+ import { Logger } from "@/utils/Logger";
8
10
  import { prettyNumber } from "@/utils/numbers";
11
+ import { renderHTML } from "../core/RenderHTML";
9
12
  import type {
10
13
  IStatelessPlugin,
11
14
  IStatelessPluginProps,
@@ -18,6 +21,7 @@ interface Data {
18
21
  bordered?: boolean;
19
22
  direction?: "increase" | "decrease";
20
23
  target_direction?: "increase" | "decrease";
24
+ slot?: object;
21
25
  }
22
26
 
23
27
  export class StatPlugin implements IStatelessPlugin<Data> {
@@ -30,6 +34,7 @@ export class StatPlugin implements IStatelessPlugin<Data> {
30
34
  bordered: z.boolean().default(false),
31
35
  direction: z.enum(["increase", "decrease"]).optional(),
32
36
  target_direction: z.enum(["increase", "decrease"]).default("increase"),
37
+ slot: z.any().optional(),
33
38
  });
34
39
 
35
40
  render({ data }: IStatelessPluginProps<Data>): JSX.Element {
@@ -44,6 +49,7 @@ export const StatComponent: React.FC<Data> = ({
44
49
  bordered,
45
50
  direction,
46
51
  target_direction,
52
+ slot,
47
53
  }) => {
48
54
  const { locale } = useLocale();
49
55
 
@@ -71,39 +77,53 @@ export const StatComponent: React.FC<Data> = ({
71
77
  const fillColor = onTarget ? "var(--grass-8)" : "var(--red-8)";
72
78
  const strokeColor = onTarget ? "var(--grass-9)" : "var(--red-9)";
73
79
 
80
+ const renderSlot = () => {
81
+ const mimeValues = getMimeValues(slot);
82
+ if (mimeValues?.[0]) {
83
+ const { mimetype, data } = mimeValues[0];
84
+ if (mimetype !== "text/html") {
85
+ Logger.warn(`Expected text/html, got ${mimetype}`);
86
+ }
87
+ return renderHTML({ html: data, alwaysSanitizeHtml: true });
88
+ }
89
+ };
90
+
74
91
  return (
75
92
  <div
76
93
  className={cn(
77
- "text-card-foreground",
94
+ "text-card-foreground p-6",
78
95
  bordered && "rounded-xl border shadow bg-card",
79
96
  )}
80
97
  >
81
98
  {label && (
82
- <div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
99
+ <div className="flex flex-row items-center justify-between space-y-0 pb-2">
83
100
  <h3 className="tracking-tight text-sm font-medium">{label}</h3>
84
101
  </div>
85
102
  )}
86
- <div className="p-6 pt-0">
87
- <div className="text-2xl font-bold">{renderPrettyValue()}</div>
88
- {caption && (
89
- <p className="pt-1 text-xs text-muted-foreground flex align-center">
90
- {direction === "increase" && (
91
- <TriangleIcon
92
- className="w-4 h-4 mr-1 p-0.5"
93
- fill={fillColor}
94
- stroke={strokeColor}
95
- />
96
- )}
97
- {direction === "decrease" && (
98
- <TriangleIcon
99
- className="w-4 h-4 mr-1 p-0.5 transform rotate-180"
100
- fill={fillColor}
101
- stroke={strokeColor}
102
- />
103
- )}
104
- {caption}
105
- </p>
106
- )}
103
+ <div className="pt-0 flex flex-row gap-3.5">
104
+ <div>
105
+ <div className="text-2xl font-bold">{renderPrettyValue()}</div>
106
+ {caption && (
107
+ <p className="pt-1 text-xs text-muted-foreground flex align-center whitespace-nowrap">
108
+ {direction === "increase" && (
109
+ <TriangleIcon
110
+ className="w-4 h-4 mr-1 p-0.5"
111
+ fill={fillColor}
112
+ stroke={strokeColor}
113
+ />
114
+ )}
115
+ {direction === "decrease" && (
116
+ <TriangleIcon
117
+ className="w-4 h-4 mr-1 p-0.5 transform rotate-180"
118
+ fill={fillColor}
119
+ stroke={strokeColor}
120
+ />
121
+ )}
122
+ {caption}
123
+ </p>
124
+ )}
125
+ </div>
126
+ {slot && <div className="[--slot:true]">{renderSlot()}</div>}
107
127
  </div>
108
128
  </div>
109
129
  );