Add line numbering and collapse/expand logic to gr.JSON (#8929)

* add line numbers and collapse + expand logic

* add story test and style tweaks

* add changeset

* allow expanding via preview

* story tweaks

* remove mobile/desktop story tests

* remove unused thing

* add open param

* amend test

* * add cm-like theme colors
* prevent copy + pasting line numbers and toggle
* a11y tweaks

* update lines on rerender

* fix test

* fix test

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
Hannah 2024-07-30 23:58:03 +01:00 committed by GitHub
parent 716bf94ff5
commit 3539787ebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 305 additions and 127 deletions

View File

@ -0,0 +1,6 @@
---
"@gradio/json": minor
"gradio": minor
---
feat:Add line numbering and collapse/expand logic to gr.JSON

View File

@ -42,6 +42,7 @@ class JSON(Component):
elem_classes: list[str] | str | None = None,
render: bool = True,
key: int | str | None = None,
open: bool = False,
):
"""
Parameters:
@ -58,6 +59,7 @@ class JSON(Component):
elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
render: If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.
key: if assigned, will be used to assume identity across a re-render. Components that have the same key across a re-render will have their value preserved.
open: If True, all JSON nodes will be expanded when rendered. By default, node levels deeper than 3 are collapsed.
"""
super().__init__(
label=label,
@ -75,6 +77,8 @@ class JSON(Component):
value=value,
)
self.open = open
def preprocess(self, payload: dict | list | None) -> dict | list | None:
"""
Parameters:

View File

@ -19,5 +19,7 @@ test("can run an api request and display the data", async ({ page }) => {
await run_button.click();
const json = await page.getByTestId("json").first();
await expect(json).toContainText(`Covid: 0.25, Lung Cancer: 0.5`);
await expect(json).toHaveText(
` [ \"0\": { \"Covid\": 0.25 , \"Lung Cancer\": 0.5 } ] `
);
});

View File

@ -63,30 +63,9 @@ test("test outputs", async ({ page }) => {
);
const json = await page.locator("data-testid=json");
await expect(json).toContainText(`{
items: {
item: [
0: {
id: "0001",
type: null,
is_good: false,
ppu: 0.55,
batters: {
batter: expand 4 children
},
topping: [
0: {+2 items} ,
1: {+2 items} ,
2: {+2 items} ,
3: {+2 items} ,
4: {+2 items} ,
5: {+2 items} ,
6: {+2 items}
]
}
]
}
}`);
await expect(json).toContainText(
`{ "items": { "item": [ "0": { Object(6) } "id": "0001" , "type": null , "is_good": false , "ppu": 0.55 , "batters": { Object(1) } , "batter": [ Array(4) ] "0": { Object(2) } , "id": "1001" , "type": "Regular" } , "1": { Object(2) } , "id": "1002" , "type": "Chocolate" } , "2": { Object(2) } , "id": "1003" , "type": "Blueberry" } , "3": { Object(2) } "id": "1004" , "type": "Devil's Food" } ] } , "topping": [ Array(7) ] "0": { Object(2) } , "id": "5001" , "type": "None" } , "1": { Object(2) } , "id": "5002" , "type": "Glazed" } , "2": { Object(2) } , "id": "5005" , "type": "Sugar" } , "3": { Object(2) } , "id": "5007" , "type": "Powdered Sugar" } , "4": { Object(2) } , "id": "5006" , "type": "Chocolate with Sprinkles" } , "5": { Object(2) } , "id": "5003" , "type": "Chocolate" } , "6": { Object(2) } "id": "5004" , "type": "Maple" } ] } ] } } `
);
const image = page.locator("img").nth(0);
const image_data = await image.getAttribute("src");

View File

@ -26,6 +26,8 @@
change: never;
clear_status: LoadingStatus;
}>;
export let open = false;
export let theme_mode: "system" | "light" | "dark";
$: {
if (value !== old_value) {
@ -62,5 +64,5 @@
on:clear_status={() => gradio.dispatch("clear_status", loading_status)}
/>
<JSON {value} />
<JSON {value} {open} {theme_mode} />
</Block>

View File

@ -0,0 +1,43 @@
<script context="module">
import { Template, Story } from "@storybook/addon-svelte-csf";
import JSON from "./Index.svelte";
import { userEvent, within } from "@storybook/test";
const SAMPLE_JSON = {
key1: "value1",
key2: "value2",
key3: {
key4: "value4",
key5: "value5"
}
};
export const meta = {
title: "Components/JSON",
component: JSON
};
</script>
<Template let:args>
<JSON value={SAMPLE_JSON} {...args} />
</Template>
<Story name="Default JSON" args={{}} />
<Story
name="JSON Interactions"
args={{
value: SAMPLE_JSON,
interactive: true
}}
play={async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggles = within(canvasElement).getAllByRole("button");
await userEvent.click(toggles[1]);
await userEvent.click(toggles[1]);
await userEvent.click(toggles[2]);
await userEvent.click(canvas.getByText("Object(2)"));
}}
/>

View File

@ -7,6 +7,8 @@
import { Copy, Check } from "@gradio/icons";
export let value: any = {};
export let open = false;
export let theme_mode: "system" | "light" | "dark" = "system";
let copied = false;
let timer: NodeJS.Timeout;
@ -57,7 +59,7 @@
{/if}
</button>
<div class="json-holder">
<JSONNode {value} depth={0} />
<JSONNode {value} depth={0} is_root={true} {open} {theme_mode} />
</div>
{:else}
<div class="empty-wrapper">

View File

@ -1,127 +1,266 @@
<script lang="ts">
import { onMount, createEventDispatcher, tick, afterUpdate } from "svelte";
export let value: any;
export let depth: number;
export let collapsed = depth > 4;
export let depth = 0;
export let is_root = false;
export let is_last_item = true;
export let key: string | number | null = null;
export let open = false;
export let theme_mode: "system" | "light" | "dark" = "system";
const dispatch = createEventDispatcher();
let root_element: HTMLElement;
let collapsed = open ? false : depth >= 3;
let child_nodes: any[] = [];
function is_collapsible(val: any): boolean {
return val !== null && (typeof val === "object" || Array.isArray(val));
}
async function toggle_collapse(): Promise<void> {
collapsed = !collapsed;
await tick();
dispatch("toggle", { collapsed, depth });
}
function get_collapsed_preview(val: any): string {
if (Array.isArray(val)) return `Array(${val.length})`;
if (typeof val === "object" && val !== null)
return `Object(${Object.keys(val).length})`;
return String(val);
}
$: if (is_collapsible(value)) {
child_nodes = Object.entries(value);
} else {
child_nodes = [];
}
$: if (is_root && root_element) {
updateLineNumbers();
}
function updateLineNumbers(): void {
const lines = root_element.querySelectorAll(".line");
lines.forEach((line, index) => {
const line_number = line.querySelector(".line-number");
if (line_number) {
line_number.setAttribute("data-pseudo-content", (index + 1).toString());
line_number?.setAttribute(
"aria-roledescription",
`Line number ${index + 1}`
);
}
});
}
onMount(() => {
if (is_root) {
updateLineNumbers();
}
});
afterUpdate(() => {
if (is_root) {
updateLineNumbers();
}
});
</script>
<span class="spacer" class:mt-10={depth === 0} />
<div class="json-node">
{#if value instanceof Array}
{#if collapsed}
<button
on:click={() => {
collapsed = false;
}}
>
<span class="expand-array">expand {value.length} children</span>
</button>
{:else}
[
<div class="children">
{#each value as node, i}
<div>
{i}: <svelte:self value={node} depth={depth + 1} />
{#if i !== value.length - 1}
,
{/if}
</div>
{/each}
<div
class="json-node"
class:root={is_root}
class:dark-mode={theme_mode === "dark"}
bind:this={root_element}
on:toggle
style="--depth: {depth};"
>
<div class="line" class:collapsed>
<span class="line-number"></span>
<span class="content">
{#if is_collapsible(value)}
<button
data-pseudo-content={collapsed ? "▶" : "▼"}
aria-label={collapsed ? "Expand" : "Collapse"}
class="toggle"
on:click={toggle_collapse}
/>
{/if}
{#if key !== null}
<span class="key">"{key}"</span><span class="punctuation colon"
>:
</span>
{/if}
{#if is_collapsible(value)}
<span
class="punctuation bracket"
class:square-bracket={Array.isArray(value)}
>{Array.isArray(value) ? "[" : "{"}</span
>
{#if collapsed}
<button on:click={toggle_collapse} class="preview">
{get_collapsed_preview(value)}
</button>
<span class="punctuation bracket"
>{Array.isArray(value) ? "]" : "}"}</span
>
{/if}
{:else if typeof value === "string"}
<span class="value string">"{value}"</span>
{:else if typeof value === "number"}
<span class="value number">{value}</span>
{:else if typeof value === "boolean"}
<span class="value bool">{value.toString()}</span>
{:else if value === null}
<span class="value null">null</span>
{:else}
<span>{value}</span>
{/if}
{#if !is_last_item && (!is_collapsible(value) || collapsed)}
<span class="punctuation">,</span>
{/if}
</span>
</div>
{#if is_collapsible(value)}
<div class="children" class:hidden={collapsed}>
{#each child_nodes as [subKey, subVal], i}
<svelte:self
value={subVal}
depth={depth + 1}
is_last_item={i === child_nodes.length - 1}
key={subKey}
{open}
{theme_mode}
on:toggle
/>
{/each}
<div class="line">
<span class="line-number"></span>
<span class="content">
<span
class="punctuation bracket"
class:square-bracket={Array.isArray(value)}
>{Array.isArray(value) ? "]" : "}"}</span
>
{#if !is_last_item}<span class="punctuation">,</span>{/if}
</span>
</div>
]
{/if}
{:else if value instanceof Object}
{#if collapsed}
<button
on:click={() => {
collapsed = false;
}}
>
&#123;+{Object.keys(value).length} items&#125;
</button>
{:else}
&#123;
<div class="children">
{#each Object.entries(value) as node, i}
<div>
{node[0]}: <svelte:self
value={node[1]}
depth={depth + 1}
key={i}
/><!--
-->{#if i !== Object.keys(value).length - 1}<!--
-->,
{/if}
</div>
{/each}
</div>
&#125;
{/if}
{:else if value === null}
<div class="json-item null">null</div>
{:else if typeof value === "string"}
<div class="json-item string">
"{value}"
</div>
{:else if typeof value === "boolean"}
<div class="json-item bool">
{value.toLocaleString()}
</div>
{:else if typeof value === "number"}
<div class="json-item number">
{value}
</div>
{:else}
<div class="json-item">
{value}
</div>
{/if}
</div>
<style>
.spacer {
display: inline-block;
width: 0;
height: 0;
}
.json-node {
display: inline;
color: var(--body-text-color);
line-height: var(--line-sm);
font-family: var(--font-mono);
}
--text-color: #d18770;
--key-color: var(--text-color);
--string-color: #ce9178;
--number-color: #719fad;
.expand-array {
border: 1px solid var(--border-color-primary);
border-radius: var(--radius-sm);
background: var(--background-fill-secondary);
padding: 0 var(--size-1);
color: var(--body-text-color);
--bracket-color: #5d8585;
--square-bracket-color: #be6069;
--punctuation-color: #8fbcbb;
--line-number-color: #6a737d;
--separator-color: var(--line-number-color);
}
.expand-array:hover {
background: var(--background-fill-primary);
.json-node.dark-mode {
--bracket-color: #7eb4b3;
--number-color: #638d9a;
}
.json-node.root {
position: relative;
padding-left: var(--size-14);
}
.json-node.root::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: var(--size-11);
width: 1px;
background-color: var(--separator-color);
}
.line {
display: flex;
align-items: flex-start;
padding: 0;
margin: 0;
line-height: var(--line-md);
}
.line-number {
position: absolute;
left: 0;
width: calc(var(--size-10) - 4px);
text-align: right;
color: var(--line-number-color);
user-select: none;
text-overflow: ellipsis;
padding-right: 4px;
}
.content {
flex: 1;
display: flex;
align-items: center;
padding-left: calc(var(--depth) * var(--size-2));
flex-wrap: wrap;
}
.children {
padding-left: var(--size-4);
}
.json-item {
display: inline;
.children.hidden {
display: none;
}
.null {
color: var(--body-text-color-subdued);
.key {
color: var(--key-color);
}
.string {
color: var(--color-green-500);
color: var(--string-color);
}
.number {
color: var(--color-blue-500);
color: var(--number-color);
}
.bool {
color: var(--color-red-500);
color: var(--text-color);
}
.null {
color: var(--text-color);
}
.value {
margin-left: var(--spacing-md);
}
.punctuation {
color: var(--punctuation-color);
}
.bracket {
margin-left: var(--spacing-sm);
color: var(--bracket-color);
}
.square-bracket {
margin-left: var(--spacing-sm);
color: var(--square-bracket-color);
}
.toggle,
.preview {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
margin: 0;
}
.toggle {
user-select: none;
margin-right: var(--spacing-md);
}
.preview {
margin: 0 var(--spacing-sm) 0 var(--spacing-lg);
}
.preview:hover {
text-decoration: underline;
}
:global([data-pseudo-content])::before {
content: attr(data-pseudo-content);
}
</style>

View File

@ -26,6 +26,7 @@ class TestJSON:
"name": "json",
"proxy_url": None,
"_selectable": False,
"open": False,
"key": None,
}
js_component = gr.Json(value={"a": 1, "b": 2})