mirror of
https://github.com/gradio-app/gradio.git
synced 2025-01-24 10:54:04 +08:00
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:
parent
716bf94ff5
commit
3539787ebb
6
.changeset/sharp-monkeys-throw.md
Normal file
6
.changeset/sharp-monkeys-throw.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gradio/json": minor
|
||||
"gradio": minor
|
||||
---
|
||||
|
||||
feat:Add line numbering and collapse/expand logic to gr.JSON
|
@ -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:
|
||||
|
@ -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 } ] `
|
||||
);
|
||||
});
|
||||
|
@ -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");
|
||||
|
@ -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>
|
||||
|
43
js/json/JSON.stories.svelte
Normal file
43
js/json/JSON.stories.svelte
Normal 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)"));
|
||||
}}
|
||||
/>
|
@ -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">
|
||||
|
@ -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;
|
||||
}}
|
||||
>
|
||||
{+{Object.keys(value).length} items}
|
||||
</button>
|
||||
{:else}
|
||||
{
|
||||
<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>
|
||||
}
|
||||
{/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>
|
||||
|
@ -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})
|
||||
|
Loading…
Reference in New Issue
Block a user