diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md index 4582dd84..1d848ab9 100644 --- a/chartlets.js/CHANGES.md +++ b/chartlets.js/CHANGES.md @@ -15,6 +15,9 @@ * Callbacks will now only be invoked when there’s an actual change in state, reducing unnecessary processing and improving performance. (#112) +* New (MUI) components + - `Skeleton` (currently supported for Vega Charts and Tables) + ## Version 0.1.4 (from 2025/03/06) * In `chartlets.js` we no longer emit warnings and errors in common diff --git a/chartlets.js/packages/lib/src/actions/handleComponentChange.ts b/chartlets.js/packages/lib/src/actions/handleComponentChange.ts index ea8528e1..183743e1 100644 --- a/chartlets.js/packages/lib/src/actions/handleComponentChange.ts +++ b/chartlets.js/packages/lib/src/actions/handleComponentChange.ts @@ -70,6 +70,10 @@ function getCallbackRequests( equalObjPaths(input.property, changeEvent.property), ); if (inputIndex >= 0) { + // Collect output IDs for updating their respective loading states + const outputs = contribution.callbacks?.[callbackIndex]["outputs"]; + const outputIds: string[] = + outputs?.map((output) => output.id as string) ?? []; // Collect triggered callback callbackRequests.push({ contribPoint, @@ -77,6 +81,7 @@ function getCallbackRequests( callbackIndex, inputIndex, inputValues: getInputValues(inputs, contribution, hostStore), + outputIds: outputIds, }); } } diff --git a/chartlets.js/packages/lib/src/actions/handleHostStoreChange.ts b/chartlets.js/packages/lib/src/actions/handleHostStoreChange.ts index 30d00c1e..eae910b6 100644 --- a/chartlets.js/packages/lib/src/actions/handleHostStoreChange.ts +++ b/chartlets.js/packages/lib/src/actions/handleHostStoreChange.ts @@ -114,7 +114,11 @@ const getCallbackRequest = ( [callbackId]: inputValues, }, }); - return { ...propertyRef, inputValues }; + // Collect output IDs for updating their respective loading states + const outputs = contribution.callbacks?.[callbackIndex]["outputs"]; + const outputIds: string[] = + outputs?.map((output) => output.id as string) ?? []; + return { ...propertyRef, inputValues, outputIds }; }; /** diff --git a/chartlets.js/packages/lib/src/actions/handleHostStoreChanges.test.tsx b/chartlets.js/packages/lib/src/actions/handleHostStoreChanges.test.tsx index 7ad89d90..8325bc2e 100644 --- a/chartlets.js/packages/lib/src/actions/handleHostStoreChanges.test.tsx +++ b/chartlets.js/packages/lib/src/actions/handleHostStoreChanges.test.tsx @@ -190,6 +190,7 @@ describe("handleHostStoreChange", () => { inputIndex: 0, inputValues: ["CHL"], property: "variableName", + outputIds: ["select"], }, ]); @@ -281,6 +282,7 @@ describe("handleHostStoreChange", () => { expect(result[0]).toEqual({ ...propertyRefs[0], inputValues: ["CHL"], + outputIds: [], }); // second call -> memoized -> should not create callback request @@ -293,6 +295,7 @@ describe("handleHostStoreChange", () => { expect(result[0]).toEqual({ ...propertyRefs[0], inputValues: ["TMP"], + outputIds: [], }); // fourth call -> memoized -> should not invoke callback diff --git a/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts index 1fe177ee..e1c8187a 100644 --- a/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts +++ b/chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts @@ -4,7 +4,7 @@ import { fetchCallback } from "@/api/fetchCallback"; import { applyStateChangeRequests } from "@/actions/helpers/applyStateChangeRequests"; export function invokeCallbacks(callbackRequests: CallbackRequest[]) { - const { configuration } = store.getState(); + const { configuration, loadingState } = store.getState(); const shouldLog = configuration.logging?.enabled; const invocationId = getInvocationId(); if (shouldLog) { @@ -13,6 +13,20 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { callbackRequests, ); } + + // Set the respective callback's outputs loading state to true before + // sending the request + callbackRequests.forEach((callbackRequest) => { + const outputIds = callbackRequest.outputIds; + outputIds.forEach((outputId) => { + store.setState({ + loadingState: { + ...loadingState, + [outputId]: true, + }, + }); + }); + }); fetchCallback(callbackRequests, configuration.api).then( (changeRequestsResult) => { if (changeRequestsResult.data) { @@ -23,6 +37,19 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { ); } applyStateChangeRequests(changeRequestsResult.data); + // Set the loading state of each output ID of the callback's that + // were invoked to false as the fetch was successful. + callbackRequests.forEach((callbackRequest) => { + const outputIds = callbackRequest.outputIds; + outputIds.forEach((outputId) => { + store.setState({ + loadingState: { + ...loadingState, + [outputId]: false, + }, + }); + }); + }); } else { console.error( "callback failed:", @@ -30,6 +57,19 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { "for call requests:", callbackRequests, ); + // Set the loading state of each output ID of the callback's that + // were invoked to `failed` as the fetch was unsuccessful. + callbackRequests.forEach((callbackRequest) => { + const outputIds = callbackRequest.outputIds; + outputIds.forEach((outputId) => { + store.setState({ + loadingState: { + ...loadingState, + [outputId]: "failed", + }, + }); + }); + }); } }, ); diff --git a/chartlets.js/packages/lib/src/hooks.ts b/chartlets.js/packages/lib/src/hooks.ts index 43882e14..4f2b8f39 100644 --- a/chartlets.js/packages/lib/src/hooks.ts +++ b/chartlets.js/packages/lib/src/hooks.ts @@ -20,6 +20,8 @@ const selectContributionsRecord = (state: StoreState) => const selectThemeMode = (state: StoreState) => state.themeMode; +const selectLoadingState = (state: StoreState) => state.loadingState; + const useStore = store; export const useConfiguration = () => useStore(selectConfiguration); @@ -27,6 +29,7 @@ export const useExtensions = () => useStore(selectExtensions); export const useContributionsResult = () => useStore(selectContributionsResult); export const useContributionsRecord = () => useStore(selectContributionsRecord); export const useThemeMode = () => useStore(selectThemeMode); +export const useLoadingState = () => useStore(selectLoadingState); /** * A hook that retrieves the contributions for the given contribution diff --git a/chartlets.js/packages/lib/src/plugins/mui/Skeleton.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Skeleton.test.tsx new file mode 100644 index 00000000..b6c2b08d --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Skeleton.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Skeleton } from "@/plugins/mui/Skeleton"; + +describe("Skeleton", () => { + it("should render the MUI Skeleton when loading is true", () => { + render(); + const muiSkeleton = screen.getByTestId("skeleton-test-id"); + expect(muiSkeleton).toBeInTheDocument(); + expect(muiSkeleton).toHaveClass("MuiSkeleton-root"); + }); + + it("should not render the MUI Skeleton and render children when loading is false", () => { + render( + +
Test Content
+
, + ); + const muiSkeleton = screen.queryByTestId("skeleton-test-id"); + expect(muiSkeleton).not.toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render with the specified variant", () => { + render(); + const muiSkeleton = screen.getByTestId("skeleton-test-id"); + expect(muiSkeleton).toHaveClass("MuiSkeleton-circular"); + }); + + it("should render with specified width and height", () => { + render(); + const muiSkeleton = screen.getByTestId("skeleton-test-id"); + expect(muiSkeleton).toHaveStyle("width: 100px"); + expect(muiSkeleton).toHaveStyle("height: 50px"); + }); + + it("should render with specified animation", () => { + render(); + const muiSkeleton = screen.getByTestId("skeleton-test-id"); + expect(muiSkeleton).toHaveClass("MuiSkeleton-wave"); + }); +}); diff --git a/chartlets.js/packages/lib/src/plugins/mui/Skeleton.tsx b/chartlets.js/packages/lib/src/plugins/mui/Skeleton.tsx new file mode 100644 index 00000000..b80903c1 --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Skeleton.tsx @@ -0,0 +1,61 @@ +import { + Skeleton as MuiSkeleton, + type SkeletonProps as MuiSkeletonProps, +} from "@mui/material"; + +import type { ComponentState } from "@/index"; +import type { ReactElement } from "react"; + +interface SkeletonState extends Omit { + variant?: MuiSkeletonProps["variant"]; + width?: MuiSkeletonProps["width"]; + height?: MuiSkeletonProps["height"]; + animation?: MuiSkeletonProps["animation"]; + opacity?: number; + isLoading: boolean; + children?: ReactElement; +} + +export interface SkeletonProps extends SkeletonState {} + +export const Skeleton = ({ + id, + style, + children, + isLoading, + ...props +}: SkeletonProps) => { + // Set default values if not available + const opacity: number = props.opacity ?? 0.7; + props.width = props.width ?? "100%"; + props.height = props.height ?? "100%"; + + return ( +
+ {children} + {isLoading && ( +
+ +
+ )} +
+ ); +}; diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx index e196d301..231ad37b 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx @@ -73,4 +73,30 @@ describe("Table", () => { }, }); }); + + it("should not render the Table component when no id provided", () => { + render( {}} />); + + const table = screen.queryByRole("table"); + expect(table).toBeNull(); + }); + + it( + "should render the Table component with skeleton when skeletonProps are" + + " provided", + () => { + render( +
{}} + skeletonProps={{ variant: "rectangular" }} + />, + ); + + const table = screen.queryByRole("table"); + expect(table).toBeNull(); + }, + ); }); diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx index c5dbd78e..d52bddf4 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/Table.tsx +++ b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx @@ -9,6 +9,9 @@ import { } from "@mui/material"; import type { ComponentProps, ComponentState } from "@/index"; import type { SxProps } from "@mui/system"; +import { Skeleton } from "@/plugins/mui/Skeleton"; +import type { ReactElement } from "react"; +import { useLoadingState } from "@/hooks"; interface TableCellProps { id: string | number; @@ -38,8 +41,18 @@ export const Table = ({ columns, hover, stickyHeader, + skeletonProps, onChange, }: TableProps) => { + const loadingState = useLoadingState(); + if (!id) { + return; + } + const isLoading = loadingState[id]; + if (isLoading == "failed") { + return
An error occurred while loading the data.
; + } + if (!columns || columns.length === 0) { return
No columns provided.
; } @@ -69,7 +82,7 @@ export const Table = ({ } }; - return ( + const table: ReactElement | null = ( @@ -107,4 +120,20 @@ export const Table = ({ ); + + const isSkeletonRequired = skeletonProps !== undefined; + if (!isSkeletonRequired) { + return table; + } + const skeletonId = id + "-skeleton"; + return ( + + {table} + + ); }; diff --git a/chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx b/chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx index 99e29ec3..44d76cfc 100644 --- a/chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx +++ b/chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { render } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; // import { render, screen, fireEvent } from "@testing-library/react"; import type { TopLevelSpec } from "vega-lite"; import { createChangeHandler } from "@/plugins/mui/common.test"; @@ -21,7 +21,7 @@ describe("VegaChart", () => { expect(document.querySelector("#vc")).not.toBeUndefined(); }); - it("should render if chart is given", () => { + it("should render if chart is given", async () => { const { recordedEvents, onChange } = createChangeHandler(); render( { // expect(document.body).toEqual({}); expect(recordedEvents.length).toBe(0); + const test_chart = screen.queryByTestId("vega-test-id"); + expect(test_chart).toBeDefined(); + const canvas = await waitFor(() => screen.getByRole("graphics-document")); + expect(canvas).toBeInTheDocument(); + // TODO: all of the following doesn't work! // expect(document.querySelector("canvas")).toEqual({}); // expect(screen.getByRole("canvas")).not.toBeUndefined(); // fireEvent.click(screen.getByRole("canvas")); // expect(recordedEvents.length).toBe(1); }); + + it("should not render if id is not given", () => { + const { recordedEvents, onChange } = createChangeHandler(); + render(); + expect(recordedEvents.length).toBe(0); + const test_chart = screen.queryByTestId("vega-test-id"); + expect(test_chart).toBeNull(); + }); }); const chart: TopLevelSpec = { diff --git a/chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx b/chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx index d00e7b87..cd815850 100644 --- a/chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx +++ b/chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx @@ -5,6 +5,9 @@ import type { ComponentProps, ComponentState } from "@/index"; import { useSignalListeners } from "./hooks/useSignalListeners"; import { useVegaTheme, type VegaTheme } from "./hooks/useVegaTheme"; import { useResizeObserver } from "./hooks/useResizeObserver"; +import { Skeleton } from "@/plugins/mui/Skeleton"; +import { useLoadingState } from "@/hooks"; +import type { ReactElement } from "react"; interface VegaChartState extends ComponentState { theme?: VegaTheme | "default" | "system"; @@ -20,26 +23,55 @@ export function VegaChart({ id, style, theme, - chart, + chart: initialChart, + skeletonProps, onChange, }: VegaChartProps) { - const signalListeners = useSignalListeners(chart, type, id, onChange); + const signalListeners = useSignalListeners(initialChart, type, id, onChange); const vegaTheme = useVegaTheme(theme); const { containerSizeKey, containerCallbackRef } = useResizeObserver(); - if (chart) { - return ( -
- -
- ); - } else { - return
; + + const loadingState = useLoadingState(); + if (!id) { + return; + } + const isLoading = loadingState[id]; + + if (isLoading == "failed") { + return
An error occurred while loading the data.
; + } + + const chart: ReactElement | null = initialChart ? ( +
+ +
+ ) : ( +
+ ); + const isSkeletonRequired = skeletonProps !== undefined; + if (!isSkeletonRequired) { + return chart; } + const skeletonId = id + "-skeleton"; + return ( + + {chart} + + ); } diff --git a/chartlets.js/packages/lib/src/store.ts b/chartlets.js/packages/lib/src/store.ts index 683ac9d3..9fe699c3 100644 --- a/chartlets.js/packages/lib/src/store.ts +++ b/chartlets.js/packages/lib/src/store.ts @@ -8,4 +8,5 @@ export const store = create(() => ({ contributionsResult: {}, contributionsRecord: {}, lastCallbackInputValues: {}, + loadingState: {}, })); diff --git a/chartlets.js/packages/lib/src/types/model/callback.ts b/chartlets.js/packages/lib/src/types/model/callback.ts index 12117833..789b2be4 100644 --- a/chartlets.js/packages/lib/src/types/model/callback.ts +++ b/chartlets.js/packages/lib/src/types/model/callback.ts @@ -73,8 +73,8 @@ export interface InputRef { /** * A `CallbackRequest` is a request to invoke a server-side callback. - * The result from invoking server-side callbacks is a list of `StateChangeRequest` - * instances. + * The result from invoking server-side callbacks is a list of + * `StateChangeRequest` instances. */ export interface CallbackRequest extends ContribRef, CallbackRef, InputRef { /** @@ -83,11 +83,17 @@ export interface CallbackRequest extends ContribRef, CallbackRef, InputRef { * as the callback's inputs. */ inputValues: unknown[]; + /** + * The output IDs of the callback that will be used to update the + * loading state of its respective output. + */ + outputIds: string[]; } /** * A `StateChangeRequest` is a request to change the application state. - * Instances of this interface are returned from invoking a server-side callback. + * Instances of this interface are returned from invoking a server-side + * callback. */ export interface StateChangeRequest extends ContribRef { /** diff --git a/chartlets.js/packages/lib/src/types/state/component.ts b/chartlets.js/packages/lib/src/types/state/component.ts index 5bb5033c..04f00e9d 100644 --- a/chartlets.js/packages/lib/src/types/state/component.ts +++ b/chartlets.js/packages/lib/src/types/state/component.ts @@ -1,6 +1,7 @@ import { type CSSProperties } from "react"; import { isObject } from "@/utils/isObject"; import { isString } from "@/utils/isString"; +import type { SkeletonProps } from "@/plugins/mui/Skeleton"; export type ComponentNode = | ComponentState @@ -24,6 +25,7 @@ export interface ComponentState { label?: string; color?: string; tooltip?: string; + skeletonProps?: Omit; } export interface ContainerState extends ComponentState { diff --git a/chartlets.js/packages/lib/src/types/state/store.ts b/chartlets.js/packages/lib/src/types/state/store.ts index cb769bde..d5131715 100644 --- a/chartlets.js/packages/lib/src/types/state/store.ts +++ b/chartlets.js/packages/lib/src/types/state/store.ts @@ -49,4 +49,9 @@ export interface StoreState { * there are no state changes */ lastCallbackInputValues: Record; + /** + * Store the loading state of each output ID of the callback that is invoked. + * If the request fails, the state is set to `failed` + */ + loadingState: Record; } diff --git a/chartlets.py/CHANGES.md b/chartlets.py/CHANGES.md index 838aa662..121bf526 100644 --- a/chartlets.py/CHANGES.md +++ b/chartlets.py/CHANGES.md @@ -2,6 +2,8 @@ * Add `multiple` property for `Select` component to enable the of multiple elements. +* New (MUI) components + - `Skeleton` (currently supported for Vega Charts and Tables) * Add support for `Python 3.13` diff --git a/chartlets.py/chartlets/component.py b/chartlets.py/chartlets/component.py index 08dbf1d1..38e8da06 100644 --- a/chartlets.py/chartlets/component.py +++ b/chartlets.py/chartlets/component.py @@ -37,6 +37,10 @@ class Component(ABC): children: list[Union["Component", str, None]] | None = None """Children used by many specific components. Optional.""" + skeletonProps: dict[str, Any] | None = None + """Add the skeleton props from the Skeleton MUI component to render a + skeleton during long loading times.""" + @property def type(self): return self.__class__.__name__ diff --git a/chartlets.py/chartlets/components/__init__.py b/chartlets.py/chartlets/components/__init__.py index 80df8b1c..8f517b35 100644 --- a/chartlets.py/chartlets/components/__init__.py +++ b/chartlets.py/chartlets/components/__init__.py @@ -12,6 +12,7 @@ from .radiogroup import Radio from .radiogroup import RadioGroup from .select import Select +from .skeleton import Skeleton from .slider import Slider from .switch import Switch from .datagrid import DataGrid diff --git a/chartlets.py/chartlets/components/skeleton.py b/chartlets.py/chartlets/components/skeleton.py new file mode 100644 index 00000000..a41b0c30 --- /dev/null +++ b/chartlets.py/chartlets/components/skeleton.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Literal + +from chartlets import Component + + +@dataclass(frozen=True) +class Skeleton(Component): + """Display a placeholder preview of your content before the data gets + loaded to reduce load-time frustration.""" + + variant: Literal["text", "rectangular", "circular", "rounded"] | str | None\ + = None + """The type of skeleton to display.""" + + width: str | int | None = None + """Width of the skeleton. Can be a number (pixels) or a string (e.g., '100%').""" + + height: str | int | None = None + """Height of the skeleton. Can be a number (pixels) or a string (e.g., '50px').""" + + animation: Literal["pulse", "wave", False] | None = None + """The animation effect to use. + - 'pulse': A subtle pulsing animation. + - 'wave': A shimmering animation. + - False: No animation. + """ + + opacity: float | None = None + """Opacity to change what is seen during the load time. + If opacity is set to 1, it will hide everything behind it. + If opacity is less than 1 but not 0, it provides a opaque view of the + background + If opacity is set to 0, it still shows the minimal amount of skeleton. + """ diff --git a/chartlets.py/demo/my_extension/my_panel_1.py b/chartlets.py/demo/my_extension/my_panel_1.py index 2783fa32..0d46ee4f 100644 --- a/chartlets.py/demo/my_extension/my_panel_1.py +++ b/chartlets.py/demo/my_extension/my_panel_1.py @@ -1,7 +1,8 @@ +import time from typing import Any import altair as alt from chartlets import Component, Input, Output, State -from chartlets.components import VegaChart, Box, Select +from chartlets.components import VegaChart, Box, Select, Skeleton from server.context import Context from server.panel import Panel @@ -13,8 +14,14 @@ @panel.layout() def render_panel(ctx: Context) -> Component: selected_dataset: int = 0 + chart_skeleton = Skeleton( + height="100%", width="100%", variant="rounded", animation="wave", opacity=0.1 + ) chart = VegaChart( - id="chart", chart=make_chart(ctx, selected_dataset), style={"flexGrow": 1} + id="chart", + chart=make_chart(ctx, selected_dataset), + style={"flexGrow": 1}, + skeletonProps=chart_skeleton.to_dict(), ) select = Select( id="selected_dataset", @@ -52,6 +59,8 @@ def render_panel(ctx: Context) -> Component: def make_chart(ctx: Context, selected_dataset: int = 0) -> alt.Chart: dataset_key = tuple(ctx.datasets.keys())[selected_dataset] dataset = ctx.datasets[dataset_key] + # simulate lag to show skeleton + time.sleep(5) variable_name = "a" if selected_dataset == 0 else "u" @@ -103,6 +112,8 @@ def get_click_event_points( that was clicked. """ + # simulate lag to show skeleton + time.sleep(5) if points: conditions = [] for field, values in points.items(): diff --git a/chartlets.py/demo/my_extension/my_panel_6.py b/chartlets.py/demo/my_extension/my_panel_6.py index 7622c678..e6e9672a 100644 --- a/chartlets.py/demo/my_extension/my_panel_6.py +++ b/chartlets.py/demo/my_extension/my_panel_6.py @@ -1,5 +1,7 @@ +import time + from chartlets import Component, Input, Output -from chartlets.components import Box, Typography, Table +from chartlets.components import Box, Typography, Table, Skeleton, Button from server.context import Context from server.panel import Panel @@ -32,11 +34,23 @@ def render_panel( ["3", "Peter", "Jones", 40], ] - table = Table(id="table", rows=rows, columns=columns, hover=True) + table_skeleton = Skeleton( + height="100%", width="100%", variant="rounded", animation="wave", opacity=0.1 + ) + + table = Table( + id="table", + rows=rows, + columns=columns, + hover=True, + skeletonProps=table_skeleton.to_dict(), + ) title_text = Typography(id="title_text", children=["Basic Table"]) info_text = Typography(id="info_text", children=["Click on any row."]) + update_button = Button(id="update_button", text="Update Table") + return Box( style={ "display": "flex", @@ -45,14 +59,31 @@ def render_panel( "height": "100%", "gap": "6px", }, - children=[title_text, table, info_text], + children=[title_text, table, update_button, info_text], ) # noinspection PyUnusedLocal -@panel.callback(Input("table"), Output("info_text", "children")) -def update_info_text( - ctx: Context, - table_row: int, -) -> list[str]: +@panel.callback( + Input("table"), + Output("info_text", "children"), +) +def update_info_text(ctx: Context, table_row: int) -> list[str]: + time.sleep(3) + return [f"The clicked row value is {table_row}."] + + +@panel.callback( + Input("update_button", "clicked"), + Output("table", "rows"), +) +def update_table_rows(ctx: Context, update_button_clicked) -> TableRow: + # simulate lag to show skeleton + time.sleep(3) + rows: TableRow = [ + ["1", "John", "Smith", 94], + ["2", "Jane", "Jones", 5], + ["3", "Peter", "Doe", 40.5], + ] + return rows diff --git a/chartlets.py/tests/components/skeleton_test.py b/chartlets.py/tests/components/skeleton_test.py new file mode 100644 index 00000000..8b6a2cb3 --- /dev/null +++ b/chartlets.py/tests/components/skeleton_test.py @@ -0,0 +1,24 @@ +from chartlets.components import Skeleton +from tests.component_test import make_base + + +class SkeletonTest(make_base(Skeleton)): + + def test_is_json_serializable(self): + self.assert_is_json_serializable( + self.cls( + width=100, + height=50, + animation="pulse", + id="my-skeleton", + variant="rounded", + ), + { + "type": "Skeleton", + "width": 100, + "height": 50, + "animation": "pulse", + "id": "my-skeleton", + "variant": "rounded", + }, + )