@gradio/uploadbutton 0.9.18 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @gradio/uploadbutton
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Features
6
+
7
+ - [#13502](https://github.com/gradio-app/gradio/pull/13502) [`429faeb`](https://github.com/gradio-app/gradio/commit/429faeb643fb1afc1722c0f63fafa11603f2c87f) - Ensure every component dispatches a `change` event when its value changes. Thanks @abidlabs!
8
+
9
+ ### Dependency updates
10
+
11
+ - @gradio/client@2.2.2
12
+ - @gradio/button@0.7.0
13
+
14
+ ## 0.9.19
15
+
16
+ ### Fixes
17
+
18
+ - [#13336](https://github.com/gradio-app/gradio/pull/13336) [`14c8870`](https://github.com/gradio-app/gradio/commit/14c88701756c5fa57e8eaeac4df1385b9601da45) - Add UploadButton unit tests. Thanks @freddyaboulton!
19
+
20
+ ### Dependency updates
21
+
22
+ - @gradio/client@2.2.1
23
+ - @gradio/upload@0.17.10
24
+
25
+ ## 0.9.18
26
+
27
+ ### Dependency updates
28
+
29
+ - @gradio/upload@0.17.9
30
+
31
+ ## 0.9.18
32
+
33
+ ### Dependency updates
34
+
35
+ - @gradio/client@2.2.0
36
+
37
+ ## 0.9.18
38
+
39
+ ### Dependency updates
40
+
41
+ - @gradio/utils@0.12.2
42
+ - @gradio/upload@0.17.8
43
+
44
+ ## 0.9.18
45
+
46
+ ### Dependency updates
47
+
48
+ - @gradio/utils@0.12.1
49
+ - @gradio/button@0.6.6
50
+
3
51
  ## 0.9.18
4
52
 
5
53
  ### Dependency updates
package/Index.svelte CHANGED
@@ -11,13 +11,7 @@
11
11
  const props = $props();
12
12
  const gradio = new Gradio<UploadButtonEvents, UploadButtonProps>(props);
13
13
 
14
- let value = $state(gradio.props.value);
15
-
16
- $effect(() => {
17
- if (value !== gradio.props.value) {
18
- gradio.props.value = value;
19
- }
20
- });
14
+ let value = $derived(gradio.props.value);
21
15
 
22
16
  async function handle_event(
23
17
  detail: null | FileData | FileData[],
@@ -1,79 +1,436 @@
1
- import { test, describe, expect, vi, afterEach, assert } from "vitest";
2
- import { cleanup, render } from "@self/tootils";
1
+ import { test, describe, afterEach, expect, vi } from "vitest";
2
+ import {
3
+ cleanup,
4
+ render,
5
+ waitFor,
6
+ upload_file,
7
+ mock_client,
8
+ TEST_TXT,
9
+ TEST_JPG
10
+ } from "@self/tootils/render";
11
+ import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
3
12
  import event from "@testing-library/user-event";
4
- import { setupi18n } from "../core/src/i18n";
13
+
5
14
  import UploadButton from "./Index.svelte";
6
15
 
16
+ const base_props = {
17
+ label: "Upload",
18
+ show_label: true,
19
+ interactive: true,
20
+ value: null as any,
21
+ file_count: "single" as "single" | "multiple" | "directory",
22
+ file_types: [] as string[],
23
+ size: "lg" as "sm" | "md" | "lg",
24
+ icon: null as any,
25
+ variant: "secondary" as "primary" | "secondary" | "stop"
26
+ };
27
+
28
+ const upload_props = {
29
+ ...base_props,
30
+ root: "https://example.com",
31
+ client: mock_client()
32
+ };
33
+
34
+ run_shared_prop_tests({
35
+ component: UploadButton,
36
+ name: "UploadButton",
37
+ base_props,
38
+ has_label: false,
39
+ has_validation_error: false,
40
+ has_block_wrapper: false,
41
+ visible_false_hides: true
42
+ });
43
+
7
44
  describe("UploadButton", () => {
8
- afterEach(() => {
9
- cleanup();
10
- vi.restoreAllMocks();
45
+ afterEach(() => cleanup());
46
+
47
+ test("renders a button with the label text", async () => {
48
+ const { getByRole } = await render(UploadButton, {
49
+ ...base_props,
50
+ label: "Upload a File"
51
+ });
52
+
53
+ expect(getByRole("button", { name: "Upload a File" })).toBeVisible();
54
+ });
55
+
56
+ test("button is enabled when interactive is true", async () => {
57
+ const { getByRole } = await render(UploadButton, {
58
+ ...base_props,
59
+ interactive: true
60
+ });
61
+
62
+ expect(getByRole("button", { name: "Upload" })).toBeEnabled();
63
+ });
64
+
65
+ test("button is disabled when interactive is false", async () => {
66
+ const { getByRole } = await render(UploadButton, {
67
+ ...base_props,
68
+ interactive: false
69
+ });
70
+
71
+ expect(getByRole("button", { name: "Upload" })).toBeDisabled();
72
+ });
73
+ });
74
+
75
+ describe("Props: file_count", () => {
76
+ afterEach(() => cleanup());
77
+
78
+ test("file_count='single' returns a single FileData, not an array", async () => {
79
+ const { get_data } = await render(UploadButton, {
80
+ ...upload_props,
81
+ file_count: "single"
82
+ });
83
+
84
+ await upload_file(TEST_TXT);
85
+
86
+ await waitFor(async () => {
87
+ const data = await get_data();
88
+ expect(data.value).not.toBeNull();
89
+ });
90
+ const data = await get_data();
91
+ expect(Array.isArray(data.value)).toBe(false);
92
+ expect(data.value).toHaveProperty("orig_name");
11
93
  });
12
94
 
13
- test.skip("Uploads with blob", async () => {
14
- vi.mock("@gradio/client", async () => {
15
- const actual = await vi.importActual("@gradio/client");
16
- console.log(actual);
95
+ test("file_count='multiple' returns an array of FileData", async () => {
96
+ const { get_data } = await render(UploadButton, {
97
+ ...upload_props,
98
+ file_count: "multiple"
99
+ });
100
+
101
+ await upload_file([TEST_TXT, TEST_JPG]);
102
+
103
+ await waitFor(async () => {
104
+ const data = await get_data();
105
+ expect(data.value).not.toBeNull();
106
+ });
107
+ const data = await get_data();
108
+ expect(Array.isArray(data.value)).toBe(true);
109
+ expect(data.value).toHaveLength(2);
110
+ });
17
111
 
18
- return {
19
- ...actual,
20
- prepare_files: () => [],
21
- // upload: vi.fn((f) => new Promise((res) => res([]))),
22
- upload_files: vi.fn((f) => new Promise((res) => res({})))
23
- };
112
+ // Attribute assertion: directory upload can't be driven programmatically —
113
+ // webkitdirectory is the browser-native mechanism and there's no behavioural
114
+ // equivalent we can simulate in a unit test.
115
+ test("file_count='directory' sets webkitdirectory attribute on input", async () => {
116
+ const { getByTestId } = await render(UploadButton, {
117
+ ...base_props,
118
+ file_count: "directory"
24
119
  });
25
120
 
26
- const api = await import("@gradio/client");
121
+ const input = getByTestId("Upload-upload-button");
122
+ expect(input).toHaveAttribute("webkitdirectory");
123
+ });
124
+ });
27
125
 
28
- setupi18n();
126
+ describe("Props: file_types", () => {
127
+ afterEach(() => cleanup());
29
128
 
129
+ test("file_types maps category names to wildcard MIME types", async () => {
30
130
  const { getByTestId } = await render(UploadButton, {
31
- label: "file",
32
- value: null,
33
- root: "http://localhost:7860",
34
- file_count: "1"
131
+ ...base_props,
132
+ file_types: ["image", "audio"]
35
133
  });
36
134
 
37
- const item = getByTestId("file-upload-button"); // container.querySelectorAll("input")[0];
135
+ const input = getByTestId("Upload-upload-button");
136
+ expect(input).toHaveAttribute("accept", "image/*, audio/*");
137
+ });
38
138
 
39
- const file = new File(["hello"], "my-audio.wav", { type: "audio/wav" });
40
- await event.upload(item, file);
139
+ test("file_types passes dot-prefixed extensions through unchanged", async () => {
140
+ const { getByTestId } = await render(UploadButton, {
141
+ ...base_props,
142
+ file_types: [".csv", ".json"]
143
+ });
41
144
 
42
- expect(api.upload_files).toHaveBeenCalled();
145
+ const input = getByTestId("Upload-upload-button");
146
+ expect(input).toHaveAttribute("accept", ".csv, .json");
43
147
  });
44
148
 
45
- // we need mocks for this test now, no time atm.
46
- test.skip("upload sets change event", async () => {
47
- vi.mock("@gradio/client", async () => {
48
- const actual = await vi.importActual("@gradio/client");
149
+ test("empty file_types array results in empty accept attribute", async () => {
150
+ const { getByTestId } = await render(UploadButton, {
151
+ ...base_props,
152
+ file_types: []
153
+ });
154
+
155
+ const input = getByTestId("Upload-upload-button");
156
+ expect(input).toHaveAttribute("accept", "");
157
+ });
158
+
159
+ test("rejects files that do not match file_types and dispatches error", async () => {
160
+ const { listen } = await render(UploadButton, {
161
+ ...upload_props,
162
+ file_types: ["image"]
163
+ });
164
+
165
+ const upload = listen("upload");
166
+ const error = listen("error");
167
+
168
+ await upload_file(TEST_TXT);
169
+
170
+ await waitFor(() => {
171
+ expect(error).toHaveBeenCalledTimes(1);
172
+ });
173
+ expect(error).toHaveBeenCalledWith(
174
+ expect.stringContaining("Invalid file type")
175
+ );
176
+ expect(upload).not.toHaveBeenCalled();
177
+ });
178
+
179
+ test("accepts files matching a wildcard MIME category", async () => {
180
+ const { listen } = await render(UploadButton, {
181
+ ...upload_props,
182
+ file_types: ["image"]
183
+ });
184
+
185
+ const upload = listen("upload");
186
+
187
+ await upload_file(TEST_JPG);
188
+
189
+ await waitFor(() => {
190
+ expect(upload).toHaveBeenCalledTimes(1);
191
+ });
192
+ });
193
+
194
+ test("accepts files matching a dot-prefixed extension", async () => {
195
+ const { listen } = await render(UploadButton, {
196
+ ...upload_props,
197
+ file_types: [".txt"]
198
+ });
199
+
200
+ const upload = listen("upload");
201
+
202
+ await upload_file(TEST_TXT);
203
+
204
+ await waitFor(() => {
205
+ expect(upload).toHaveBeenCalledTimes(1);
206
+ });
207
+ });
208
+
209
+ test("multiple file_types each allow their matching files", async () => {
210
+ const { listen } = await render(UploadButton, {
211
+ ...upload_props,
212
+ file_types: ["image", ".txt"]
213
+ });
214
+
215
+ const upload = listen("upload");
216
+
217
+ await upload_file(TEST_JPG);
218
+
219
+ await waitFor(() => {
220
+ expect(upload).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ await upload_file(TEST_TXT);
224
+
225
+ await waitFor(() => {
226
+ expect(upload).toHaveBeenCalledTimes(2);
227
+ });
228
+ });
49
229
 
50
- return {
51
- ...actual,
52
- prepare_files: () => [],
53
- // upload: vi.fn((f) => new Promise((res) => res([]))),
54
- upload_files: vi.fn((f) => new Promise((res) => res({})))
55
- };
230
+ test("rejects file not matching any of multiple file_types", async () => {
231
+ const { listen } = await render(UploadButton, {
232
+ ...upload_props,
233
+ file_types: ["image", "audio"]
56
234
  });
57
235
 
58
- await import("@gradio/client");
59
- setupi18n();
60
- const { component, getByTestId, wait_for_event } = await render(
61
- UploadButton,
62
- {
63
- label: "file",
64
- value: null,
65
- root: "http://localhost:7860",
66
- file_count: "1"
236
+ const upload = listen("upload");
237
+ const error = listen("error");
238
+
239
+ await upload_file(TEST_TXT);
240
+
241
+ await waitFor(() => {
242
+ expect(error).toHaveBeenCalledTimes(1);
243
+ });
244
+ expect(upload).not.toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe("Props: icon", () => {
249
+ afterEach(() => cleanup());
250
+
251
+ test("icon renders an image inside the button when provided", async () => {
252
+ const { getByRole } = await render(UploadButton, {
253
+ ...base_props,
254
+ icon: {
255
+ path: "icon.png",
256
+ url: "https://example.com/icon.png",
257
+ orig_name: "icon.png",
258
+ size: 100,
259
+ mime_type: "image/png"
67
260
  }
261
+ });
262
+
263
+ expect(getByRole("img")).toHaveAttribute(
264
+ "src",
265
+ "https://example.com/icon.png"
266
+ );
267
+ });
268
+
269
+ test("no icon rendered when icon is null", async () => {
270
+ const { queryByRole } = await render(UploadButton, {
271
+ ...base_props,
272
+ icon: null
273
+ });
274
+
275
+ expect(queryByRole("img")).not.toBeInTheDocument();
276
+ });
277
+ });
278
+
279
+ describe("Events", () => {
280
+ afterEach(() => cleanup());
281
+
282
+ test("no spurious change event on mount", async () => {
283
+ const { listen } = await render(UploadButton, {
284
+ ...base_props,
285
+ value: null
286
+ });
287
+
288
+ const change = listen("change", { retrospective: true });
289
+ expect(change).not.toHaveBeenCalled();
290
+ });
291
+
292
+ test("click event fires when button is clicked", async () => {
293
+ const { getByRole, listen } = await render(UploadButton, {
294
+ ...upload_props
295
+ });
296
+
297
+ const click = listen("click");
298
+ await event.click(getByRole("button", { name: "Upload" }));
299
+
300
+ expect(click).toHaveBeenCalledTimes(1);
301
+ });
302
+
303
+ test("upload and change events fire after file upload", async () => {
304
+ const { listen } = await render(UploadButton, {
305
+ ...upload_props
306
+ });
307
+
308
+ const upload = listen("upload");
309
+ const change = listen("change");
310
+
311
+ await upload_file(TEST_TXT);
312
+
313
+ await waitFor(() => {
314
+ expect(upload).toHaveBeenCalledTimes(1);
315
+ });
316
+ expect(change).toHaveBeenCalledTimes(1);
317
+ });
318
+
319
+ test("upload failure dispatches error event with the message", async () => {
320
+ const failing_upload = vi
321
+ .fn()
322
+ .mockRejectedValue(new Error("File too large"));
323
+ const { listen } = await render(UploadButton, {
324
+ ...upload_props,
325
+ client: { ...mock_client(), upload: failing_upload }
326
+ });
327
+
328
+ const error = listen("error");
329
+
330
+ await upload_file(TEST_TXT);
331
+
332
+ await waitFor(() => {
333
+ expect(failing_upload).toHaveBeenCalled();
334
+ });
335
+
336
+ await waitFor(() => {
337
+ expect(error).toHaveBeenCalledTimes(1);
338
+ });
339
+ expect(error).toHaveBeenCalledWith("File too large");
340
+ });
341
+ });
342
+
343
+ describe("get_data / set_data", () => {
344
+ afterEach(() => cleanup());
345
+
346
+ test("get_data returns null initially", async () => {
347
+ const { get_data } = await render(UploadButton, {
348
+ ...base_props,
349
+ value: null
350
+ });
351
+
352
+ const data = await get_data();
353
+ expect(data.value).toBeNull();
354
+ });
355
+
356
+ test("get_data returns initial value when mounted with one", async () => {
357
+ const { get_data } = await render(UploadButton, {
358
+ ...base_props,
359
+ value: TEST_TXT
360
+ });
361
+
362
+ const data = await get_data();
363
+ expect(data.value).toEqual(TEST_TXT);
364
+ });
365
+
366
+ test("set_data updates the value reported by get_data", async () => {
367
+ const { set_data, get_data } = await render(UploadButton, base_props);
368
+ await set_data({ value: TEST_TXT });
369
+ await waitFor(async () =>
370
+ expect((await get_data()).value).toEqual(TEST_TXT)
68
371
  );
372
+ });
69
373
 
70
- const item = getByTestId("file-upload-button"); //container.querySelectorAll("input")[0];
71
- const file = new File(["hello"], "my-audio.wav", { type: "audio/wav" });
72
- event.upload(item, file);
73
- const mock = await wait_for_event("change");
74
- expect(mock.callCount).toBe(1);
75
- const [data] = component.$capture_state().value;
76
- expect(data).toBeTruthy();
77
- assert.equal(data.name, "my-audio.wav");
374
+ test("upload interaction is reflected in get_data", async () => {
375
+ const { get_data } = await render(UploadButton, upload_props);
376
+ await upload_file(TEST_TXT);
377
+ await waitFor(async () => expect((await get_data()).value).not.toBeNull());
78
378
  });
79
379
  });
380
+
381
+ describe("Edge cases", () => {
382
+ afterEach(() => cleanup());
383
+
384
+ test("max_file_size is forwarded to client.upload", async () => {
385
+ const upload_spy = vi.fn(async (file_data) => file_data);
386
+ await render(UploadButton, {
387
+ ...upload_props,
388
+ max_file_size: 100,
389
+ client: { ...mock_client(), upload: upload_spy }
390
+ });
391
+ await upload_file(TEST_TXT);
392
+ await waitFor(() => expect(upload_spy).toHaveBeenCalled());
393
+ expect(upload_spy).toHaveBeenCalledWith(
394
+ expect.anything(),
395
+ expect.anything(),
396
+ undefined,
397
+ 100
398
+ );
399
+ });
400
+
401
+ test("no mount-time events with initial value set", async () => {
402
+ const { listen } = await render(UploadButton, {
403
+ ...base_props,
404
+ value: TEST_TXT
405
+ });
406
+
407
+ const change = listen("change", { retrospective: true });
408
+ const upload = listen("upload", { retrospective: true });
409
+ expect(change).not.toHaveBeenCalled();
410
+ expect(upload).not.toHaveBeenCalled();
411
+ });
412
+ });
413
+
414
+ test.todo(
415
+ "VISUAL: variant='primary' renders the button with primary styling — needs Playwright visual regression screenshot comparison"
416
+ );
417
+
418
+ test.todo(
419
+ "VISUAL: variant='secondary' renders the button with secondary styling — needs Playwright visual regression screenshot comparison"
420
+ );
421
+
422
+ test.todo(
423
+ "VISUAL: variant='stop' renders the button with stop/red styling — needs Playwright visual regression screenshot comparison"
424
+ );
425
+
426
+ test.todo(
427
+ "VISUAL: size='sm' renders a small button — needs Playwright visual regression screenshot comparison"
428
+ );
429
+
430
+ test.todo(
431
+ "VISUAL: size='md' renders a medium button — needs Playwright visual regression screenshot comparison"
432
+ );
433
+
434
+ test.todo(
435
+ "VISUAL: size='lg' renders a large button — needs Playwright visual regression screenshot comparison"
436
+ );
package/dist/Index.svelte CHANGED
@@ -11,13 +11,7 @@
11
11
  const props = $props();
12
12
  const gradio = new Gradio<UploadButtonEvents, UploadButtonProps>(props);
13
13
 
14
- let value = $state(gradio.props.value);
15
-
16
- $effect(() => {
17
- if (value !== gradio.props.value) {
18
- gradio.props.value = value;
19
- }
20
- });
14
+ let value = $derived(gradio.props.value);
21
15
 
22
16
  async function handle_event(
23
17
  detail: null | FileData | FileData[],
@@ -2,6 +2,7 @@
2
2
  import { tick } from "svelte";
3
3
  import { BaseButton } from "@gradio/button";
4
4
  import { prepare_files, type FileData, type Client } from "@gradio/client";
5
+ import { is_valid_mimetype } from "@gradio/upload";
5
6
 
6
7
  let {
7
8
  elem_id = "",
@@ -70,13 +71,20 @@
70
71
  }
71
72
 
72
73
  async function load_files(files: FileList): Promise<void> {
73
- let _files: File[] = Array.from(files);
74
+ let _files: File[] = Array.from(files).filter((file) => {
75
+ const ext = "." + file.name.toLowerCase().split(".").pop();
76
+ if (is_valid_mimetype(accept_file_types, ext, file.type)) {
77
+ return true;
78
+ }
79
+ onerror?.(`Invalid file type only ${file_types?.join(", ")} allowed.`);
80
+ return false;
81
+ });
74
82
 
75
- if (!files.length) {
83
+ if (!_files.length) {
76
84
  return;
77
85
  }
78
86
  if (file_count === "single") {
79
- _files = [files[0]];
87
+ _files = [_files[0]];
80
88
  }
81
89
  let all_file_data = await prepare_files(_files);
82
90
  await tick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/uploadbutton",
3
- "version": "0.9.18",
3
+ "version": "0.10.0",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -17,13 +17,13 @@
17
17
  },
18
18
  "main": "./Index.svelte",
19
19
  "dependencies": {
20
- "@gradio/button": "^0.6.5",
21
- "@gradio/upload": "^0.17.7",
22
- "@gradio/utils": "^0.12.0",
23
- "@gradio/client": "^2.1.0"
20
+ "@gradio/button": "^0.7.0",
21
+ "@gradio/upload": "^0.17.10",
22
+ "@gradio/utils": "^0.12.2",
23
+ "@gradio/client": "^2.2.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@gradio/preview": "^0.16.0"
26
+ "@gradio/preview": "^0.16.2"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "svelte": "^5.48.0"
@@ -2,6 +2,7 @@
2
2
  import { tick } from "svelte";
3
3
  import { BaseButton } from "@gradio/button";
4
4
  import { prepare_files, type FileData, type Client } from "@gradio/client";
5
+ import { is_valid_mimetype } from "@gradio/upload";
5
6
 
6
7
  let {
7
8
  elem_id = "",
@@ -70,13 +71,20 @@
70
71
  }
71
72
 
72
73
  async function load_files(files: FileList): Promise<void> {
73
- let _files: File[] = Array.from(files);
74
+ let _files: File[] = Array.from(files).filter((file) => {
75
+ const ext = "." + file.name.toLowerCase().split(".").pop();
76
+ if (is_valid_mimetype(accept_file_types, ext, file.type)) {
77
+ return true;
78
+ }
79
+ onerror?.(`Invalid file type only ${file_types?.join(", ")} allowed.`);
80
+ return false;
81
+ });
74
82
 
75
- if (!files.length) {
83
+ if (!_files.length) {
76
84
  return;
77
85
  }
78
86
  if (file_count === "single") {
79
- _files = [files[0]];
87
+ _files = [_files[0]];
80
88
  }
81
89
  let all_file_data = await prepare_files(_files);
82
90
  await tick();