Add static_columns param for interactive dataframes (#10734)

* allow showing image sin dataframe with image component

* add scroll to top button

* add changeset

* fix truncated text issue + prevent truncating non-text data

* refactor with state mgmnt

* add changeset

* formatting

* tweaks

* tweak

* add e2e tests

* notebook

* more component extraction, move css to components

* type fixes

* test fixes

* fix test

* notebook

* add changeset

* fix tests

* fix test

* fix test

* remove misc.css file

* reset sort

* fix z-index over progress bar

* css tweak

* fix search and add search to e2e test

* fix keyboard reactivity issue

* add cell selection e2e tests

* z-index fixes

* ensure unique context ids

* fix row number bug

* frozen -> pinned

* pinned col border tweak

* pinned col fix when show_row_numbers is true

* header tweak

* fix pinned columns clash with column_widths

* add row test

* add static_cols param and test

* add changeset

* tweak

* fix tests

* test tweaks

* add row story

* tweaks

* fix test

* tweak

* redesign selection buttons

* fix test

* fix test

* fix test

* fix test

* edit demo with static cols

* change to list[int]

* cursor tweak

* fix test

* nb

* set col count to fixed if static_cols are set

* lint

* tweak demo

* fix col count logic

* add padlock icon

* fix py test

* lint

* fix test

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
Hannah 2025-03-10 19:10:36 +00:00 committed by GitHub
parent 0ce7bfe1bd
commit c44b8f47b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 130 additions and 7 deletions

View File

@ -0,0 +1,6 @@
---
"@gradio/dataframe": minor
"gradio": minor
---
feat:Add `static_columns` param for interactive dataframes

File diff suppressed because one or more lines are too long

View File

@ -38,6 +38,7 @@ with gr.Blocks() as demo:
show_search="filter",
show_copy_button=True,
show_row_numbers=True,
static_columns=[4]
)
with gr.Column(scale=1):

View File

@ -102,6 +102,7 @@ class Dataframe(Component):
max_chars: int | None = None,
show_search: Literal["none", "search", "filter"] = "none",
pinned_columns: int | None = None,
static_columns: list[int] | None = None,
):
"""
Parameters:
@ -135,12 +136,19 @@ class Dataframe(Component):
max_chars: Maximum number of characters to display in each cell before truncating (single-clicking a cell value will still reveal the full content). If None, no truncation is applied.
show_search: Show a search input in the toolbar. If "search", a search input is shown. If "filter", a search input and filter buttons are shown. If "none", no search input is shown.
pinned_columns: If provided, will pin the specified number of columns from the left.
static_columns: List of column indices (int) that should not be editable. Only applies when interactive=True. When specified, col_count is automatically set to "fixed" and columns cannot be inserted or deleted.
"""
self.wrap = wrap
self.row_count = self.__process_counts(row_count)
self.static_columns = static_columns or []
self.col_count = self.__process_counts(
col_count, len(headers) if headers else 3
)
if self.static_columns and isinstance(self.col_count, tuple):
self.col_count = (self.col_count[0], "fixed")
self.__validate_headers(headers, self.col_count[0])
self.headers = (

View File

@ -606,6 +606,7 @@ class Numpy(components.Dataframe):
column_widths: list[str | int] | None = None,
show_row_numbers: bool = False,
show_search: Literal["none", "search", "filter"] = "none",
static_columns: list[int] | None = None,
pinned_columns: int | None = None,
show_fullscreen_button: bool = False,
max_chars: int | None = None,
@ -641,6 +642,7 @@ class Numpy(components.Dataframe):
show_fullscreen_button=show_fullscreen_button,
max_chars=max_chars,
show_copy_button=show_copy_button,
static_columns=static_columns,
)
@ -689,6 +691,7 @@ class Matrix(components.Dataframe):
show_fullscreen_button: bool = False,
max_chars: int | None = None,
show_copy_button: bool = False,
static_columns: list[int] | None = None,
):
super().__init__(
value=value,
@ -720,6 +723,7 @@ class Matrix(components.Dataframe):
show_fullscreen_button=show_fullscreen_button,
max_chars=max_chars,
show_copy_button=show_copy_button,
static_columns=static_columns,
)
@ -768,6 +772,7 @@ class List(components.Dataframe):
show_fullscreen_button: bool = False,
max_chars: int | None = None,
show_copy_button: bool = False,
static_columns: list[int] | None = None,
):
super().__init__(
value=value,
@ -795,6 +800,7 @@ class List(components.Dataframe):
min_width=min_width,
show_row_numbers=show_row_numbers,
show_search=show_search,
static_columns=static_columns,
pinned_columns=pinned_columns,
show_fullscreen_button=show_fullscreen_button,
max_chars=max_chars,

View File

@ -57,6 +57,7 @@
export let show_row_numbers = false;
export let show_search: "none" | "search" | "filter" = "none";
export let pinned_columns = 0;
export let static_columns: (string | number)[] = [];
$: _headers = [...(value.headers || headers)];
$: display_value = value?.metadata?.display_value
@ -119,5 +120,6 @@
{show_search}
{pinned_columns}
components={{ image: Image }}
{static_columns}
/>
</Block>

View File

@ -24,6 +24,7 @@
export let clear_on_focus = false;
export let line_breaks = true;
export let editable = true;
export let is_static = false;
export let root: string;
export let max_chars: number | null = null;
export let components: Record<string, any> = {};
@ -102,8 +103,11 @@
{#if edit}
<input
disabled={is_static}
aria-disabled={is_static}
class:static={is_static}
role="textbox"
aria-label="Edit cell"
aria-label={is_static ? "Cell is read-only" : "Edit cell"}
bind:this={el}
bind:value={_value}
class:header
@ -125,6 +129,7 @@
class:edit
class:expanded={is_expanded}
class:multiline={header}
class:static={!editable}
on:focus|preventDefault
style={styling}
data-editable={editable}
@ -232,4 +237,8 @@
width: auto;
object-fit: contain;
}
input:disabled {
cursor: not-allowed;
}
</style>

View File

@ -75,6 +75,7 @@
export let max_chars: number | undefined = undefined;
export let show_search: "none" | "search" | "filter" = "none";
export let pinned_columns = 0;
export let static_columns: (string | number)[] = [];
$: actual_pinned_columns =
pinned_columns && data?.[0]?.length
@ -699,8 +700,10 @@
{max_chars}
{root}
{editable}
is_static={static_columns.includes(i)}
{i18n}
bind:el={els[id].input}
{col_count}
/>
{/each}
</tr>
@ -804,8 +807,10 @@
{max_chars}
{root}
{editable}
is_static={static_columns.includes(i)}
{i18n}
bind:el={els[id].input}
{col_count}
/>
{/each}
</tr>
@ -836,6 +841,7 @@
{max_chars}
{root}
{editable}
is_static={static_columns.includes(j)}
{i18n}
{components}
{handle_select_column}

View File

@ -50,6 +50,7 @@
export let max_chars: number | undefined;
export let root: string;
export let editable: boolean;
export let is_static = false;
export let i18n: I18nFormatter;
export let components: Record<string, any> = {};
export let el: {
@ -130,6 +131,7 @@
{latex_delimiters}
{line_breaks}
{editable}
{is_static}
edit={editing && editing[0] === index && editing[1] === j}
{datatype}
on:blur={() => {

View File

@ -4,6 +4,7 @@
import CellMenuButton from "./CellMenuButton.svelte";
import type { I18nFormatter } from "js/core/src/gradio_helper";
import type { SortDirection } from "./context/table_context";
import Padlock from "./icons/Padlock.svelte";
export let value: string;
export let i: number;
@ -33,6 +34,10 @@
export let editable: boolean;
export let i18n: I18nFormatter;
export let el: HTMLInputElement | null;
export let is_static: boolean;
export let col_count: [number, "fixed" | "dynamic"];
$: can_add_columns = col_count && col_count[1] === "dynamic";
function get_header_position(col_index: number): string {
if (col_index >= actual_pinned_columns) {
@ -100,11 +105,15 @@
header
{root}
{editable}
{is_static}
{i18n}
/>
</button>
{#if is_static}
<Padlock />
{/if}
</div>
{#if editable}
{#if editable && can_add_columns}
<CellMenuButton on_click={(event) => toggle_header_menu(event, i)} />
{/if}
</div>

View File

@ -1,5 +1,6 @@
import { getContext, setContext } from "svelte";
import { writable, type Writable, get } from "svelte/store";
import { writable, get } from "svelte/store";
import type { Writable } from "svelte/store";
import { sort_table_data } from "../utils/table_utils";
import { dequal } from "dequal/lite";
import type { DataframeValue } from "../utils";

View File

@ -0,0 +1,24 @@
<div class="wrapper" aria-label="Static column">
<svg
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<style>
.wrapper {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -134,7 +134,11 @@ async function handle_enter_key(
const cell_id = ctx.data[i][j].id;
const input_el = ctx.els[cell_id].input;
if (input_el) {
const old_value = ctx.data[i][j].value;
ctx.data[i][j].value = input_el.value;
if (old_value !== input_el.value) {
ctx.dispatch("input");
}
}
ctx.df_actions.set_editing(false);
await tick();

View File

@ -239,7 +239,7 @@ test("Dataframe shift+click selection works", async ({ page }) => {
navigator.clipboard.readText()
);
expect(clipboard_value).toBe("0,6\n0,6");
expect(clipboard_value).toBe("0,6\n0,0");
});
test("Dataframe cmd + click selection works", async ({ page }) => {
@ -268,10 +268,32 @@ test("Dataframe cmd + click selection works", async ({ page }) => {
navigator.clipboard.readText()
);
expect(clipboard_value).toBe("6\n8");
expect(clipboard_value).toBe("6\n0");
});
test.only("Dataframe search functionality works correctly after data update", async ({
test("Static columns cannot be edited", async ({ page }) => {
const static_df = page.locator("#dataframe");
const static_column_cell = get_cell(static_df, 0, 4);
await static_column_cell.click();
await page.waitForTimeout(100);
const is_disabled =
(await static_column_cell.locator("input").getAttribute("disabled")) !==
null;
expect(is_disabled).toBe(true);
const editable_cell = get_cell(static_df, 2, 3);
await editable_cell.click();
await page.waitForTimeout(100);
const is_not_disabled = await editable_cell
.locator("input")
.getAttribute("aria-disabled");
expect(is_not_disabled).toEqual("false");
});
test("Dataframe search functionality works correctly after data update", async ({
page
}) => {
const df = page.locator("#non-interactive-dataframe");

View File

@ -49,6 +49,7 @@ class TestDataframe:
"elem_classes": [],
"show_row_numbers": False,
"show_search": "none",
"static_columns": [],
"pinned_columns": None,
"wrap": False,
"proxy_url": None,
@ -93,6 +94,7 @@ class TestDataframe:
"show_label": True,
"show_row_numbers": False,
"show_search": "none",
"static_columns": [],
"pinned_columns": None,
"scale": None,
"min_width": 160,
@ -402,3 +404,24 @@ class TestDataframe:
styled_df = test_df.style
styled_df.hide(axis=1, subset=["col2"])
assert df.get_cell_data(styled_df) == [[1], [3]]
def test_static_columns(self):
# when static_columns is specified, col_count should be fixed
dataframe = gr.Dataframe(static_columns=[0, 1])
assert dataframe.col_count[1] == "fixed"
# when static_columns is specified with dynamic col_count, it should be converted to fixed
dataframe = gr.Dataframe(col_count=(4, "dynamic"), static_columns=[0, 1])
assert dataframe.col_count[1] == "fixed"
# when static_columns is empty, col_count should remain as specified
dataframe = gr.Dataframe(col_count=(4, "dynamic"), static_columns=[])
assert dataframe.col_count[1] == "dynamic"
# when static_columns is None, col_count should remain as specified
dataframe = gr.Dataframe(col_count=(4, "dynamic"), static_columns=None)
assert dataframe.col_count[1] == "dynamic"
# when static_columns is not specified at all, col_count should remain as specified
dataframe = gr.Dataframe(col_count=(4, "dynamic"))
assert dataframe.col_count[1] == "dynamic"