@gradio/dataframe 0.24.5 → 0.24.7

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.
@@ -35,7 +35,7 @@
35
35
  is_cell_selected,
36
36
  handle_click_outside as handle_click_outside_util
37
37
  } from "./utils/selection_utils";
38
- import { copy_table_data, handle_file_upload } from "./utils/table_utils";
38
+ import { copy_table_data, parse_table_file } from "./utils/table_utils";
39
39
  import { gradio_filter_fn } from "./utils/filter";
40
40
  import { create_column_measurement } from "./column_measurement.svelte.js";
41
41
 
@@ -73,7 +73,8 @@
73
73
  onselect,
74
74
  onedit,
75
75
  onsearch,
76
- onfullscreen
76
+ onfullscreen,
77
+ onerror
77
78
  }: {
78
79
  datatype: Datatype | Datatype[];
79
80
  label?: string | null;
@@ -109,6 +110,7 @@
109
110
  onedit?: (detail: EditData) => void;
110
111
  onsearch?: (detail: string | null) => void;
111
112
  onfullscreen?: () => void;
113
+ onerror?: (message: string) => void;
112
114
  } = $props();
113
115
 
114
116
  type GradioRow = Record<string, CellValue> & { _index: number };
@@ -277,7 +279,11 @@
277
279
  let copy_flash = $state(false);
278
280
  let is_dragging = $state(false);
279
281
  let show_scroll_button = $state(false);
280
- let dragging = $state(false); // file drag
282
+ let file_dragging = $state(false);
283
+ let discard_pending_edit = false;
284
+ // document-unique so several dataframes on a page do not describe each
285
+ // other's grids
286
+ const drop_hint_id = $props.id();
281
287
 
282
288
  let parent: HTMLDivElement;
283
289
 
@@ -603,12 +609,21 @@
603
609
  blur_event: FocusEvent;
604
610
  coords: [number, number];
605
611
  }): void {
612
+ // an import replaces the table under an open editor, and EditableCell
613
+ // commits when it leaves edit mode either way. that half-typed value
614
+ // belongs to the table that just went away
615
+ if (discard_pending_edit) return;
616
+
606
617
  const { coords } = detail;
607
618
  const input_el = detail.blur_event.target as HTMLTextAreaElement;
608
619
  if (!input_el || input_el.value === undefined) return;
609
620
 
610
621
  const [row, col] = coords;
611
- const old_value = values?.[row]?.[col];
622
+ // and deleting a row leaves the editor's row behind. a column short of
623
+ // the header is not the same thing: a value can arrive ragged, and the
624
+ // cell past the end of its own row still renders and still takes an edit
625
+ if (!values?.[row]) return;
626
+ const old_value = values[row][col];
612
627
  const new_value = input_el.value;
613
628
 
614
629
  if (String(old_value) !== String(new_value)) {
@@ -858,16 +873,22 @@
858
873
  return true;
859
874
  }
860
875
 
876
+ // every one of these holds a row or column index, so they have to go
877
+ // whenever the table stops being the one they were recorded against
878
+ function reset_interaction_state(): void {
879
+ selected_cells = [];
880
+ selected = false;
881
+ range_anchor = false;
882
+ editing = false;
883
+ header_edit = false;
884
+ selected_header = false;
885
+ active_cell_menu = null;
886
+ active_header_menu = null;
887
+ }
888
+
861
889
  function handle_click_outside(event: Event): void {
862
890
  if (handle_click_outside_util(event, parent)) {
863
- selected_cells = [];
864
- selected = false;
865
- range_anchor = false;
866
- editing = false;
867
- header_edit = false;
868
- selected_header = false;
869
- active_cell_menu = null;
870
- active_header_menu = null;
891
+ reset_interaction_state();
871
892
  }
872
893
  }
873
894
 
@@ -1113,21 +1134,87 @@
1113
1134
  }
1114
1135
  }
1115
1136
 
1116
- function on_file_upload(file_data: any): void {
1117
- handle_file_upload(
1118
- typeof file_data === "string" ? file_data : (file_data?.data ?? ""),
1119
- (head) => {
1120
- headers = head.map((h: any) => h ?? "");
1121
- return (headers as string[]).map((h: string, i: number) => ({
1122
- id: `h_${i}`,
1123
- value: h
1124
- }));
1125
- },
1126
- (vals) => {
1127
- values = vals;
1128
- push_change(vals, headers as string[]);
1129
- }
1137
+ function interpolate(
1138
+ message: string,
1139
+ values: Record<string, string | number>
1140
+ ): string {
1141
+ return message.replace(/\{(\w+)\}/g, (_, key) => String(values[key]));
1142
+ }
1143
+
1144
+ function apply_imported_table(
1145
+ new_headers: (string | null)[],
1146
+ new_values: CellValue[][]
1147
+ ): void {
1148
+ if (!new_headers.length) {
1149
+ throw new Error(i18n("dataframe.import_empty_file"));
1150
+ }
1151
+ // a file of separators alone parses into a header of blank names, and
1152
+ // importing it would replace the table with unnamed columns
1153
+ if (new_headers.every((h) => !h?.trim())) {
1154
+ throw new Error(i18n("dataframe.import_no_column_names"));
1155
+ }
1156
+ // a row the header cannot account for would reach the backend as a ragged
1157
+ // value, and the column checks below only see the header
1158
+ const ragged = new_values.findIndex(
1159
+ (row) => row.length !== new_headers.length
1130
1160
  );
1161
+ if (ragged !== -1) {
1162
+ throw new Error(
1163
+ interpolate(i18n("dataframe.import_ragged_row"), {
1164
+ line: ragged + 2,
1165
+ fields: new_values[ragged].length,
1166
+ columns: new_headers.length
1167
+ })
1168
+ );
1169
+ }
1170
+ // the menu paths already refuse to change a fixed shape or write to a
1171
+ // read-only column, so an import must not be the way around them
1172
+ if (static_columns.length > 0) {
1173
+ throw new Error(i18n("dataframe.import_static_columns"));
1174
+ }
1175
+ if (col_count[1] === "fixed" && new_headers.length !== col_count[0]) {
1176
+ throw new Error(
1177
+ interpolate(i18n("dataframe.import_fixed_columns"), {
1178
+ expected: col_count[0],
1179
+ actual: new_headers.length
1180
+ })
1181
+ );
1182
+ }
1183
+ if (row_count[1] === "fixed" && new_values.length !== row_count[0]) {
1184
+ throw new Error(
1185
+ interpolate(i18n("dataframe.import_fixed_rows"), {
1186
+ expected: row_count[0],
1187
+ actual: new_values.length
1188
+ })
1189
+ );
1190
+ }
1191
+
1192
+ discard_pending_edit = true;
1193
+ headers = new_headers;
1194
+ values = new_values;
1195
+ reset_interaction_state();
1196
+ // tanstack keys these by positional col_N, so they would land on whichever
1197
+ // column now sits at that index. a search is worse than misplaced: it hides
1198
+ // imported rows, and commit_filter then drops the hidden ones from the value
1199
+ sorting = [];
1200
+ column_filters = [];
1201
+ global_filter = "";
1202
+ push_change(new_values, headers as string[]);
1203
+ tick().then(() => (discard_pending_edit = false));
1204
+ }
1205
+
1206
+ // undefined when every dropped file was filtered out by `filetype`
1207
+ function on_file_upload(file: File | undefined): void {
1208
+ if (!editable || !file) return;
1209
+ parse_table_file(file)
1210
+ .then(({ headers: new_headers, values: new_values }) =>
1211
+ apply_imported_table(new_headers, new_values)
1212
+ )
1213
+ .catch((e) => {
1214
+ const message = e instanceof Error ? e.message : String(e);
1215
+ if (onerror) onerror(message);
1216
+ else console.error(message);
1217
+ });
1131
1218
  }
1132
1219
 
1133
1220
  onMount(() => {
@@ -1225,6 +1312,7 @@
1225
1312
  bind:this={parent}
1226
1313
  class="table-wrap"
1227
1314
  class:dragging={is_dragging}
1315
+ class:file-dragging={file_dragging && editable}
1228
1316
  class:menu-open={active_cell_menu || active_header_menu}
1229
1317
  onkeydown={handle_keydown}
1230
1318
  role="grid"
@@ -1232,6 +1320,7 @@
1232
1320
  aria-rowcount={rows.length + 1}
1233
1321
  aria-colcount={resolved_headers.length + Number(show_row_numbers)}
1234
1322
  aria-readonly={!editable}
1323
+ aria-describedby={editable ? drop_hint_id : undefined}
1235
1324
  tabindex={rows.length === 0 ? 0 : -1}
1236
1325
  style="--df-max-col-width: {viewport_width}px;"
1237
1326
  >
@@ -1242,10 +1331,12 @@
1242
1331
  center={false}
1243
1332
  boundedheight={false}
1244
1333
  disable_click={true}
1334
+ format="blob"
1335
+ filetype={[".csv", ".tsv"]}
1245
1336
  {root}
1246
1337
  onload={on_file_upload}
1247
- bind:dragging
1248
- aria_label={i18n("dataframe.drop_to_upload")}
1338
+ onerror={editable ? onerror : undefined}
1339
+ bind:dragging={file_dragging}
1249
1340
  tab_index={-1}
1250
1341
  container_element="div"
1251
1342
  >
@@ -1470,6 +1561,15 @@
1470
1561
  {/if}
1471
1562
  </div>
1472
1563
 
1564
+ <!-- outside the grid: #13729 took the label off a span like this one and put it
1565
+ on the grid as an attribute, and a test pins that there is no sr-only text
1566
+ inside. a description has no equally well supported attribute form -->
1567
+ {#if editable}
1568
+ <span id={drop_hint_id} class="drop-hint"
1569
+ >{i18n("dataframe.drop_to_upload")}</span
1570
+ >
1571
+ {/if}
1572
+
1473
1573
  {#if values.length === 0 && editable && row_count[1] === "dynamic"}
1474
1574
  <EmptyRowButton on_click={() => add_row()} />
1475
1575
  {/if}
@@ -1585,8 +1685,13 @@
1585
1685
 
1586
1686
  .table-wrap {
1587
1687
  position: relative;
1588
- transition: 150ms;
1589
1688
  width: 100%;
1689
+ /* the duration used to stand alone, which means every property. that
1690
+ swept the drag outline in from `currentcolor`, near-white in dark
1691
+ mode, before it reached the accent. the list is what the fullscreen
1692
+ rules above actually change */
1693
+ transition-property: flex-grow, flex-shrink, flex-basis, min-height;
1694
+ transition-duration: 150ms;
1590
1695
  }
1591
1696
 
1592
1697
  /* Constrain Upload component wrapper */
@@ -1602,6 +1707,27 @@
1602
1707
  outline: none;
1603
1708
  }
1604
1709
 
1710
+ /* after :focus-within, which is equally specific and would otherwise
1711
+ clear the outline whenever a cell is focused. drawn inside the box so
1712
+ hovering a file over the table shifts nothing */
1713
+ .table-wrap.file-dragging {
1714
+ outline: 2px solid var(--color-accent);
1715
+ outline-offset: -2px;
1716
+ /* matches the .upload-container border the outline covers */
1717
+ border-radius: var(--table-radius);
1718
+ }
1719
+
1720
+ /* an aria-describedby target, so it has to stay in the accessibility tree
1721
+ rather than be hidden with display: none */
1722
+ .drop-hint {
1723
+ position: absolute;
1724
+ clip-path: inset(50%);
1725
+ width: 1px;
1726
+ height: 1px;
1727
+ overflow: hidden;
1728
+ white-space: nowrap;
1729
+ }
1730
+
1605
1731
  .table-wrap.dragging {
1606
1732
  cursor: crosshair !important;
1607
1733
  user-select: none;
package/shared/types.ts CHANGED
@@ -3,12 +3,6 @@ export type EditingState = CellCoordinate | false;
3
3
 
4
4
  export type Headers = (string | null)[];
5
5
 
6
- export interface HeadersWithIDs {
7
- id: string;
8
- value: string;
9
- }
10
- [];
11
-
12
6
  export type CellValue = string | number | boolean;
13
7
 
14
8
  export interface TableCell {
@@ -1,4 +1,4 @@
1
- import type { CellValue, Headers, HeadersWithIDs, TableData } from "../types";
1
+ import type { CellValue, Headers, TableData } from "../types";
2
2
  import { dsvFormat } from "d3-dsv";
3
3
 
4
4
  export function make_cell_id(row: number, col: number): string {
@@ -50,49 +50,43 @@ export async function copy_table_data(
50
50
  }
51
51
  }
52
52
 
53
- export function guess_delimiter(
54
- text: string,
55
- possibleDelimiters: string[]
56
- ): string[] {
57
- return possibleDelimiters.filter(weedOut);
58
-
59
- function weedOut(delimiter: string): boolean {
60
- var cache = -1;
61
- return text.split("\n").every(checkLength);
62
-
63
- function checkLength(line: string): boolean {
64
- if (!line) return true;
65
- var length = line.split(delimiter).length;
66
- if (cache < 0) cache = length;
67
- return cache === length && length > 1;
53
+ export async function parse_table_file(
54
+ file: File
55
+ ): Promise<{ headers: Headers; values: CellValue[][] }> {
56
+ const text = await file.text();
57
+ if (!text.trim()) return { headers: [], values: [] };
58
+ // the drop zone only accepts .csv and .tsv, so the extension goes first and
59
+ // wins any tie, but a mislabeled file is still read by its content: a
60
+ // separator only takes the file if it splits every line the same way, which
61
+ // is what tells a real separator from one that sits in a header name. the
62
+ // split is d3's rather than a raw count, so a separator inside a quoted
63
+ // field does not throw the detection off.
64
+ const by_extension = file.name.toLowerCase().endsWith(".tsv") ? "\t" : ",";
65
+ const candidates = by_extension === "\t" ? ["\t", ","] : [",", "\t"];
66
+ let consistent: string[][] | undefined;
67
+ let ragged: string[][] | undefined;
68
+ let unsplit: string[][] | undefined;
69
+ for (const delimiter of candidates) {
70
+ // a blank line parses to a single empty field, which both the width
71
+ // checks below and the caller's ragged check would read as a short row
72
+ const parsed = dsvFormat(delimiter)
73
+ .parseRows(text)
74
+ .filter((row) => row.length > 1 || row[0] !== "");
75
+ const width = parsed[0]?.length ?? 0;
76
+ // the extension is first in `candidates`, so this keeps its parse for the
77
+ // single-column case rather than running it again at the end
78
+ unsplit ??= parsed;
79
+ if (width < 2) continue;
80
+ if (parsed.every((row) => row.length === width)) {
81
+ consistent = parsed;
82
+ break;
68
83
  }
84
+ // a separator that splits the header but leaves the rows uneven is
85
+ // usually the one that just happens to appear in a header name. keep it
86
+ // only in case the other does not split at all, where a ragged table is
87
+ // still better to report on than an unsplit one
88
+ ragged ??= parsed;
69
89
  }
70
- }
71
-
72
- export function data_uri_to_blob(data_uri: string): Blob {
73
- const byte_str = atob(data_uri.split(",")[1]);
74
- const mime_str = data_uri.split(",")[0].split(":")[1].split(";")[0];
75
- const ab = new ArrayBuffer(byte_str.length);
76
- const ia = new Uint8Array(ab);
77
- for (let i = 0; i < byte_str.length; i++) {
78
- ia[i] = byte_str.charCodeAt(i);
79
- }
80
- return new Blob([ab], { type: mime_str });
81
- }
82
-
83
- export function handle_file_upload(
84
- data_uri: string,
85
- update_headers: (headers: Headers) => HeadersWithIDs[],
86
- update_values: (values: CellValue[][]) => void
87
- ): void {
88
- const blob = data_uri_to_blob(data_uri);
89
- const reader = new FileReader();
90
- reader.addEventListener("loadend", (e) => {
91
- if (!e?.target?.result || typeof e.target.result !== "string") return;
92
- const [delimiter] = guess_delimiter(e.target.result, [",", "\t"]);
93
- const [head, ...rest] = dsvFormat(delimiter).parseRows(e.target.result);
94
- update_headers(head);
95
- update_values(rest);
96
- });
97
- reader.readAsText(blob);
90
+ const [head = [], ...rest] = consistent ?? ragged ?? unsplit ?? [];
91
+ return { headers: head.map((h) => h ?? ""), values: rest };
98
92
  }
@@ -15,7 +15,6 @@ export type Datatype =
15
15
  export type Metadata = {
16
16
  [key: string]: string[][] | null;
17
17
  } | null;
18
- export type HeadersWithIDs = { value: string; id: string }[];
19
18
  export type DataframeValue = {
20
19
  data: Data;
21
20
  headers: Headers;
@@ -5,6 +5,18 @@ export const default_i18n: Record<string, string> = {
5
5
  "dataframe.add_column_left": "Add column left",
6
6
  "dataframe.add_column_right": "Add column right",
7
7
  "dataframe.delete_column": "Delete column",
8
+ "dataframe.drop_to_upload":
9
+ "Drop CSV or TSV files here to import data into dataframe",
10
+ "dataframe.import_empty_file": "The dropped file is empty.",
11
+ "dataframe.import_no_column_names": "The dropped file has no column names.",
12
+ "dataframe.import_ragged_row":
13
+ "Line {line} of the file has {fields} fields, the header has {columns}.",
14
+ "dataframe.import_static_columns":
15
+ "Cannot import into a table with read-only columns.",
16
+ "dataframe.import_fixed_columns":
17
+ "This table takes exactly {expected} columns, the file has {actual}.",
18
+ "dataframe.import_fixed_rows":
19
+ "This table takes exactly {expected} rows, the file has {actual}.",
8
20
  "dataframe.sort_asc": "Sort ascending",
9
21
  "dataframe.sort_desc": "Sort descending",
10
22
  "dataframe.sort_ascending": "Sort ascending",
@@ -2,8 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  make_cell_id,
4
4
  make_header_id,
5
- guess_delimiter,
6
- data_uri_to_blob
5
+ parse_table_file
7
6
  } from "../shared/utils/table_utils";
8
7
  import { cast_value_to_type } from "../shared/utils/utils";
9
8
 
@@ -69,41 +68,106 @@ describe("cast_value_to_type", () => {
69
68
  });
70
69
  });
71
70
 
72
- describe("guess_delimiter", () => {
73
- test("detects comma delimiter", () => {
74
- const csv = "a,b,c\n1,2,3\n4,5,6";
75
- expect(guess_delimiter(csv, [",", "\t"])).toContain(",");
71
+ describe("parse_table_file", () => {
72
+ test("detects a comma-separated file", async () => {
73
+ const file = new File(["a,b,c\n1,2,3\n4,5,6"], "data.csv");
74
+ expect(await parse_table_file(file)).toEqual({
75
+ headers: ["a", "b", "c"],
76
+ values: [
77
+ ["1", "2", "3"],
78
+ ["4", "5", "6"]
79
+ ]
80
+ });
76
81
  });
77
82
 
78
- test("detects tab delimiter", () => {
79
- const tsv = "a\tb\tc\n1\t2\t3\n4\t5\t6";
80
- expect(guess_delimiter(tsv, [",", "\t"])).toContain("\t");
83
+ test("detects a tab-separated file", async () => {
84
+ const file = new File(["a\tb\tc\n1\t2\t3\n4\t5\t6"], "data.tsv");
85
+ expect(await parse_table_file(file)).toEqual({
86
+ headers: ["a", "b", "c"],
87
+ values: [
88
+ ["1", "2", "3"],
89
+ ["4", "5", "6"]
90
+ ]
91
+ });
81
92
  });
82
93
 
83
- test("returns empty array when no consistent delimiter", () => {
84
- const text = "abc\ndef\nghi";
85
- expect(guess_delimiter(text, [",", "\t"])).toEqual([]);
94
+ test("picks the extension's delimiter when both look consistent", async () => {
95
+ const file = new File(["first,last\tage\nAlice,Smith\t30\n"], "data.tsv");
96
+ expect(await parse_table_file(file)).toEqual({
97
+ headers: ["first,last", "age"],
98
+ values: [["Alice,Smith", "30"]]
99
+ });
86
100
  });
87
101
 
88
- test("handles single-line input", () => {
89
- // single line with commas — cache set once, always matches
90
- const text = "a,b,c";
91
- expect(guess_delimiter(text, [",", "\t"])).toContain(",");
102
+ test("reads a mislabeled file by its content", async () => {
103
+ const file = new File(["name,age\nAlice,30\n"], "data.tsv");
104
+ expect(await parse_table_file(file)).toEqual({
105
+ headers: ["name", "age"],
106
+ values: [["Alice", "30"]]
107
+ });
108
+ });
109
+
110
+ // counting raw separators misses this: the quoted comma makes the comma counts
111
+ // disagree between the two lines, so nothing looks consistent
112
+ test("reads a mislabeled file whose field holds a quoted comma", async () => {
113
+ const file = new File(['name,note\nA,"x,y"\n'], "data.tsv");
114
+ expect(await parse_table_file(file)).toEqual({
115
+ headers: ["name", "note"],
116
+ values: [["A", "x,y"]]
117
+ });
118
+ });
119
+
120
+ // the extension says comma and the header does split on one, but only the
121
+ // tab splits every line, so the name loses to the file's own shape
122
+ test("reads a mislabeled file whose header holds the other separator", async () => {
123
+ const file = new File(["first,last\tage\nAlice\t30\n"], "data.csv");
124
+ expect(await parse_table_file(file)).toEqual({
125
+ headers: ["first,last", "age"],
126
+ values: [["Alice", "30"]]
127
+ });
92
128
  });
93
- });
94
129
 
95
- describe("data_uri_to_blob", () => {
96
- test("converts data URI to Blob with correct MIME type", () => {
97
- const data_uri = "data:text/plain;base64,SGVsbG8=";
98
- const blob = data_uri_to_blob(data_uri);
99
- expect(blob.type).toBe("text/plain");
130
+ // nothing splits this file consistently, so the separator that at least
131
+ // splits the header wins and the caller gets to report a useful mismatch
132
+ test("falls back to the separator that splits the header", async () => {
133
+ const file = new File(["name,age\nAlice,30,Engineer\n"], "data.tsv");
134
+ expect(await parse_table_file(file)).toEqual({
135
+ headers: ["name", "age"],
136
+ values: [["Alice", "30", "Engineer"]]
137
+ });
138
+ });
139
+
140
+ test("ignores blank lines", async () => {
141
+ const file = new File(["a,b\n1,2\n\n3,4\n\n"], "data.csv");
142
+ expect(await parse_table_file(file)).toEqual({
143
+ headers: ["a", "b"],
144
+ values: [
145
+ ["1", "2"],
146
+ ["3", "4"]
147
+ ]
148
+ });
149
+ });
150
+
151
+ test("ignores blank lines in a file with windows line endings", async () => {
152
+ const file = new File(["a,b\r\n1,2\r\n\r\n"], "data.csv");
153
+ expect(await parse_table_file(file)).toEqual({
154
+ headers: ["a", "b"],
155
+ values: [["1", "2"]]
156
+ });
157
+ });
158
+
159
+ // excel writes one in front of its csv export. Blob.text() decodes as UTF-8,
160
+ // which drops it, so this holds as long as the file is read that way
161
+ test("strips a byte order mark from the first header", async () => {
162
+ const file = new File(["name,age\nAlice,30\n"], "data.csv");
163
+ expect(await parse_table_file(file)).toEqual({
164
+ headers: ["name", "age"],
165
+ values: [["Alice", "30"]]
166
+ });
100
167
  });
101
168
 
102
- test("converts data URI to Blob with correct content", async () => {
103
- // "Hello" in base64 is "SGVsbG8="
104
- const data_uri = "data:text/plain;base64,SGVsbG8=";
105
- const blob = data_uri_to_blob(data_uri);
106
- const text = await blob.text();
107
- expect(text).toBe("Hello");
169
+ test("returns nothing for a file that holds only whitespace", async () => {
170
+ const file = new File([" \n"], "data.csv");
171
+ expect(await parse_table_file(file)).toEqual({ headers: [], values: [] });
108
172
  });
109
173
  });
package/types.ts CHANGED
@@ -7,6 +7,7 @@ export interface DataframeEvents {
7
7
  input: never;
8
8
  select: SelectData;
9
9
  edit: EditData;
10
+ error: string;
10
11
  clear_status: LoadingStatus;
11
12
  }
12
13