@marimo-team/islands 0.19.7-dev23 → 0.19.7-dev25
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/{Combination-BOmAhdTT.js → Combination-Bg-xN8JV.js} +1 -1
- package/dist/{ConnectedDataExplorerComponent-BW-EEUju.js → ConnectedDataExplorerComponent-CukLYFVV.js} +9 -9
- package/dist/{any-language-editor-jlqKouk6.js → any-language-editor-DZc6NCTp.js} +4 -4
- package/dist/{button-D6ZIdUA3.js → button-BWvsJ2Wr.js} +1 -1
- package/dist/{check-S8ldILuD.js → check-CM_kewwn.js} +1 -1
- package/dist/{copy-ACOJ1BXr.js → copy-B59Bw3-w.js} +2 -2
- package/dist/{error-banner-uJ4xh94e.js → error-banner-C7KLpECd.js} +3 -3
- package/dist/{esm-DNwkt4aO.js → esm-D4WO8J3G.js} +4 -4
- package/dist/{glide-data-editor-QbwryjAp.js → glide-data-editor-uGGDZv6S.js} +7 -7
- package/dist/{hotkeys-C4e3s3sJ.js → hotkeys-B5WnGZXF.js} +3 -0
- package/dist/{label-o68OaAQk.js → label-DC2pbeUJ.js} +4 -4
- package/dist/{loader-WBQoIunJ.js → loader-B0KEFFi-.js} +1 -1
- package/dist/main.js +522 -519
- package/dist/{mermaid-CDdsQEyr.js → mermaid-Bqp2Xw99.js} +3 -3
- package/dist/{slides-component-BGEYjTca.js → slides-component-BVjvNo92.js} +2 -2
- package/dist/{spec-CuHgWmcK.js → spec-C9rnT0AN.js} +5 -5
- package/dist/style.css +1 -1
- package/dist/{types-BIvEidLq.js → types-ZLLMdAtn.js} +6 -6
- package/dist/{useAsyncData-DFBEii2l.js → useAsyncData-BjNwqCfS.js} +1 -1
- package/dist/{useDeepCompareMemoize-HKwezUBx.js → useDeepCompareMemoize-CfoxVor3.js} +5 -5
- package/dist/{useIframeCapabilities-wWXCXV88.js → useIframeCapabilities-BBO_R0ww.js} +1 -1
- package/dist/{useTheme-D0zT6HZe.js → useTheme-BYG2SH8J.js} +1 -1
- package/dist/{vega-component-2gqZksJH.js → vega-component-rDX7xwxH.js} +8 -8
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +5 -3
- package/src/components/editor/header/filename-form.tsx +15 -2
- package/src/components/editor/navigation/__tests__/navigation.test.ts +2 -0
- package/src/components/ui/progress.tsx +22 -5
- package/src/core/export/__tests__/hooks.test.ts +42 -19
- package/src/core/export/hooks.ts +33 -32
- package/src/core/saving/save-component.tsx +1 -0
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/__tests__/objects.test.ts +263 -0
- package/src/utils/__tests__/progress.test.ts +156 -0
- package/src/utils/download.ts +7 -2
- package/src/utils/objects.ts +3 -0
- package/src/utils/progress.ts +61 -0
- package/src/utils/toast-progress.tsx +41 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Objects } from "../objects";
|
|
4
|
+
|
|
5
|
+
describe("Objects", () => {
|
|
6
|
+
describe("EMPTY", () => {
|
|
7
|
+
it("should be an empty frozen object", () => {
|
|
8
|
+
expect(Objects.EMPTY).toEqual({});
|
|
9
|
+
expect(Object.isFrozen(Objects.EMPTY)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("mapValues", () => {
|
|
14
|
+
it("should map values of an object", () => {
|
|
15
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
16
|
+
const result = Objects.mapValues(obj, (v) => v * 2);
|
|
17
|
+
expect(result).toEqual({ a: 2, b: 4, c: 6 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should pass key as second argument", () => {
|
|
21
|
+
const obj = { a: 1, b: 2 };
|
|
22
|
+
const result = Objects.mapValues(obj, (v, k) => `${k}:${v}`);
|
|
23
|
+
expect(result).toEqual({ a: "a:1", b: "b:2" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle empty objects", () => {
|
|
27
|
+
const result = Objects.mapValues({}, (v) => v);
|
|
28
|
+
expect(result).toEqual({});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return falsy input unchanged", () => {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
expect(Objects.mapValues(null as any, (v) => v)).toBe(null);
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
expect(Objects.mapValues(undefined as any, (v) => v)).toBe(undefined);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("fromEntries", () => {
|
|
40
|
+
it("should create object from entries", () => {
|
|
41
|
+
const entries: [string, number][] = [
|
|
42
|
+
["a", 1],
|
|
43
|
+
["b", 2],
|
|
44
|
+
];
|
|
45
|
+
expect(Objects.fromEntries(entries)).toEqual({ a: 1, b: 2 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should handle empty entries", () => {
|
|
49
|
+
expect(Objects.fromEntries([])).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should handle numeric keys", () => {
|
|
53
|
+
const entries: [number, string][] = [
|
|
54
|
+
[1, "a"],
|
|
55
|
+
[2, "b"],
|
|
56
|
+
];
|
|
57
|
+
expect(Objects.fromEntries(entries)).toEqual({ 1: "a", 2: "b" });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("entries", () => {
|
|
62
|
+
it("should return entries of an object", () => {
|
|
63
|
+
const obj = { a: 1, b: 2 };
|
|
64
|
+
const entries = Objects.entries(obj);
|
|
65
|
+
expect(entries).toContainEqual(["a", 1]);
|
|
66
|
+
expect(entries).toContainEqual(["b", 2]);
|
|
67
|
+
expect(entries).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should handle empty objects", () => {
|
|
71
|
+
expect(Objects.entries({})).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("keys", () => {
|
|
76
|
+
it("should return keys of an object", () => {
|
|
77
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
78
|
+
const keys = Objects.keys(obj);
|
|
79
|
+
expect(keys).toContain("a");
|
|
80
|
+
expect(keys).toContain("b");
|
|
81
|
+
expect(keys).toContain("c");
|
|
82
|
+
expect(keys).toHaveLength(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should handle empty objects", () => {
|
|
86
|
+
expect(Objects.keys({})).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("size", () => {
|
|
91
|
+
it("should return the number of keys", () => {
|
|
92
|
+
expect(Objects.size({ a: 1, b: 2, c: 3 })).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return 0 for empty objects", () => {
|
|
96
|
+
expect(Objects.size({})).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("keyBy", () => {
|
|
101
|
+
it("should key items by specified key function", () => {
|
|
102
|
+
const items = [
|
|
103
|
+
{ id: "a", value: 1 },
|
|
104
|
+
{ id: "b", value: 2 },
|
|
105
|
+
];
|
|
106
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
a: { id: "a", value: 1 },
|
|
109
|
+
b: { id: "b", value: 2 },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should skip items with undefined keys", () => {
|
|
114
|
+
const items = [
|
|
115
|
+
{ id: "a", value: 1 },
|
|
116
|
+
{ id: undefined as unknown as string, value: 2 },
|
|
117
|
+
{ id: "c", value: 3 },
|
|
118
|
+
];
|
|
119
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
a: { id: "a", value: 1 },
|
|
122
|
+
c: { id: "c", value: 3 },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should handle empty arrays", () => {
|
|
127
|
+
expect(Objects.keyBy([], (item) => item)).toEqual({});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should use last item when keys collide", () => {
|
|
131
|
+
const items = [
|
|
132
|
+
{ id: "a", value: 1 },
|
|
133
|
+
{ id: "a", value: 2 },
|
|
134
|
+
];
|
|
135
|
+
const result = Objects.keyBy(items, (item) => item.id);
|
|
136
|
+
expect(result).toEqual({ a: { id: "a", value: 2 } });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("collect", () => {
|
|
141
|
+
it("should collect and transform items", () => {
|
|
142
|
+
const items = [
|
|
143
|
+
{ id: "a", value: 1 },
|
|
144
|
+
{ id: "b", value: 2 },
|
|
145
|
+
];
|
|
146
|
+
const result = Objects.collect(
|
|
147
|
+
items,
|
|
148
|
+
(item) => item.id,
|
|
149
|
+
(item) => item.value * 2,
|
|
150
|
+
);
|
|
151
|
+
expect(result).toEqual({ a: 2, b: 4 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should handle empty arrays", () => {
|
|
155
|
+
const result = Objects.collect(
|
|
156
|
+
[],
|
|
157
|
+
(item) => item,
|
|
158
|
+
(item) => item,
|
|
159
|
+
);
|
|
160
|
+
expect(result).toEqual({});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("groupBy", () => {
|
|
165
|
+
it("should group items by key", () => {
|
|
166
|
+
const items = [
|
|
167
|
+
{ category: "a", value: 1 },
|
|
168
|
+
{ category: "b", value: 2 },
|
|
169
|
+
{ category: "a", value: 3 },
|
|
170
|
+
];
|
|
171
|
+
const result = Objects.groupBy(
|
|
172
|
+
items,
|
|
173
|
+
(item) => item.category,
|
|
174
|
+
(item) => item.value,
|
|
175
|
+
);
|
|
176
|
+
expect(result).toEqual({
|
|
177
|
+
a: [1, 3],
|
|
178
|
+
b: [2],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should skip items with undefined keys", () => {
|
|
183
|
+
const items = [
|
|
184
|
+
{ category: "a", value: 1 },
|
|
185
|
+
{ category: undefined as unknown as string, value: 2 },
|
|
186
|
+
{ category: "a", value: 3 },
|
|
187
|
+
];
|
|
188
|
+
const result = Objects.groupBy(
|
|
189
|
+
items,
|
|
190
|
+
(item) => item.category,
|
|
191
|
+
(item) => item.value,
|
|
192
|
+
);
|
|
193
|
+
expect(result).toEqual({ a: [1, 3] });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should handle empty arrays", () => {
|
|
197
|
+
const result = Objects.groupBy(
|
|
198
|
+
[],
|
|
199
|
+
(item) => item,
|
|
200
|
+
(item) => item,
|
|
201
|
+
);
|
|
202
|
+
expect(result).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("filter", () => {
|
|
207
|
+
it("should filter object entries by predicate", () => {
|
|
208
|
+
const obj = { a: 1, b: 2, c: 3, d: 4 };
|
|
209
|
+
const result = Objects.filter(obj, (v) => v % 2 === 0);
|
|
210
|
+
expect(result).toEqual({ b: 2, d: 4 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should pass key as second argument", () => {
|
|
214
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
215
|
+
const result = Objects.filter(obj, (_, k) => k !== "b");
|
|
216
|
+
expect(result).toEqual({ a: 1, c: 3 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should handle empty objects", () => {
|
|
220
|
+
const result = Objects.filter({}, () => true);
|
|
221
|
+
expect(result).toEqual({});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should return empty object when nothing matches", () => {
|
|
225
|
+
const obj = { a: 1, b: 2 };
|
|
226
|
+
const result = Objects.filter(obj, () => false);
|
|
227
|
+
expect(result).toEqual({});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("omit", () => {
|
|
232
|
+
it("should omit specified keys from object", () => {
|
|
233
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
234
|
+
const result = Objects.omit(obj, ["b"]);
|
|
235
|
+
expect(result).toEqual({ a: 1, c: 3 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should omit multiple keys", () => {
|
|
239
|
+
const obj = { a: 1, b: 2, c: 3, d: 4 };
|
|
240
|
+
const result = Objects.omit(obj, ["a", "c"]);
|
|
241
|
+
expect(result).toEqual({ b: 2, d: 4 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should handle keys provided as Set", () => {
|
|
245
|
+
const obj = { a: 1, b: 2, c: 3 };
|
|
246
|
+
const result = Objects.omit(obj, new Set(["a", "c"] as const));
|
|
247
|
+
expect(result).toEqual({ b: 2 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("should handle omitting non-existent keys", () => {
|
|
251
|
+
const obj = { a: 1, b: 2 };
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
253
|
+
const result = Objects.omit(obj, ["c" as any]);
|
|
254
|
+
expect(result).toEqual({ a: 1, b: 2 });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should return all properties when omitting empty array", () => {
|
|
258
|
+
const obj = { a: 1, b: 2 };
|
|
259
|
+
const result = Objects.omit(obj, []);
|
|
260
|
+
expect(result).toEqual({ a: 1, b: 2 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ProgressState } from "../progress";
|
|
4
|
+
|
|
5
|
+
describe("ProgressState", () => {
|
|
6
|
+
describe("constructor", () => {
|
|
7
|
+
it("should initialize with a numeric total", () => {
|
|
8
|
+
const progress = new ProgressState(100);
|
|
9
|
+
expect(progress.getProgress()).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should initialize with indeterminate total", () => {
|
|
13
|
+
const progress = new ProgressState("indeterminate");
|
|
14
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("static indeterminate", () => {
|
|
19
|
+
it("should create an indeterminate progress state", () => {
|
|
20
|
+
const progress = ProgressState.indeterminate();
|
|
21
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("addTotal", () => {
|
|
26
|
+
it("should add to the total when numeric", () => {
|
|
27
|
+
const progress = new ProgressState(100);
|
|
28
|
+
progress.addTotal(50);
|
|
29
|
+
// Progress is 0, total is now 150
|
|
30
|
+
expect(progress.getProgress()).toBe(0);
|
|
31
|
+
progress.increment(75);
|
|
32
|
+
expect(progress.getProgress()).toBe(50); // 75/150 * 100 = 50
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should convert indeterminate to numeric when adding total", () => {
|
|
36
|
+
const progress = ProgressState.indeterminate();
|
|
37
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
38
|
+
progress.addTotal(100);
|
|
39
|
+
expect(progress.getProgress()).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("increment", () => {
|
|
44
|
+
it("should increment the progress", () => {
|
|
45
|
+
const progress = new ProgressState(100);
|
|
46
|
+
progress.increment(25);
|
|
47
|
+
expect(progress.getProgress()).toBe(25);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should accumulate multiple increments", () => {
|
|
51
|
+
const progress = new ProgressState(100);
|
|
52
|
+
progress.increment(25);
|
|
53
|
+
progress.increment(25);
|
|
54
|
+
progress.increment(25);
|
|
55
|
+
expect(progress.getProgress()).toBe(75);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should allow progress beyond 100%", () => {
|
|
59
|
+
const progress = new ProgressState(100);
|
|
60
|
+
progress.increment(150);
|
|
61
|
+
expect(progress.getProgress()).toBe(150);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("getProgress", () => {
|
|
66
|
+
it("should return indeterminate for indeterminate state", () => {
|
|
67
|
+
const progress = ProgressState.indeterminate();
|
|
68
|
+
progress.increment(50); // increment has no visible effect
|
|
69
|
+
expect(progress.getProgress()).toBe("indeterminate");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should return correct percentage", () => {
|
|
73
|
+
const progress = new ProgressState(200);
|
|
74
|
+
progress.increment(50);
|
|
75
|
+
expect(progress.getProgress()).toBe(25); // 50/200 * 100 = 25
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should return 0 when no progress made", () => {
|
|
79
|
+
const progress = new ProgressState(100);
|
|
80
|
+
expect(progress.getProgress()).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should return 100 when complete", () => {
|
|
84
|
+
const progress = new ProgressState(100);
|
|
85
|
+
progress.increment(100);
|
|
86
|
+
expect(progress.getProgress()).toBe(100);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("subscribe", () => {
|
|
91
|
+
it("should notify listeners on increment", () => {
|
|
92
|
+
const progress = new ProgressState(100);
|
|
93
|
+
const listener = vi.fn();
|
|
94
|
+
progress.subscribe(listener);
|
|
95
|
+
|
|
96
|
+
progress.increment(25);
|
|
97
|
+
expect(listener).toHaveBeenCalledWith(25);
|
|
98
|
+
|
|
99
|
+
progress.increment(25);
|
|
100
|
+
expect(listener).toHaveBeenCalledWith(50);
|
|
101
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should notify listeners on addTotal", () => {
|
|
105
|
+
const progress = new ProgressState(100);
|
|
106
|
+
const listener = vi.fn();
|
|
107
|
+
progress.subscribe(listener);
|
|
108
|
+
|
|
109
|
+
progress.addTotal(100);
|
|
110
|
+
expect(listener).toHaveBeenCalledWith(0); // 0/200 = 0%
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should notify listeners when converting from indeterminate", () => {
|
|
114
|
+
const progress = ProgressState.indeterminate();
|
|
115
|
+
const listener = vi.fn();
|
|
116
|
+
progress.subscribe(listener);
|
|
117
|
+
|
|
118
|
+
progress.addTotal(100);
|
|
119
|
+
expect(listener).toHaveBeenCalledWith(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should return unsubscribe function", () => {
|
|
123
|
+
const progress = new ProgressState(100);
|
|
124
|
+
const listener = vi.fn();
|
|
125
|
+
const unsubscribe = progress.subscribe(listener);
|
|
126
|
+
|
|
127
|
+
progress.increment(25);
|
|
128
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
129
|
+
|
|
130
|
+
unsubscribe();
|
|
131
|
+
progress.increment(25);
|
|
132
|
+
expect(listener).toHaveBeenCalledTimes(1); // no additional calls
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should support multiple listeners", () => {
|
|
136
|
+
const progress = new ProgressState(100);
|
|
137
|
+
const listener1 = vi.fn();
|
|
138
|
+
const listener2 = vi.fn();
|
|
139
|
+
progress.subscribe(listener1);
|
|
140
|
+
progress.subscribe(listener2);
|
|
141
|
+
|
|
142
|
+
progress.increment(50);
|
|
143
|
+
expect(listener1).toHaveBeenCalledWith(50);
|
|
144
|
+
expect(listener2).toHaveBeenCalledWith(50);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should pass indeterminate to listeners", () => {
|
|
148
|
+
const progress = ProgressState.indeterminate();
|
|
149
|
+
const listener = vi.fn();
|
|
150
|
+
progress.subscribe(listener);
|
|
151
|
+
|
|
152
|
+
progress.increment(50);
|
|
153
|
+
expect(listener).toHaveBeenCalledWith("indeterminate");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/utils/download.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import React from "react";
|
|
3
4
|
import { toast } from "@/components/ui/use-toast";
|
|
4
5
|
import { type CellId, CellOutputId } from "@/core/cells/ids";
|
|
5
6
|
import { getRequestClient } from "@/core/network/requests";
|
|
@@ -9,6 +10,8 @@ import { prettyError } from "./errors";
|
|
|
9
10
|
import { toPng } from "./html-to-image";
|
|
10
11
|
import { captureIframeAsImage } from "./iframe";
|
|
11
12
|
import { Logger } from "./Logger";
|
|
13
|
+
import { ProgressState } from "./progress";
|
|
14
|
+
import { ToastProgress } from "./toast-progress";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Show a loading toast while an async operation is in progress.
|
|
@@ -16,14 +19,16 @@ import { Logger } from "./Logger";
|
|
|
16
19
|
*/
|
|
17
20
|
export async function withLoadingToast<T>(
|
|
18
21
|
title: string,
|
|
19
|
-
fn: () => Promise<T>,
|
|
22
|
+
fn: (progress: ProgressState) => Promise<T>,
|
|
20
23
|
): Promise<T> {
|
|
24
|
+
const progress = ProgressState.indeterminate();
|
|
21
25
|
const loadingToast = toast({
|
|
22
26
|
title,
|
|
27
|
+
description: React.createElement(ToastProgress, { progress }),
|
|
23
28
|
duration: Infinity,
|
|
24
29
|
});
|
|
25
30
|
try {
|
|
26
|
-
const result = await fn();
|
|
31
|
+
const result = await fn(progress);
|
|
27
32
|
loadingToast.dismiss();
|
|
28
33
|
return result;
|
|
29
34
|
} catch (error) {
|
package/src/utils/objects.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const Objects = {
|
|
|
32
32
|
keys<K extends string | number>(obj: Record<K, unknown>): K[] {
|
|
33
33
|
return Object.keys(obj) as K[];
|
|
34
34
|
},
|
|
35
|
+
size<K extends string | number>(obj: Record<K, unknown>): number {
|
|
36
|
+
return Object.keys(obj).length;
|
|
37
|
+
},
|
|
35
38
|
/**
|
|
36
39
|
* Type-safe keyBy
|
|
37
40
|
*/
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
export type ProgressListener = (progress: number | "indeterminate") => void;
|
|
3
|
+
|
|
4
|
+
export class ProgressState {
|
|
5
|
+
private progress = 0;
|
|
6
|
+
private total: number | "indeterminate";
|
|
7
|
+
private listeners = new Set<ProgressListener>();
|
|
8
|
+
|
|
9
|
+
constructor(total: number | "indeterminate") {
|
|
10
|
+
this.total = total;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static indeterminate(): ProgressState {
|
|
14
|
+
return new ProgressState("indeterminate");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
addTotal(total: number) {
|
|
18
|
+
if (this.total === "indeterminate") {
|
|
19
|
+
this.total = total;
|
|
20
|
+
} else {
|
|
21
|
+
this.total += total;
|
|
22
|
+
}
|
|
23
|
+
this.notifyListeners();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update the progress by the given increment.
|
|
28
|
+
*/
|
|
29
|
+
increment(increment: number) {
|
|
30
|
+
this.progress += increment;
|
|
31
|
+
this.notifyListeners();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the progress as a percentage (0-100)
|
|
36
|
+
*/
|
|
37
|
+
getProgress(): number | "indeterminate" {
|
|
38
|
+
if (this.total === "indeterminate") {
|
|
39
|
+
return "indeterminate";
|
|
40
|
+
}
|
|
41
|
+
return (this.progress / this.total) * 100;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subscribe to progress updates.
|
|
46
|
+
* Returns an unsubscribe function.
|
|
47
|
+
*/
|
|
48
|
+
subscribe(listener: ProgressListener): () => void {
|
|
49
|
+
this.listeners.add(listener);
|
|
50
|
+
return () => {
|
|
51
|
+
this.listeners.delete(listener);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private notifyListeners() {
|
|
56
|
+
const progress = this.getProgress();
|
|
57
|
+
for (const listener of this.listeners) {
|
|
58
|
+
listener(progress);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import { Progress } from "@/components/ui/progress";
|
|
5
|
+
import type { ProgressState } from "./progress";
|
|
6
|
+
|
|
7
|
+
interface ToastProgressProps {
|
|
8
|
+
progress: ProgressState;
|
|
9
|
+
showPercentage?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A progress bar component that subscribes to a ProgressState and updates reactively.
|
|
14
|
+
* Designed to be used inside toasts.
|
|
15
|
+
*/
|
|
16
|
+
export const ToastProgress = ({
|
|
17
|
+
progress,
|
|
18
|
+
showPercentage = false,
|
|
19
|
+
}: ToastProgressProps) => {
|
|
20
|
+
const value = useSyncExternalStore(
|
|
21
|
+
(callback) => progress.subscribe(callback),
|
|
22
|
+
() => progress.getProgress(),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// if we are at 100%, we want to show the indeterminate progress bar
|
|
26
|
+
const isIndeterminate = value === "indeterminate" || value === 100;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="mt-2 w-full min-w-[200px]">
|
|
30
|
+
<Progress
|
|
31
|
+
value={isIndeterminate ? undefined : value}
|
|
32
|
+
indeterminate={isIndeterminate}
|
|
33
|
+
/>
|
|
34
|
+
{!isIndeterminate && showPercentage && (
|
|
35
|
+
<div className="mt-1 text-xs text-muted-foreground text-right">
|
|
36
|
+
{Math.round(value)}%
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
};
|