@gradio/dataframe 0.24.4 → 0.24.6
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 +25 -0
- package/Dataframe.test.ts +1202 -122
- package/Index.svelte +1 -0
- package/dist/Index.svelte +1 -0
- package/dist/shared/EmptyRowButton.svelte +0 -1
- package/dist/shared/Table.svelte +258 -51
- package/dist/shared/Table.svelte.d.ts +1 -0
- package/dist/shared/Toolbar.svelte +2 -3
- package/dist/shared/Toolbar.svelte.d.ts +1 -1
- package/dist/shared/types.d.ts +0 -4
- package/dist/shared/types.js +0 -1
- package/dist/shared/utils/selection_utils.d.ts +1 -1
- package/dist/shared/utils/selection_utils.js +6 -3
- package/dist/shared/utils/table_utils.d.ts +6 -5
- package/dist/shared/utils/table_utils.js +45 -40
- package/dist/shared/utils/utils.d.ts +0 -4
- package/dist/standalone/default_i18n.js +7 -0
- package/dist/types.d.ts +1 -0
- package/package.json +7 -7
- package/shared/EmptyRowButton.svelte +0 -1
- package/shared/Table.svelte +258 -51
- package/shared/Toolbar.svelte +2 -3
- package/shared/types.ts +0 -6
- package/shared/utils/selection_utils.ts +8 -3
- package/shared/utils/table_utils.ts +49 -52
- package/shared/utils/utils.ts +0 -1
- package/standalone/default_i18n.ts +12 -0
- package/test/selection_utils.test.ts +40 -7
- package/test/table_utils.test.ts +92 -28
- package/types.ts +1 -0
package/Dataframe.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, describe, afterEach, expect } from "vitest";
|
|
1
|
+
import { test, describe, afterEach, expect, vi, onTestFinished } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
cleanup,
|
|
4
4
|
render,
|
|
@@ -71,6 +71,29 @@ async function wait(ms = 50) {
|
|
|
71
71
|
await tick();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
async function sort_column(
|
|
75
|
+
container: HTMLElement,
|
|
76
|
+
header_text: string,
|
|
77
|
+
direction: "ascending" | "descending"
|
|
78
|
+
) {
|
|
79
|
+
const header = Array.from(get_header_cells(container)).find((h) =>
|
|
80
|
+
h.textContent?.includes(header_text)
|
|
81
|
+
) as HTMLElement;
|
|
82
|
+
expect(header).toBeTruthy();
|
|
83
|
+
|
|
84
|
+
const menu_btn = header.querySelector(".cell-menu-button") as HTMLElement;
|
|
85
|
+
expect(menu_btn).toBeTruthy();
|
|
86
|
+
await fireEvent.click(menu_btn);
|
|
87
|
+
await wait();
|
|
88
|
+
|
|
89
|
+
const sort_btn = Array.from(
|
|
90
|
+
document.querySelectorAll('[role="menuitem"]')
|
|
91
|
+
).find((el) => el.textContent?.includes(`sort_${direction}`)) as HTMLElement;
|
|
92
|
+
expect(sort_btn).toBeTruthy();
|
|
93
|
+
await fireEvent.click(sort_btn);
|
|
94
|
+
await wait();
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
describe("Dataframe rendering", () => {
|
|
75
98
|
afterEach(() => cleanup());
|
|
76
99
|
|
|
@@ -308,7 +331,12 @@ describe("Cell editing", () => {
|
|
|
308
331
|
});
|
|
309
332
|
|
|
310
333
|
describe("Cell selection", () => {
|
|
311
|
-
afterEach(() =>
|
|
334
|
+
afterEach(() => {
|
|
335
|
+
cleanup();
|
|
336
|
+
// a test that stubs the clipboard and then fails would otherwise leave the
|
|
337
|
+
// stub in place for everything after it
|
|
338
|
+
vi.restoreAllMocks();
|
|
339
|
+
});
|
|
312
340
|
|
|
313
341
|
test("click selects a cell", async () => {
|
|
314
342
|
const { container } = await render(Dataframe, default_props);
|
|
@@ -357,6 +385,457 @@ describe("Cell selection", () => {
|
|
|
357
385
|
expect(get_cell(container, 1, 1)!.className).toContain("cell-selected");
|
|
358
386
|
});
|
|
359
387
|
|
|
388
|
+
test("a second shift+click extends from the first click, not the last", async () => {
|
|
389
|
+
const { container } = await render(Dataframe, default_props);
|
|
390
|
+
await wait();
|
|
391
|
+
|
|
392
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
393
|
+
await wait();
|
|
394
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!, { shiftKey: true });
|
|
395
|
+
await wait();
|
|
396
|
+
await fireEvent.mouseDown(get_cell(container, 2, 0)!, { shiftKey: true });
|
|
397
|
+
await wait();
|
|
398
|
+
|
|
399
|
+
expect(get_cell(container, 0, 0)!.className).toContain("cell-selected");
|
|
400
|
+
expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
|
|
401
|
+
expect(get_cell(container, 2, 0)!.className).toContain("cell-selected");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// only the even rows match "target", so every row between two visible
|
|
405
|
+
// endpoints is one the search hides
|
|
406
|
+
const search_props = {
|
|
407
|
+
...default_props,
|
|
408
|
+
value: {
|
|
409
|
+
data: Array.from({ length: 40 }, (_, i) => [
|
|
410
|
+
`row ${i}`,
|
|
411
|
+
i % 2 === 0 ? "target" : `keep-${i}`
|
|
412
|
+
]),
|
|
413
|
+
headers: ["Name", "Tag"],
|
|
414
|
+
metadata: null
|
|
415
|
+
},
|
|
416
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
417
|
+
row_count: [40, "fixed"] as [number, "fixed" | "dynamic"],
|
|
418
|
+
show_search: "search" as const
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
function get_search_input(container: HTMLElement) {
|
|
422
|
+
return container.querySelector("input.search-input") as HTMLInputElement;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The hidden rows are never rendered, so what Delete touches is the only way
|
|
426
|
+
// to see whether they were in the range.
|
|
427
|
+
test("shift+click range skips rows the search is hiding", async () => {
|
|
428
|
+
const { container } = await render(Dataframe, search_props);
|
|
429
|
+
await wait();
|
|
430
|
+
|
|
431
|
+
const search_input = get_search_input(container);
|
|
432
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
433
|
+
await wait(100);
|
|
434
|
+
|
|
435
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!);
|
|
436
|
+
await wait();
|
|
437
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
438
|
+
await wait();
|
|
439
|
+
|
|
440
|
+
// rows 0, 2 and 4 sit next to each other on screen, so the selection ring
|
|
441
|
+
// around them has to merge into one block
|
|
442
|
+
const middle = get_cell(container, 2, 1)!.className;
|
|
443
|
+
expect(middle).toContain("no-top");
|
|
444
|
+
expect(middle).toContain("no-bottom");
|
|
445
|
+
|
|
446
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
447
|
+
await wait();
|
|
448
|
+
|
|
449
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
450
|
+
await wait(100);
|
|
451
|
+
|
|
452
|
+
for (const row of [0, 2, 4]) {
|
|
453
|
+
expect(get_cell(container, row, 1)?.textContent?.trim()).toBe("");
|
|
454
|
+
}
|
|
455
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("keep-1");
|
|
456
|
+
expect(get_cell(container, 3, 1)?.textContent).toContain("keep-3");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// the range itself, not just what Delete goes on to touch: clearing the
|
|
460
|
+
// query renders the skipped rows again, so the selection becomes visible
|
|
461
|
+
test("shift+click range holds only the rows the search was showing", async () => {
|
|
462
|
+
const { container } = await render(Dataframe, search_props);
|
|
463
|
+
await wait();
|
|
464
|
+
|
|
465
|
+
const search_input = get_search_input(container);
|
|
466
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
467
|
+
await wait(100);
|
|
468
|
+
|
|
469
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!);
|
|
470
|
+
await wait();
|
|
471
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
472
|
+
await wait();
|
|
473
|
+
|
|
474
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
475
|
+
await wait(100);
|
|
476
|
+
|
|
477
|
+
for (const row of [0, 2, 4]) {
|
|
478
|
+
expect(get_cell(container, row, 1)!.className).toContain("cell-selected");
|
|
479
|
+
}
|
|
480
|
+
for (const row of [1, 3]) {
|
|
481
|
+
expect(get_cell(container, row, 1)!.className).not.toContain(
|
|
482
|
+
"cell-selected"
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// the same data loss with the two steps swapped: the range is built while
|
|
488
|
+
// everything is on screen, and the search hides part of it afterwards
|
|
489
|
+
test("Delete leaves rows the search hid after they were selected", async () => {
|
|
490
|
+
const { container } = await render(Dataframe, search_props);
|
|
491
|
+
await wait();
|
|
492
|
+
|
|
493
|
+
await fireEvent.mouseDown(get_cell(container, 2, 1)!);
|
|
494
|
+
await wait();
|
|
495
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
496
|
+
await wait();
|
|
497
|
+
|
|
498
|
+
const search_input = get_search_input(container);
|
|
499
|
+
await fireEvent.input(search_input, { target: { value: "target" } });
|
|
500
|
+
await wait(100);
|
|
501
|
+
|
|
502
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
503
|
+
await wait();
|
|
504
|
+
|
|
505
|
+
await fireEvent.input(search_input, { target: { value: "" } });
|
|
506
|
+
await wait(100);
|
|
507
|
+
|
|
508
|
+
for (const row of [0, 2]) {
|
|
509
|
+
expect(get_cell(container, row, 1)?.textContent?.trim()).toBe("");
|
|
510
|
+
}
|
|
511
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("keep-1");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("copy leaves out rows the search hid after they were selected", async () => {
|
|
515
|
+
const { container } = await render(Dataframe, search_props);
|
|
516
|
+
await wait();
|
|
517
|
+
|
|
518
|
+
await fireEvent.mouseDown(get_cell(container, 2, 1)!);
|
|
519
|
+
await wait();
|
|
520
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
521
|
+
await wait();
|
|
522
|
+
|
|
523
|
+
await fireEvent.input(get_search_input(container), {
|
|
524
|
+
target: { value: "target" }
|
|
525
|
+
});
|
|
526
|
+
await wait(100);
|
|
527
|
+
|
|
528
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
529
|
+
key: "c",
|
|
530
|
+
metaKey: true
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// rows 0 and 2 are on screen, row 1 holds "keep-1" and is not
|
|
534
|
+
await waitFor(async () => {
|
|
535
|
+
expect(await navigator.clipboard.readText()).toBe("target\ntarget");
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("Delete fires no events when every selected row is hidden", async () => {
|
|
540
|
+
const { container, listen } = await render(Dataframe, search_props);
|
|
541
|
+
await wait();
|
|
542
|
+
|
|
543
|
+
// build a selection holding only row 1, with the anchor left on row 0 so
|
|
544
|
+
// `handle_keydown` still runs
|
|
545
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
546
|
+
await wait();
|
|
547
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { ctrlKey: true });
|
|
548
|
+
await wait();
|
|
549
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { ctrlKey: true });
|
|
550
|
+
await wait();
|
|
551
|
+
|
|
552
|
+
await fireEvent.input(get_search_input(container), {
|
|
553
|
+
target: { value: "target" }
|
|
554
|
+
});
|
|
555
|
+
await wait(100);
|
|
556
|
+
|
|
557
|
+
const change = listen("change");
|
|
558
|
+
const input = listen("input");
|
|
559
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
560
|
+
await wait(100);
|
|
561
|
+
|
|
562
|
+
expect(change).not.toHaveBeenCalled();
|
|
563
|
+
expect(input).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// [0, 0] is already "" and [1, 1] is past the end of a short row, so neither
|
|
567
|
+
// is written and the table comes out identical
|
|
568
|
+
test("Delete fires no events when the selected cells are blank", async () => {
|
|
569
|
+
const { container, listen } = await render(Dataframe, {
|
|
570
|
+
...default_props,
|
|
571
|
+
value: {
|
|
572
|
+
data: [["", "b"], ["c"]],
|
|
573
|
+
headers: ["1", "2"],
|
|
574
|
+
metadata: null
|
|
575
|
+
},
|
|
576
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
577
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
578
|
+
});
|
|
579
|
+
await wait();
|
|
580
|
+
|
|
581
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
582
|
+
await wait();
|
|
583
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
584
|
+
await wait();
|
|
585
|
+
|
|
586
|
+
const change = listen("change");
|
|
587
|
+
const input = listen("input");
|
|
588
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
589
|
+
await wait(100);
|
|
590
|
+
|
|
591
|
+
expect(change).not.toHaveBeenCalled();
|
|
592
|
+
expect(input).not.toHaveBeenCalled();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// `null` renders blank like "" does, but it is a value the backend sent, so
|
|
596
|
+
// Delete clears it rather than treating it as nothing to do
|
|
597
|
+
test("Delete clears a null cell", async () => {
|
|
598
|
+
const { container, listen } = await render(Dataframe, {
|
|
599
|
+
...default_props,
|
|
600
|
+
value: {
|
|
601
|
+
data: [
|
|
602
|
+
[null, "b"],
|
|
603
|
+
["c", "d"]
|
|
604
|
+
] as any,
|
|
605
|
+
headers: ["1", "2"],
|
|
606
|
+
metadata: null
|
|
607
|
+
},
|
|
608
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
609
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
610
|
+
});
|
|
611
|
+
await wait();
|
|
612
|
+
|
|
613
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!);
|
|
614
|
+
await wait();
|
|
615
|
+
|
|
616
|
+
const change = listen("change");
|
|
617
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
618
|
+
await wait(100);
|
|
619
|
+
|
|
620
|
+
expect(change).toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// the selection is data-space and survives a value update, so it can name a
|
|
624
|
+
// row that no longer exists
|
|
625
|
+
test("Delete skips selected rows a shrunk table no longer has", async () => {
|
|
626
|
+
const { container, set_data } = await render(Dataframe, search_props);
|
|
627
|
+
await wait();
|
|
628
|
+
|
|
629
|
+
// anchor on row 0, which survives the shrink, so `handle_keydown` runs
|
|
630
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!);
|
|
631
|
+
await wait();
|
|
632
|
+
await fireEvent.mouseDown(get_cell(container, 0, 1)!, { shiftKey: true });
|
|
633
|
+
await wait();
|
|
634
|
+
|
|
635
|
+
await set_data({
|
|
636
|
+
value: {
|
|
637
|
+
data: [
|
|
638
|
+
["row 0", "target"],
|
|
639
|
+
["row 1", "keep-1"]
|
|
640
|
+
],
|
|
641
|
+
headers: ["Name", "Tag"],
|
|
642
|
+
metadata: null
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
await wait();
|
|
646
|
+
|
|
647
|
+
await fireEvent.keyDown(get_table_wrap(container), { key: "Delete" });
|
|
648
|
+
await wait(100);
|
|
649
|
+
|
|
650
|
+
// rows 2-4 are gone; reading `new_values[2]` before the visibility check
|
|
651
|
+
// threw here and left rows 0 and 1 untouched
|
|
652
|
+
expect(get_cell(container, 0, 1)?.textContent?.trim()).toBe("");
|
|
653
|
+
expect(get_cell(container, 1, 1)?.textContent?.trim()).toBe("");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("Ctrl+C copies a selection with a gap in its first row", async () => {
|
|
657
|
+
const { container } = await render(Dataframe, {
|
|
658
|
+
...default_props,
|
|
659
|
+
value: {
|
|
660
|
+
data: [
|
|
661
|
+
["a", "b"],
|
|
662
|
+
["c", "d"]
|
|
663
|
+
],
|
|
664
|
+
headers: ["1", "2"],
|
|
665
|
+
metadata: null
|
|
666
|
+
},
|
|
667
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
668
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
669
|
+
});
|
|
670
|
+
await wait();
|
|
671
|
+
|
|
672
|
+
await fireEvent.mouseDown(get_cell(container, 0, 0)!, { ctrlKey: true });
|
|
673
|
+
await wait();
|
|
674
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { ctrlKey: true });
|
|
675
|
+
await wait();
|
|
676
|
+
|
|
677
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
678
|
+
key: "c",
|
|
679
|
+
metaKey: true
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// row 0 holds only column 0, so taking the columns from it alone dropped
|
|
683
|
+
// "d" from the clipboard
|
|
684
|
+
await waitFor(async () => {
|
|
685
|
+
expect(await navigator.clipboard.readText()).toBe("a,\n,d");
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// every row renders a cell per header, so the phantom cells of a short row
|
|
690
|
+
// are clickable and a selection can name one
|
|
691
|
+
test("Ctrl+C copies a selection that runs past the end of a short row", async () => {
|
|
692
|
+
const { container } = await render(Dataframe, {
|
|
693
|
+
...default_props,
|
|
694
|
+
value: {
|
|
695
|
+
data: [["a", "b"], ["c"]],
|
|
696
|
+
headers: ["1", "2"],
|
|
697
|
+
metadata: null
|
|
698
|
+
},
|
|
699
|
+
col_count: [2, "fixed"] as [number, "fixed" | "dynamic"],
|
|
700
|
+
row_count: [2, "fixed"] as [number, "fixed" | "dynamic"]
|
|
701
|
+
});
|
|
702
|
+
await wait();
|
|
703
|
+
|
|
704
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!);
|
|
705
|
+
await wait();
|
|
706
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!, { shiftKey: true });
|
|
707
|
+
await wait();
|
|
708
|
+
|
|
709
|
+
await fireEvent.keyDown(get_table_wrap(container), {
|
|
710
|
+
key: "c",
|
|
711
|
+
metaKey: true
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// cell [1, 1] has no value behind it, so it copies as empty rather than
|
|
715
|
+
// throwing inside `copy_table_data`
|
|
716
|
+
await waitFor(async () => {
|
|
717
|
+
expect(await navigator.clipboard.readText()).toBe("c,");
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// the toolbar button is the reachable path here: `handle_keydown` bails out
|
|
722
|
+
// before Ctrl+C whenever the row `selected` points at is off screen
|
|
723
|
+
test("the copy button does not report success with nothing on screen to copy", async () => {
|
|
724
|
+
const { container } = await render(Dataframe, search_props);
|
|
725
|
+
await wait();
|
|
726
|
+
|
|
727
|
+
// row 1 is the only selected row, and the search is about to hide it
|
|
728
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!);
|
|
729
|
+
await wait();
|
|
730
|
+
await fireEvent.input(get_search_input(container), {
|
|
731
|
+
target: { value: "target" }
|
|
732
|
+
});
|
|
733
|
+
await wait(100);
|
|
734
|
+
|
|
735
|
+
const copy_button = container.querySelector(
|
|
736
|
+
'[aria-label="Copy table data"]'
|
|
737
|
+
) as HTMLElement;
|
|
738
|
+
expect(copy_button).toBeTruthy();
|
|
739
|
+
await fireEvent.click(copy_button);
|
|
740
|
+
await wait(150);
|
|
741
|
+
|
|
742
|
+
expect(
|
|
743
|
+
container.querySelector('[aria-label="Copied to clipboard"]')
|
|
744
|
+
).toBeNull();
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// a refused write already left the copied state alone, by rejecting through
|
|
748
|
+
// `Toolbar.handle_copy` before it could run; what the boolean adds is that
|
|
749
|
+
// the rejection stops there instead of floating away unhandled
|
|
750
|
+
test("a refused clipboard write is reported, not left unhandled", async () => {
|
|
751
|
+
const { container } = await render(Dataframe, default_props);
|
|
752
|
+
await wait();
|
|
753
|
+
|
|
754
|
+
const write_text = vi
|
|
755
|
+
.spyOn(navigator.clipboard, "writeText")
|
|
756
|
+
.mockRejectedValue(new Error("denied"));
|
|
757
|
+
const unhandled = vi.fn();
|
|
758
|
+
window.addEventListener("unhandledrejection", unhandled);
|
|
759
|
+
// not at the end of the body: an assertion below would skip it and leak
|
|
760
|
+
// the listener into every later test
|
|
761
|
+
onTestFinished(() =>
|
|
762
|
+
window.removeEventListener("unhandledrejection", unhandled)
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const copy_button = container.querySelector(
|
|
766
|
+
'[aria-label="Copy table data"]'
|
|
767
|
+
) as HTMLElement;
|
|
768
|
+
await fireEvent.click(copy_button);
|
|
769
|
+
await wait(150);
|
|
770
|
+
|
|
771
|
+
expect(write_text).toHaveBeenCalled();
|
|
772
|
+
expect(
|
|
773
|
+
container.querySelector('[aria-label="Copied to clipboard"]')
|
|
774
|
+
).toBeNull();
|
|
775
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("shift+click falls back to one cell when the anchor is filtered out", async () => {
|
|
779
|
+
const { container } = await render(Dataframe, search_props);
|
|
780
|
+
await wait();
|
|
781
|
+
|
|
782
|
+
// row 1 does not match "target", so the search is about to hide it
|
|
783
|
+
await fireEvent.mouseDown(get_cell(container, 1, 1)!);
|
|
784
|
+
await wait();
|
|
785
|
+
|
|
786
|
+
await fireEvent.input(get_search_input(container), {
|
|
787
|
+
target: { value: "target" }
|
|
788
|
+
});
|
|
789
|
+
await wait(100);
|
|
790
|
+
|
|
791
|
+
await fireEvent.mouseDown(get_cell(container, 4, 1)!, { shiftKey: true });
|
|
792
|
+
await wait();
|
|
793
|
+
|
|
794
|
+
expect(container.querySelectorAll(".cell-selected")).toHaveLength(1);
|
|
795
|
+
expect(get_cell(container, 4, 1)!.className).toContain("cell-selected");
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("shift+click range follows the sorted order, not the data order", async () => {
|
|
799
|
+
const { container } = await render(Dataframe, {
|
|
800
|
+
...default_props,
|
|
801
|
+
value: {
|
|
802
|
+
data: [
|
|
803
|
+
["A", "1"],
|
|
804
|
+
["B", "3"],
|
|
805
|
+
["C", "2"],
|
|
806
|
+
["D", "4"]
|
|
807
|
+
],
|
|
808
|
+
headers: ["Name", "Age"],
|
|
809
|
+
metadata: null
|
|
810
|
+
},
|
|
811
|
+
// the header menu, and so the sort control, only renders for dynamic columns
|
|
812
|
+
col_count: [2, "dynamic"] as [number, "fixed" | "dynamic"],
|
|
813
|
+
row_count: [4, "fixed"] as [number, "fixed" | "dynamic"]
|
|
814
|
+
});
|
|
815
|
+
await wait();
|
|
816
|
+
|
|
817
|
+
await sort_column(container, "Age", "descending");
|
|
818
|
+
|
|
819
|
+
// screen order is now D(4) B(3) C(2) A(1), so the span from D to B holds
|
|
820
|
+
// only those two even though data rows 3 and 1 straddle row 2
|
|
821
|
+
await fireEvent.mouseDown(get_cell(container, 3, 0)!);
|
|
822
|
+
await wait();
|
|
823
|
+
await fireEvent.mouseDown(get_cell(container, 1, 0)!, { shiftKey: true });
|
|
824
|
+
await wait();
|
|
825
|
+
|
|
826
|
+
// D and B are the top and bottom of the block on screen, so the outer
|
|
827
|
+
// edges are drawn and the two facing each other are suppressed
|
|
828
|
+
expect(get_cell(container, 3, 0)!.className).not.toContain("no-top");
|
|
829
|
+
expect(get_cell(container, 3, 0)!.className).toContain("no-bottom");
|
|
830
|
+
expect(get_cell(container, 1, 0)!.className).toContain("no-top");
|
|
831
|
+
expect(get_cell(container, 1, 0)!.className).not.toContain("no-bottom");
|
|
832
|
+
|
|
833
|
+
expect(container.querySelectorAll(".cell-selected")).toHaveLength(2);
|
|
834
|
+
expect(get_cell(container, 3, 0)!.className).toContain("cell-selected");
|
|
835
|
+
expect(get_cell(container, 1, 0)!.className).toContain("cell-selected");
|
|
836
|
+
expect(get_cell(container, 2, 0)!.className).not.toContain("cell-selected");
|
|
837
|
+
});
|
|
838
|
+
|
|
360
839
|
// Regression: pressing Ctrl between mousedown and mouseup must not call
|
|
361
840
|
// handle_cell_click a second time via the click event. If it did, the
|
|
362
841
|
// Ctrl+click path would toggle the already-selected cell back off,
|
|
@@ -591,18 +1070,30 @@ describe("Keyboard accessibility", () => {
|
|
|
591
1070
|
expect(first_cell).toHaveFocus();
|
|
592
1071
|
});
|
|
593
1072
|
|
|
594
|
-
test("Enter
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1073
|
+
test("Enter, F2, Tab and Escape move between grid navigation and cell editing", async () => {
|
|
1074
|
+
// The editor focuses itself in a requestAnimationFrame, which the browser
|
|
1075
|
+
// defers under full-suite load. Running frames as plain timers keeps the
|
|
1076
|
+
// focus hand-off deterministic without changing what the component does.
|
|
1077
|
+
vi.stubGlobal(
|
|
1078
|
+
"requestAnimationFrame",
|
|
1079
|
+
(callback: FrameRequestCallback): number =>
|
|
1080
|
+
window.setTimeout(() => callback(performance.now()), 0)
|
|
598
1081
|
);
|
|
1082
|
+
vi.stubGlobal("cancelAnimationFrame", (id: number): void =>
|
|
1083
|
+
window.clearTimeout(id)
|
|
1084
|
+
);
|
|
1085
|
+
onTestFinished(() => vi.unstubAllGlobals());
|
|
1086
|
+
|
|
1087
|
+
const { findByRole, getByRole, getByTestId, queryByRole, listen } =
|
|
1088
|
+
await render(Dataframe, navigation_props);
|
|
599
1089
|
const select = listen("select");
|
|
600
1090
|
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
1091
|
+
const second_cell = getByTestId("cell-0-1");
|
|
601
1092
|
|
|
602
1093
|
first_cell.focus();
|
|
603
1094
|
await event.keyboard("{Enter}");
|
|
604
1095
|
|
|
605
|
-
const editor =
|
|
1096
|
+
const editor = await findByRole("textbox", { name: "Edit cell" });
|
|
606
1097
|
await waitFor(() => expect(editor).toHaveFocus());
|
|
607
1098
|
expect(select).toHaveBeenCalledWith({
|
|
608
1099
|
index: [0, 0],
|
|
@@ -612,16 +1103,44 @@ describe("Keyboard accessibility", () => {
|
|
|
612
1103
|
});
|
|
613
1104
|
|
|
614
1105
|
await event.keyboard("{Enter}");
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1106
|
+
await waitFor(() =>
|
|
1107
|
+
expect(
|
|
1108
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1109
|
+
).not.toBeInTheDocument()
|
|
1110
|
+
);
|
|
1111
|
+
await waitFor(() => expect(first_cell).toHaveFocus());
|
|
1112
|
+
|
|
1113
|
+
await event.keyboard("{F2}");
|
|
1114
|
+
await waitFor(() =>
|
|
1115
|
+
expect(
|
|
1116
|
+
within(first_cell).getByRole("textbox", { name: "Edit cell" })
|
|
1117
|
+
).toHaveFocus()
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
await event.tab();
|
|
1121
|
+
await waitFor(() =>
|
|
1122
|
+
expect(
|
|
1123
|
+
within(second_cell).getByRole("textbox", { name: "Edit cell" })
|
|
1124
|
+
).toHaveFocus()
|
|
1125
|
+
);
|
|
1126
|
+
expect(second_cell).toHaveAttribute("tabindex", "0");
|
|
1127
|
+
|
|
1128
|
+
await event.keyboard("{Escape}");
|
|
1129
|
+
await waitFor(() =>
|
|
1130
|
+
expect(
|
|
1131
|
+
queryByRole("textbox", { name: "Edit cell" })
|
|
1132
|
+
).not.toBeInTheDocument()
|
|
1133
|
+
);
|
|
1134
|
+
await waitFor(() => expect(second_cell).toHaveFocus());
|
|
1135
|
+
expect(getByRole("grid").querySelectorAll('[tabindex="0"]')).toHaveLength(
|
|
1136
|
+
1
|
|
1137
|
+
);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test("Space activates a cell without entering edit mode", async () => {
|
|
1141
|
+
const { getByTestId, queryByRole, listen } = await render(
|
|
1142
|
+
Dataframe,
|
|
1143
|
+
navigation_props
|
|
625
1144
|
);
|
|
626
1145
|
const select = listen("select");
|
|
627
1146
|
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
@@ -658,71 +1177,6 @@ describe("Keyboard accessibility", () => {
|
|
|
658
1177
|
expect(cell).toHaveFocus();
|
|
659
1178
|
});
|
|
660
1179
|
|
|
661
|
-
test("F2 toggles edit mode and Escape returns to grid navigation", async () => {
|
|
662
|
-
const { getByRole, getByTestId, queryByRole } = await render(
|
|
663
|
-
Dataframe,
|
|
664
|
-
navigation_props
|
|
665
|
-
);
|
|
666
|
-
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
667
|
-
|
|
668
|
-
first_cell.focus();
|
|
669
|
-
await event.keyboard("{F2}");
|
|
670
|
-
await waitFor(() =>
|
|
671
|
-
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
672
|
-
);
|
|
673
|
-
await event.keyboard("{F2}");
|
|
674
|
-
expect(
|
|
675
|
-
queryByRole("textbox", { name: "Edit cell" })
|
|
676
|
-
).not.toBeInTheDocument();
|
|
677
|
-
expect(first_cell).toHaveFocus();
|
|
678
|
-
|
|
679
|
-
await event.keyboard("{F2}");
|
|
680
|
-
await waitFor(() =>
|
|
681
|
-
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
682
|
-
);
|
|
683
|
-
|
|
684
|
-
await event.keyboard("{Escape}");
|
|
685
|
-
expect(
|
|
686
|
-
queryByRole("textbox", { name: "Edit cell" })
|
|
687
|
-
).not.toBeInTheDocument();
|
|
688
|
-
expect(first_cell).toHaveFocus();
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
test("Tab moves between cells while editing and leaves at the grid boundary", async () => {
|
|
692
|
-
const before = append_external_button("Before dataframe");
|
|
693
|
-
const { getByRole, getByTestId } = await render(
|
|
694
|
-
Dataframe,
|
|
695
|
-
navigation_props
|
|
696
|
-
);
|
|
697
|
-
const after = append_external_button("After dataframe");
|
|
698
|
-
const first_cell = await waitFor(() => getByTestId("cell-0-0"));
|
|
699
|
-
const last_cell = getByTestId("cell-2-2");
|
|
700
|
-
|
|
701
|
-
first_cell.focus();
|
|
702
|
-
await event.keyboard("{Enter}");
|
|
703
|
-
await waitFor(() =>
|
|
704
|
-
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
705
|
-
);
|
|
706
|
-
await event.tab();
|
|
707
|
-
await waitFor(() =>
|
|
708
|
-
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
709
|
-
);
|
|
710
|
-
expect(getByTestId("cell-0-1")).toHaveAttribute("tabindex", "0");
|
|
711
|
-
|
|
712
|
-
last_cell.focus();
|
|
713
|
-
await event.keyboard("{Enter}");
|
|
714
|
-
await event.tab();
|
|
715
|
-
expect(after).toHaveFocus();
|
|
716
|
-
|
|
717
|
-
first_cell.focus();
|
|
718
|
-
await event.keyboard("{Enter}");
|
|
719
|
-
await waitFor(() =>
|
|
720
|
-
expect(getByRole("textbox", { name: "Edit cell" })).toHaveFocus()
|
|
721
|
-
);
|
|
722
|
-
await event.tab({ shift: true });
|
|
723
|
-
expect(before).toHaveFocus();
|
|
724
|
-
});
|
|
725
|
-
|
|
726
1180
|
test("arrow navigation follows visible filtered rows while select reports original indices", async () => {
|
|
727
1181
|
const filtered_props = {
|
|
728
1182
|
...navigation_props,
|
|
@@ -760,10 +1214,6 @@ describe("Keyboard accessibility", () => {
|
|
|
760
1214
|
col_value: ["match one", "skip", "match two"]
|
|
761
1215
|
});
|
|
762
1216
|
});
|
|
763
|
-
|
|
764
|
-
test.todo(
|
|
765
|
-
"VISUAL: keyboard focus shows a visible outline around the active Dataframe cell — needs Playwright visual regression screenshot comparison"
|
|
766
|
-
);
|
|
767
1217
|
});
|
|
768
1218
|
|
|
769
1219
|
describe("Header overflow", () => {
|
|
@@ -814,9 +1264,13 @@ describe("Header overflow", () => {
|
|
|
814
1264
|
const viewport = wrapper.querySelector(
|
|
815
1265
|
".virtual-table-viewport"
|
|
816
1266
|
) as HTMLElement;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1267
|
+
// Column widths are measured asynchronously; until that lands the columns
|
|
1268
|
+
// share the container equally and the 3rd one is still in view.
|
|
1269
|
+
await waitFor(() => {
|
|
1270
|
+
const viewport_rect = viewport.getBoundingClientRect();
|
|
1271
|
+
const third_rect = headers[2].getBoundingClientRect();
|
|
1272
|
+
expect(third_rect.left).toBeGreaterThanOrEqual(viewport_rect.right);
|
|
1273
|
+
});
|
|
820
1274
|
});
|
|
821
1275
|
});
|
|
822
1276
|
|
|
@@ -830,28 +1284,7 @@ describe("Sorting", () => {
|
|
|
830
1284
|
});
|
|
831
1285
|
await wait();
|
|
832
1286
|
|
|
833
|
-
|
|
834
|
-
const headers = get_header_cells(container);
|
|
835
|
-
const age_header = Array.from(headers).find((h) =>
|
|
836
|
-
h.textContent?.includes("Age")
|
|
837
|
-
) as HTMLElement;
|
|
838
|
-
expect(age_header).toBeTruthy();
|
|
839
|
-
|
|
840
|
-
// Click the menu button on the header
|
|
841
|
-
const menu_btn = age_header.querySelector(
|
|
842
|
-
".cell-menu-button"
|
|
843
|
-
) as HTMLElement;
|
|
844
|
-
expect(menu_btn).toBeTruthy();
|
|
845
|
-
await fireEvent.click(menu_btn);
|
|
846
|
-
await wait();
|
|
847
|
-
|
|
848
|
-
// Click "Sort ascending" in the menu
|
|
849
|
-
const sort_asc_btn = Array.from(
|
|
850
|
-
document.querySelectorAll('[role="menuitem"]')
|
|
851
|
-
).find((el) => el.textContent?.includes("sort_ascending")) as HTMLElement;
|
|
852
|
-
expect(sort_asc_btn).toBeTruthy();
|
|
853
|
-
await fireEvent.click(sort_asc_btn);
|
|
854
|
-
await wait();
|
|
1287
|
+
await sort_column(container, "Age", "ascending");
|
|
855
1288
|
|
|
856
1289
|
// After sorting by Age ascending, Bob (25) should be first visible row
|
|
857
1290
|
const first_row = container.querySelector(".virtual-row");
|
|
@@ -936,16 +1369,21 @@ describe("Add/remove rows and columns", () => {
|
|
|
936
1369
|
row_count: [3, "dynamic"] as [number, "fixed" | "dynamic"]
|
|
937
1370
|
};
|
|
938
1371
|
|
|
1372
|
+
const empty_dynamic_props = {
|
|
1373
|
+
...dynamic_props,
|
|
1374
|
+
value: {
|
|
1375
|
+
data: [],
|
|
1376
|
+
headers: default_props.value.headers,
|
|
1377
|
+
metadata: null
|
|
1378
|
+
},
|
|
1379
|
+
row_count: [0, "dynamic"] as [number, "dynamic"]
|
|
1380
|
+
};
|
|
1381
|
+
|
|
939
1382
|
test("add row button appends a row and focuses its first cell", async () => {
|
|
940
|
-
const { container, getByRole, getByTestId } = await render(
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
headers: default_props.value.headers,
|
|
945
|
-
metadata: null
|
|
946
|
-
},
|
|
947
|
-
row_count: [0, "dynamic"] as [number, "dynamic"]
|
|
948
|
-
});
|
|
1383
|
+
const { container, getByRole, getByTestId } = await render(
|
|
1384
|
+
Dataframe,
|
|
1385
|
+
empty_dynamic_props
|
|
1386
|
+
);
|
|
949
1387
|
await wait();
|
|
950
1388
|
|
|
951
1389
|
await fireEvent.click(getByRole("button", { name: "Add row" }));
|
|
@@ -955,12 +1393,62 @@ describe("Add/remove rows and columns", () => {
|
|
|
955
1393
|
expect(added_cell).toHaveFocus();
|
|
956
1394
|
});
|
|
957
1395
|
|
|
1396
|
+
test("empty table keeps its add row button on screen in fullscreen", async () => {
|
|
1397
|
+
const { getByRole } = await render(Dataframe, empty_dynamic_props);
|
|
1398
|
+
await wait();
|
|
1399
|
+
|
|
1400
|
+
const toggle = (): HTMLElement =>
|
|
1401
|
+
getByRole("button", { name: /fullscreen/i });
|
|
1402
|
+
|
|
1403
|
+
// A sibling that follows `.table-container` is laid out past the bottom of
|
|
1404
|
+
// the fixed fullscreen viewport, so the button has to be inside it.
|
|
1405
|
+
expect(
|
|
1406
|
+
document.querySelector(".table-container .add-row-button")
|
|
1407
|
+
).not.toBeNull();
|
|
1408
|
+
|
|
1409
|
+
function expect_button_under_header(): void {
|
|
1410
|
+
const box = getByRole("button", {
|
|
1411
|
+
name: "Add row"
|
|
1412
|
+
}).getBoundingClientRect();
|
|
1413
|
+
const header = (
|
|
1414
|
+
document.querySelector("thead") as HTMLElement
|
|
1415
|
+
).getBoundingClientRect();
|
|
1416
|
+
// getByRole already rules out a button that has left the accessibility
|
|
1417
|
+
// tree, but a zero-sized one would still satisfy the bounds below, and
|
|
1418
|
+
// an upper bound on its own also admits a button above the header.
|
|
1419
|
+
expect(box.height).toBeGreaterThan(0);
|
|
1420
|
+
expect(box.top).toBeGreaterThanOrEqual(header.bottom);
|
|
1421
|
+
expect(box.bottom).toBeLessThanOrEqual(window.innerHeight);
|
|
1422
|
+
expect(box.top - header.bottom).toBeLessThan(50);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
expect_button_under_header();
|
|
1426
|
+
|
|
1427
|
+
// Block animates into fullscreen over 0.1s and the container reaches its
|
|
1428
|
+
// final size a frame before the layout inside it does, so there is no DOM
|
|
1429
|
+
// state to wait on that is not still mid-settle. Measuring early reads the
|
|
1430
|
+
// pre-fullscreen geometry and passes against any layout.
|
|
1431
|
+
await fireEvent.click(toggle());
|
|
1432
|
+
await waitFor(() =>
|
|
1433
|
+
expect(
|
|
1434
|
+
document.querySelector(".table-container.fullscreen")
|
|
1435
|
+
).not.toBeNull()
|
|
1436
|
+
);
|
|
1437
|
+
await wait(300);
|
|
1438
|
+
expect_button_under_header();
|
|
1439
|
+
|
|
1440
|
+
await fireEvent.click(toggle());
|
|
1441
|
+
await waitFor(() =>
|
|
1442
|
+
expect(document.querySelector(".table-container.fullscreen")).toBeNull()
|
|
1443
|
+
);
|
|
1444
|
+
await wait(300);
|
|
1445
|
+
expect_button_under_header();
|
|
1446
|
+
});
|
|
1447
|
+
|
|
958
1448
|
// Cell menu add row tests: The CellMenu renders outside the table-wrap parent,
|
|
959
1449
|
// so the document click handler (handle_click_outside) unmounts it before
|
|
960
1450
|
// the menu button's onclick fires in synthetic event dispatch. These
|
|
961
1451
|
// interactions are covered by E2E tests in dataframe_events.spec.ts.
|
|
962
|
-
test.todo("add row above via cell menu");
|
|
963
|
-
test.todo("add row below via cell menu");
|
|
964
1452
|
|
|
965
1453
|
test("delete row via cell menu", async () => {
|
|
966
1454
|
const { container, getByTestId } = await render(Dataframe, dynamic_props);
|
|
@@ -1473,3 +1961,595 @@ describe("Boolean column select-all header checkbox", () => {
|
|
|
1473
1961
|
expect(admin.indeterminate).toBe(false);
|
|
1474
1962
|
});
|
|
1475
1963
|
});
|
|
1964
|
+
|
|
1965
|
+
describe("Dataframe CSV drop", () => {
|
|
1966
|
+
afterEach(() => cleanup());
|
|
1967
|
+
|
|
1968
|
+
const drop_props = {
|
|
1969
|
+
...default_props,
|
|
1970
|
+
value: {
|
|
1971
|
+
data: [["", ""]],
|
|
1972
|
+
headers: ["a", "b"],
|
|
1973
|
+
metadata: null
|
|
1974
|
+
},
|
|
1975
|
+
col_count: [2, "dynamic"] as [number, "dynamic"],
|
|
1976
|
+
row_count: [1, "dynamic"] as [number, "dynamic"]
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
// dispatched natively rather than through fireEvent: testing-library builds
|
|
1980
|
+
// its own DataTransfer and copies only own properties across, which leaves
|
|
1981
|
+
// the file list empty
|
|
1982
|
+
function drop_csv(
|
|
1983
|
+
container: HTMLElement,
|
|
1984
|
+
text: string,
|
|
1985
|
+
name = "data.csv"
|
|
1986
|
+
): void {
|
|
1987
|
+
const data_transfer = new DataTransfer();
|
|
1988
|
+
data_transfer.items.add(new File([text], name, { type: "text/csv" }));
|
|
1989
|
+
const upload_container = container.querySelector(
|
|
1990
|
+
".upload-container"
|
|
1991
|
+
) as HTMLElement;
|
|
1992
|
+
upload_container.dispatchEvent(
|
|
1993
|
+
new DragEvent("drop", {
|
|
1994
|
+
bubbles: true,
|
|
1995
|
+
cancelable: true,
|
|
1996
|
+
dataTransfer: data_transfer
|
|
1997
|
+
})
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function header_texts(container: HTMLElement): string[] {
|
|
2002
|
+
return Array.from(
|
|
2003
|
+
container.querySelectorAll("th.header-cell .header-content")
|
|
2004
|
+
).map((h) => h.textContent?.trim() ?? "");
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
test("imports a dropped CSV as headers and values", async () => {
|
|
2008
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2009
|
+
await wait();
|
|
2010
|
+
const change = listen("change");
|
|
2011
|
+
|
|
2012
|
+
drop_csv(container, "name,age\nAlice,30\nBob,25\n");
|
|
2013
|
+
await wait();
|
|
2014
|
+
|
|
2015
|
+
expect(header_texts(container)).toEqual(["name", "age"]);
|
|
2016
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2017
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("25");
|
|
2018
|
+
expect(change.mock.calls.at(-1)?.[0].data).toEqual([
|
|
2019
|
+
["Alice", "30"],
|
|
2020
|
+
["Bob", "25"]
|
|
2021
|
+
]);
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
test("imports a dropped TSV as headers and values", async () => {
|
|
2025
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2026
|
+
await wait();
|
|
2027
|
+
|
|
2028
|
+
drop_csv(container, "name\tage\nAlice\t30\nBob\t25\n", "data.tsv");
|
|
2029
|
+
await wait();
|
|
2030
|
+
|
|
2031
|
+
expect(header_texts(container)).toEqual(["name", "age"]);
|
|
2032
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2033
|
+
expect(get_cell(container, 1, 1)?.textContent).toContain("25");
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// counting raw tabs misses this: the quoted one makes the counts disagree
|
|
2037
|
+
// between the two lines, so nothing looks consistent
|
|
2038
|
+
test("imports a dropped TSV whose field contains a quoted tab", async () => {
|
|
2039
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2040
|
+
await wait();
|
|
2041
|
+
|
|
2042
|
+
drop_csv(container, 'name\tnote\nAlice\t"hello\tworld"\n', "data.tsv");
|
|
2043
|
+
await wait();
|
|
2044
|
+
|
|
2045
|
+
expect(header_texts(container)).toEqual(["name", "note"]);
|
|
2046
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2047
|
+
expect(get_cell(container, 0, 1)?.textContent).toContain("hello\tworld");
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
test("reports a dropped file that is neither CSV nor TSV", async () => {
|
|
2051
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2052
|
+
await wait();
|
|
2053
|
+
const change = listen("change");
|
|
2054
|
+
const error = listen("error");
|
|
2055
|
+
|
|
2056
|
+
drop_csv(container, "name,age\nAlice,30\n", "data.png");
|
|
2057
|
+
await wait();
|
|
2058
|
+
|
|
2059
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2060
|
+
expect(change).not.toHaveBeenCalled();
|
|
2061
|
+
expect(error).toHaveBeenCalled();
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
test("imports a dropped single-column CSV", async () => {
|
|
2065
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2066
|
+
await wait();
|
|
2067
|
+
|
|
2068
|
+
drop_csv(container, "name\nAlice\nBob\n");
|
|
2069
|
+
await wait();
|
|
2070
|
+
|
|
2071
|
+
expect(header_texts(container)).toEqual(["name"]);
|
|
2072
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2073
|
+
expect(get_cell(container, 1, 0)?.textContent).toContain("Bob");
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
function drag_over(target: Element, type: "dragenter" | "dragleave"): void {
|
|
2077
|
+
target.dispatchEvent(
|
|
2078
|
+
new DragEvent(type, { bubbles: true, cancelable: true })
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function drop_target(container: HTMLElement): Element {
|
|
2083
|
+
return container.querySelector(".upload-container")!;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
test("highlights the table while a file is dragged over it", async () => {
|
|
2087
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2088
|
+
await wait();
|
|
2089
|
+
const table_wrap = get_table_wrap(container);
|
|
2090
|
+
|
|
2091
|
+
drag_over(drop_target(container), "dragenter");
|
|
2092
|
+
await wait();
|
|
2093
|
+
expect(table_wrap).toHaveClass("file-dragging");
|
|
2094
|
+
|
|
2095
|
+
drag_over(drop_target(container), "dragleave");
|
|
2096
|
+
await wait();
|
|
2097
|
+
expect(table_wrap).not.toHaveClass("file-dragging");
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
test.todo(
|
|
2101
|
+
"VISUAL: the file drag outline is drawn inside the table's border and follows its corner radius, needs Playwright visual regression screenshot comparison"
|
|
2102
|
+
);
|
|
2103
|
+
|
|
2104
|
+
// #13729 moved this component off hidden sr-only text and onto ARIA attributes
|
|
2105
|
+
// on the grid, so the drop hint goes there too rather than into <Upload>, which
|
|
2106
|
+
// discards a label on its div branch
|
|
2107
|
+
test("announces the drop target on an editable table", async () => {
|
|
2108
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2109
|
+
await wait();
|
|
2110
|
+
|
|
2111
|
+
const described_by =
|
|
2112
|
+
get_table_wrap(container).getAttribute("aria-describedby");
|
|
2113
|
+
expect(described_by).toBeTruthy();
|
|
2114
|
+
// tootils stubs i18n as a passthrough, so the key stands in for the string
|
|
2115
|
+
// it resolves to (en.json: "Drop CSV or TSV files here to import data into
|
|
2116
|
+
// dataframe")
|
|
2117
|
+
expect(document.getElementById(described_by!)?.textContent).toBe(
|
|
2118
|
+
"dataframe.drop_to_upload"
|
|
2119
|
+
);
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
test("does not announce a drop target on a read-only table", async () => {
|
|
2123
|
+
const { container } = await render(Dataframe, {
|
|
2124
|
+
...drop_props,
|
|
2125
|
+
interactive: false
|
|
2126
|
+
});
|
|
2127
|
+
await wait();
|
|
2128
|
+
|
|
2129
|
+
expect(get_table_wrap(container)).not.toHaveAttribute("aria-describedby");
|
|
2130
|
+
expect(container.querySelector(".drop-hint")).toBeNull();
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
test("does not highlight the table when it is not interactive", async () => {
|
|
2134
|
+
const { container } = await render(Dataframe, {
|
|
2135
|
+
...drop_props,
|
|
2136
|
+
interactive: false
|
|
2137
|
+
});
|
|
2138
|
+
await wait();
|
|
2139
|
+
|
|
2140
|
+
drag_over(drop_target(container), "dragenter");
|
|
2141
|
+
await wait();
|
|
2142
|
+
|
|
2143
|
+
expect(get_table_wrap(container)).not.toHaveClass("file-dragging");
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
test("ignores a dropped CSV when the dataframe is not interactive", async () => {
|
|
2147
|
+
const { container, listen } = await render(Dataframe, {
|
|
2148
|
+
...drop_props,
|
|
2149
|
+
interactive: false
|
|
2150
|
+
});
|
|
2151
|
+
await wait();
|
|
2152
|
+
const change = listen("change");
|
|
2153
|
+
const input = listen("input");
|
|
2154
|
+
|
|
2155
|
+
drop_csv(container, "name,age\nAlice,30\nBob,25\n");
|
|
2156
|
+
await wait();
|
|
2157
|
+
|
|
2158
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2159
|
+
expect(change).not.toHaveBeenCalled();
|
|
2160
|
+
expect(input).not.toHaveBeenCalled();
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
const dynamic_3x3_props = {
|
|
2164
|
+
...drop_props,
|
|
2165
|
+
value: {
|
|
2166
|
+
data: [
|
|
2167
|
+
["a1", "b1", "c1"],
|
|
2168
|
+
["a2", "b2", "c2"],
|
|
2169
|
+
["a3", "b3", "c3"]
|
|
2170
|
+
],
|
|
2171
|
+
headers: ["A", "B", "C"],
|
|
2172
|
+
metadata: null
|
|
2173
|
+
},
|
|
2174
|
+
col_count: [3, "dynamic"] as [number, "dynamic"],
|
|
2175
|
+
row_count: [3, "dynamic"] as [number, "dynamic"]
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
const fixed_props = {
|
|
2179
|
+
...drop_props,
|
|
2180
|
+
value: {
|
|
2181
|
+
data: [
|
|
2182
|
+
["", ""],
|
|
2183
|
+
["", ""]
|
|
2184
|
+
],
|
|
2185
|
+
headers: ["a", "b"],
|
|
2186
|
+
metadata: null
|
|
2187
|
+
},
|
|
2188
|
+
col_count: [2, "fixed"] as [number, "fixed"],
|
|
2189
|
+
row_count: [2, "fixed"] as [number, "fixed"]
|
|
2190
|
+
};
|
|
2191
|
+
|
|
2192
|
+
// both delimiters split every line into the same number of fields, so the
|
|
2193
|
+
// extension breaks the tie
|
|
2194
|
+
test("imports a dropped TSV whose fields contain commas", async () => {
|
|
2195
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2196
|
+
await wait();
|
|
2197
|
+
|
|
2198
|
+
drop_csv(container, "first,last\tage\nAlice,Smith\t30\n", "data.tsv");
|
|
2199
|
+
await wait();
|
|
2200
|
+
|
|
2201
|
+
expect(header_texts(container)).toEqual(["first,last", "age"]);
|
|
2202
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice,Smith");
|
|
2203
|
+
expect(get_cell(container, 0, 1)?.textContent).toContain("30");
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
// the name says tab, but every tab-split of this file is one field wide, so it
|
|
2207
|
+
// has to be read as the comma-separated file it actually is
|
|
2208
|
+
test("imports a mislabeled TSV whose field holds a quoted comma", async () => {
|
|
2209
|
+
const { container } = await render(Dataframe, drop_props);
|
|
2210
|
+
await wait();
|
|
2211
|
+
|
|
2212
|
+
drop_csv(container, 'name,note\nAlice,"x,y"\n', "data.tsv");
|
|
2213
|
+
await wait();
|
|
2214
|
+
|
|
2215
|
+
expect(header_texts(container)).toEqual(["name", "note"]);
|
|
2216
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2217
|
+
expect(get_cell(container, 0, 1)?.textContent).toContain("x,y");
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
test("rejects a dropped file whose rows do not match its header", async () => {
|
|
2221
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2222
|
+
await wait();
|
|
2223
|
+
const change = listen("change");
|
|
2224
|
+
const error = listen("error");
|
|
2225
|
+
|
|
2226
|
+
drop_csv(container, "name,age\nAlice,30,Engineer\n");
|
|
2227
|
+
await wait();
|
|
2228
|
+
|
|
2229
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2230
|
+
expect(change).not.toHaveBeenCalled();
|
|
2231
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
2232
|
+
|
|
2233
|
+
drop_csv(container, "name,age\nAlice\n");
|
|
2234
|
+
await wait();
|
|
2235
|
+
|
|
2236
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2237
|
+
expect(change).not.toHaveBeenCalled();
|
|
2238
|
+
expect(error).toHaveBeenCalledTimes(2);
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
test("reports an error and keeps the table when the file is blank", async () => {
|
|
2242
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2243
|
+
await wait();
|
|
2244
|
+
const change = listen("change");
|
|
2245
|
+
const error = listen("error");
|
|
2246
|
+
|
|
2247
|
+
drop_csv(container, " \n");
|
|
2248
|
+
await wait();
|
|
2249
|
+
|
|
2250
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2251
|
+
expect(change).not.toHaveBeenCalled();
|
|
2252
|
+
expect(error).toHaveBeenCalled();
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
// separators alone parse into a header of blank names, which would replace
|
|
2256
|
+
// the table with unnamed columns instead of reporting anything
|
|
2257
|
+
test("reports an error and keeps the table when the file has no column names", async () => {
|
|
2258
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2259
|
+
await wait();
|
|
2260
|
+
const change = listen("change");
|
|
2261
|
+
const error = listen("error");
|
|
2262
|
+
|
|
2263
|
+
drop_csv(container, ",\n");
|
|
2264
|
+
await wait();
|
|
2265
|
+
|
|
2266
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2267
|
+
expect(change).not.toHaveBeenCalled();
|
|
2268
|
+
expect(error).toHaveBeenCalled();
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
test("imports a dropped CSV that ends with a blank line", async () => {
|
|
2272
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2273
|
+
await wait();
|
|
2274
|
+
const change = listen("change");
|
|
2275
|
+
const error = listen("error");
|
|
2276
|
+
|
|
2277
|
+
drop_csv(container, "name,age\nAlice,30\n\n");
|
|
2278
|
+
await wait();
|
|
2279
|
+
|
|
2280
|
+
expect(header_texts(container)).toEqual(["name", "age"]);
|
|
2281
|
+
expect(error).not.toHaveBeenCalled();
|
|
2282
|
+
expect(change.mock.calls.at(-1)?.[0].data).toEqual([["Alice", "30"]]);
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
// the name says comma and the header does split on one, but only the tab
|
|
2286
|
+
// splits every line, so the name loses to the file's own shape
|
|
2287
|
+
test("imports a mislabeled CSV whose header holds a tab", async () => {
|
|
2288
|
+
const { container, listen } = await render(Dataframe, drop_props);
|
|
2289
|
+
await wait();
|
|
2290
|
+
const error = listen("error");
|
|
2291
|
+
|
|
2292
|
+
drop_csv(container, "first,last\tage\nAlice\t30\n");
|
|
2293
|
+
await wait();
|
|
2294
|
+
|
|
2295
|
+
expect(header_texts(container)).toEqual(["first,last", "age"]);
|
|
2296
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("Alice");
|
|
2297
|
+
expect(error).not.toHaveBeenCalled();
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
// a dropped file the upload rejects would otherwise report a type error on a
|
|
2301
|
+
// table that ignores dropped files in the first place
|
|
2302
|
+
test("reports nothing for a file dropped on a read-only table", async () => {
|
|
2303
|
+
const { container, listen } = await render(Dataframe, {
|
|
2304
|
+
...drop_props,
|
|
2305
|
+
interactive: false
|
|
2306
|
+
});
|
|
2307
|
+
await wait();
|
|
2308
|
+
const error = listen("error");
|
|
2309
|
+
|
|
2310
|
+
drop_csv(container, "name,age\nAlice,30\n", "data.png");
|
|
2311
|
+
await wait();
|
|
2312
|
+
|
|
2313
|
+
expect(error).not.toHaveBeenCalled();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
// a value can reach the frontend ragged, and a cell past the end of its own
|
|
2317
|
+
// row still renders and still takes an edit
|
|
2318
|
+
test("keeps an edit to a cell in a row shorter than the header", async () => {
|
|
2319
|
+
const { container, listen } = await render(Dataframe, {
|
|
2320
|
+
...drop_props,
|
|
2321
|
+
value: {
|
|
2322
|
+
data: [["a1"], ["a2", "b2"]],
|
|
2323
|
+
headers: ["A", "B"],
|
|
2324
|
+
metadata: null
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
await wait();
|
|
2328
|
+
const edit = listen("edit");
|
|
2329
|
+
|
|
2330
|
+
const cell = get_cell(container, 0, 1)!;
|
|
2331
|
+
await fireEvent.mouseDown(cell);
|
|
2332
|
+
await fireEvent.dblClick(cell);
|
|
2333
|
+
await wait();
|
|
2334
|
+
|
|
2335
|
+
const textarea = container.querySelector(
|
|
2336
|
+
"textarea[aria-label='Edit cell']"
|
|
2337
|
+
) as HTMLTextAreaElement;
|
|
2338
|
+
textarea.value = "typed";
|
|
2339
|
+
await fireEvent.input(textarea);
|
|
2340
|
+
await fireEvent.blur(textarea);
|
|
2341
|
+
await wait();
|
|
2342
|
+
|
|
2343
|
+
expect(edit).toHaveBeenCalled();
|
|
2344
|
+
expect(get_cell(container, 0, 1)?.textContent).toContain("typed");
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
// the cell unmounts while still in edit mode, and EditableCell commits on
|
|
2348
|
+
// teardown, so the stale coordinates land in the table that replaced it
|
|
2349
|
+
test("drops an in-flight cell edit when the imported table is narrower", async () => {
|
|
2350
|
+
const { container, listen } = await render(Dataframe, dynamic_3x3_props);
|
|
2351
|
+
await wait();
|
|
2352
|
+
const change = listen("change");
|
|
2353
|
+
const edit = listen("edit");
|
|
2354
|
+
|
|
2355
|
+
await fireEvent.dblClick(get_cell(container, 0, 2)!);
|
|
2356
|
+
await wait();
|
|
2357
|
+
|
|
2358
|
+
drop_csv(container, "name,age\nAlice,30\n");
|
|
2359
|
+
await wait();
|
|
2360
|
+
|
|
2361
|
+
expect(edit).not.toHaveBeenCalled();
|
|
2362
|
+
expect(change.mock.calls.at(-1)?.[0].data).toEqual([["Alice", "30"]]);
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
test("drops an in-flight cell edit when the imported table is shorter", async () => {
|
|
2366
|
+
const { container, listen } = await render(Dataframe, dynamic_3x3_props);
|
|
2367
|
+
await wait();
|
|
2368
|
+
const change = listen("change");
|
|
2369
|
+
const edit = listen("edit");
|
|
2370
|
+
|
|
2371
|
+
await fireEvent.dblClick(get_cell(container, 2, 0)!);
|
|
2372
|
+
await wait();
|
|
2373
|
+
|
|
2374
|
+
drop_csv(container, "name,age,role\nAlice,30,Engineer\n");
|
|
2375
|
+
await wait();
|
|
2376
|
+
|
|
2377
|
+
expect(edit).not.toHaveBeenCalled();
|
|
2378
|
+
expect(change.mock.calls.at(-1)?.[0].data).toEqual([
|
|
2379
|
+
["Alice", "30", "Engineer"]
|
|
2380
|
+
]);
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
// the shorter case above is caught by the row guard in handle_blur. this one
|
|
2384
|
+
// is not: the edited cell is still in range after the import, so the
|
|
2385
|
+
// teardown commit lands on a row that exists
|
|
2386
|
+
test("drops an in-flight cell edit when the imported table is the same shape", async () => {
|
|
2387
|
+
const { container, listen } = await render(Dataframe, dynamic_3x3_props);
|
|
2388
|
+
await wait();
|
|
2389
|
+
const change = listen("change");
|
|
2390
|
+
const edit = listen("edit");
|
|
2391
|
+
|
|
2392
|
+
const cell = get_cell(container, 0, 0)!;
|
|
2393
|
+
await fireEvent.mouseDown(cell);
|
|
2394
|
+
await fireEvent.dblClick(cell);
|
|
2395
|
+
await wait();
|
|
2396
|
+
|
|
2397
|
+
const textarea = container.querySelector(
|
|
2398
|
+
"textarea[aria-label='Edit cell']"
|
|
2399
|
+
) as HTMLTextAreaElement;
|
|
2400
|
+
textarea.value = "typed";
|
|
2401
|
+
await fireEvent.input(textarea);
|
|
2402
|
+
await wait();
|
|
2403
|
+
|
|
2404
|
+
drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
|
|
2405
|
+
await wait();
|
|
2406
|
+
|
|
2407
|
+
expect(edit).not.toHaveBeenCalled();
|
|
2408
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("1");
|
|
2409
|
+
expect(change.mock.calls.at(-1)?.[0].data).toEqual([
|
|
2410
|
+
["1", "2", "3"],
|
|
2411
|
+
["4", "5", "6"],
|
|
2412
|
+
["7", "8", "9"]
|
|
2413
|
+
]);
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
// a coordinate kept from the old table reaches handle_copy, which reads it
|
|
2417
|
+
// out of the new one. asserted against an import of the same shape, so the
|
|
2418
|
+
// cell is still in the DOM and its class means something
|
|
2419
|
+
test("clears the cell selection when a file is imported", async () => {
|
|
2420
|
+
const { container } = await render(Dataframe, dynamic_3x3_props);
|
|
2421
|
+
await wait();
|
|
2422
|
+
|
|
2423
|
+
await fireEvent.mouseDown(get_cell(container, 2, 2)!);
|
|
2424
|
+
await wait();
|
|
2425
|
+
expect(get_cell(container, 2, 2)?.className).toContain("cell-selected");
|
|
2426
|
+
|
|
2427
|
+
drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
|
|
2428
|
+
await wait();
|
|
2429
|
+
|
|
2430
|
+
expect(get_cell(container, 2, 2)?.className).not.toContain("cell-selected");
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
// tanstack keys sorting and column filters by positional col_N, and a search
|
|
2434
|
+
// left over from the old table hides imported rows, which commit_filter then
|
|
2435
|
+
// drops from the value entirely
|
|
2436
|
+
test("clears the search when a file is imported", async () => {
|
|
2437
|
+
const { container } = await render(Dataframe, {
|
|
2438
|
+
...dynamic_3x3_props,
|
|
2439
|
+
show_search: "search"
|
|
2440
|
+
});
|
|
2441
|
+
await wait();
|
|
2442
|
+
|
|
2443
|
+
const search = container.querySelector(
|
|
2444
|
+
"input.search-input"
|
|
2445
|
+
) as HTMLInputElement;
|
|
2446
|
+
search.value = "a1";
|
|
2447
|
+
await fireEvent.input(search);
|
|
2448
|
+
await wait();
|
|
2449
|
+
expect(get_cell(container, 1, 0)).toBeNull();
|
|
2450
|
+
|
|
2451
|
+
drop_csv(container, "x,y,z\n1,2,3\n4,5,6\n7,8,9\n");
|
|
2452
|
+
await wait();
|
|
2453
|
+
|
|
2454
|
+
expect(get_cell(container, 0, 0)?.textContent).toContain("1");
|
|
2455
|
+
expect(get_cell(container, 2, 0)?.textContent).toContain("7");
|
|
2456
|
+
});
|
|
2457
|
+
|
|
2458
|
+
// dragenter on the cell being entered arrives before dragleave on the one
|
|
2459
|
+
// being left, and both bubble to the drop target
|
|
2460
|
+
test("keeps the highlight while the file moves between cells", async () => {
|
|
2461
|
+
const { container } = await render(Dataframe, dynamic_3x3_props);
|
|
2462
|
+
await wait();
|
|
2463
|
+
const table_wrap = get_table_wrap(container);
|
|
2464
|
+
const first = get_cell(container, 0, 0)!;
|
|
2465
|
+
const second = get_cell(container, 0, 1)!;
|
|
2466
|
+
|
|
2467
|
+
drag_over(first, "dragenter");
|
|
2468
|
+
await wait();
|
|
2469
|
+
expect(table_wrap).toHaveClass("file-dragging");
|
|
2470
|
+
|
|
2471
|
+
drag_over(second, "dragenter");
|
|
2472
|
+
drag_over(first, "dragleave");
|
|
2473
|
+
await wait();
|
|
2474
|
+
expect(table_wrap).toHaveClass("file-dragging");
|
|
2475
|
+
|
|
2476
|
+
drag_over(second, "dragleave");
|
|
2477
|
+
await wait();
|
|
2478
|
+
expect(table_wrap).not.toHaveClass("file-dragging");
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
test("imports into a fixed-shape dataframe when the file matches", async () => {
|
|
2482
|
+
const { container } = await render(Dataframe, fixed_props);
|
|
2483
|
+
await wait();
|
|
2484
|
+
|
|
2485
|
+
drop_csv(container, "name,age\nAlice,30\nBob,25\n");
|
|
2486
|
+
await wait();
|
|
2487
|
+
|
|
2488
|
+
expect(header_texts(container)).toEqual(["name", "age"]);
|
|
2489
|
+
expect(get_cell(container, 1, 0)?.textContent).toContain("Bob");
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test("rejects a dropped file that does not fit a fixed column count", async () => {
|
|
2493
|
+
const { container, listen } = await render(Dataframe, fixed_props);
|
|
2494
|
+
await wait();
|
|
2495
|
+
const change = listen("change");
|
|
2496
|
+
const error = listen("error");
|
|
2497
|
+
|
|
2498
|
+
drop_csv(container, "name,age,role\nAlice,30,Engineer\nBob,25,Designer\n");
|
|
2499
|
+
await wait();
|
|
2500
|
+
|
|
2501
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2502
|
+
expect(change).not.toHaveBeenCalled();
|
|
2503
|
+
expect(error).toHaveBeenCalled();
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
// the passthrough i18n stub returns the bare key, which has no placeholders
|
|
2507
|
+
// to fill, so this one swaps in a translation of its own
|
|
2508
|
+
test("reports an import error through i18n with its counts filled in", async () => {
|
|
2509
|
+
const { container, listen } = await render(Dataframe, {
|
|
2510
|
+
...fixed_props,
|
|
2511
|
+
i18n: (key: string) =>
|
|
2512
|
+
key === "dataframe.import_fixed_columns"
|
|
2513
|
+
? "want {expected} columns, got {actual}"
|
|
2514
|
+
: key
|
|
2515
|
+
});
|
|
2516
|
+
await wait();
|
|
2517
|
+
const error = listen("error");
|
|
2518
|
+
|
|
2519
|
+
drop_csv(container, "name,age,role\nAlice,30,Engineer\nBob,25,Designer\n");
|
|
2520
|
+
await wait();
|
|
2521
|
+
|
|
2522
|
+
expect(error).toHaveBeenCalledWith("want 2 columns, got 3");
|
|
2523
|
+
});
|
|
2524
|
+
|
|
2525
|
+
test("rejects a dropped file that does not fit a fixed row count", async () => {
|
|
2526
|
+
const { container, listen } = await render(Dataframe, fixed_props);
|
|
2527
|
+
await wait();
|
|
2528
|
+
const change = listen("change");
|
|
2529
|
+
const error = listen("error");
|
|
2530
|
+
|
|
2531
|
+
drop_csv(container, "name,age\nAlice,30\n");
|
|
2532
|
+
await wait();
|
|
2533
|
+
|
|
2534
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2535
|
+
expect(change).not.toHaveBeenCalled();
|
|
2536
|
+
expect(error).toHaveBeenCalled();
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
test("rejects a dropped file when a column is read-only", async () => {
|
|
2540
|
+
const { container, listen } = await render(Dataframe, {
|
|
2541
|
+
...drop_props,
|
|
2542
|
+
static_columns: [0]
|
|
2543
|
+
});
|
|
2544
|
+
await wait();
|
|
2545
|
+
const change = listen("change");
|
|
2546
|
+
const error = listen("error");
|
|
2547
|
+
|
|
2548
|
+
drop_csv(container, "name,age\nAlice,30\n");
|
|
2549
|
+
await wait();
|
|
2550
|
+
|
|
2551
|
+
expect(header_texts(container)).toEqual(["a", "b"]);
|
|
2552
|
+
expect(change).not.toHaveBeenCalled();
|
|
2553
|
+
expect(error).toHaveBeenCalled();
|
|
2554
|
+
});
|
|
2555
|
+
});
|