Skip to content

Add Skeleton component #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chartlets.js/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,18 @@
equalObjPaths(input.property, changeEvent.property),
);
if (inputIndex >= 0) {
// Collect output IDs for updating their respective loading states
const outputs = contribution.callbacks?.[callbackIndex]["outputs"];

Check warning on line 74 in chartlets.js/packages/lib/src/actions/handleComponentChange.ts

View check run for this annotation

Codecov / codecov/patch

chartlets.js/packages/lib/src/actions/handleComponentChange.ts#L74

Added line #L74 was not covered by tests
const outputIds: string[] =
outputs?.map((output) => output.id as string) ?? [];
// Collect triggered callback
callbackRequests.push({
contribPoint,
contribIndex,
callbackIndex,
inputIndex,
inputValues: getInputValues(inputs, contribution, hostStore),
outputIds: outputIds,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe("handleHostStoreChange", () => {
inputIndex: 0,
inputValues: ["CHL"],
property: "variableName",
outputIds: ["select"],
},
]);

Expand Down Expand Up @@ -281,6 +282,7 @@ describe("handleHostStoreChange", () => {
expect(result[0]).toEqual({
...propertyRefs[0],
inputValues: ["CHL"],
outputIds: [],
});

// second call -> memoized -> should not create callback request
Expand All @@ -293,6 +295,7 @@ describe("handleHostStoreChange", () => {
expect(result[0]).toEqual({
...propertyRefs[0],
inputValues: ["TMP"],
outputIds: [],
});

// fourth call -> memoized -> should not invoke callback
Expand Down
42 changes: 41 additions & 1 deletion chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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) {
Expand All @@ -13,6 +13,20 @@
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) {
Expand All @@ -23,13 +37,39 @@
);
}
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({

Check warning on line 45 in chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts

View check run for this annotation

Codecov / codecov/patch

chartlets.js/packages/lib/src/actions/helpers/invokeCallbacks.ts#L42-L45

Added lines #L42 - L45 were not covered by tests
loadingState: {
...loadingState,
[outputId]: false,
},
});
});
});
} else {
console.error(
"callback failed:",
changeRequestsResult.error,
"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",
},
});
});
});
}
},
);
Expand Down
3 changes: 3 additions & 0 deletions chartlets.js/packages/lib/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ const selectContributionsRecord = (state: StoreState) =>

const selectThemeMode = (state: StoreState) => state.themeMode;

const selectLoadingState = (state: StoreState) => state.loadingState;

const useStore = store;

export const useConfiguration = () => useStore(selectConfiguration);
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
Expand Down
42 changes: 42 additions & 0 deletions chartlets.js/packages/lib/src/plugins/mui/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Skeleton isLoading height={10} />);
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(
<Skeleton isLoading={false} height={10}>
<div>Test Content</div>
</Skeleton>,
);
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(<Skeleton isLoading variant="circular" />);
const muiSkeleton = screen.getByTestId("skeleton-test-id");
expect(muiSkeleton).toHaveClass("MuiSkeleton-circular");
});

it("should render with specified width and height", () => {
render(<Skeleton isLoading width={100} height={50} />);
const muiSkeleton = screen.getByTestId("skeleton-test-id");
expect(muiSkeleton).toHaveStyle("width: 100px");
expect(muiSkeleton).toHaveStyle("height: 50px");
});

it("should render with specified animation", () => {
render(<Skeleton isLoading animation="wave" />);
const muiSkeleton = screen.getByTestId("skeleton-test-id");
expect(muiSkeleton).toHaveClass("MuiSkeleton-wave");
});
});
61 changes: 61 additions & 0 deletions chartlets.js/packages/lib/src/plugins/mui/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentState, "type" | "children"> {
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 (
<div style={{ position: "relative", ...style }} id={id}>
{children}
{isLoading && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(255, 255, 255, ${opacity})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1,
}}
>
<MuiSkeleton
id={id}
style={{ transform: "none" }}
{...props}
data-testid="skeleton-test-id"
/>
</div>
)}
</div>
);
};
26 changes: 26 additions & 0 deletions chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,30 @@ describe("Table", () => {
},
});
});

it("should not render the Table component when no id provided", () => {
render(<Table type={"Table"} rows={rows} onChange={() => {}} />);

const table = screen.queryByRole("table");
expect(table).toBeNull();
});

it(
"should render the Table component with skeleton when skeletonProps are" +
" provided",
() => {
render(
<Table
id="table"
type={"Table"}
rows={rows}
onChange={() => {}}
skeletonProps={{ variant: "rectangular" }}
/>,
);

const table = screen.queryByRole("table");
expect(table).toBeNull();
},
);
});
31 changes: 30 additions & 1 deletion chartlets.js/packages/lib/src/plugins/mui/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
} 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;
Expand Down Expand Up @@ -38,8 +41,18 @@
columns,
hover,
stickyHeader,
skeletonProps,
onChange,
}: TableProps) => {
const loadingState = useLoadingState();
if (!id) {
return;
}
const isLoading = loadingState[id];
if (isLoading == "failed") {
return <div>An error occurred while loading the data.</div>;
}

if (!columns || columns.length === 0) {
return <div>No columns provided.</div>;
}
Expand Down Expand Up @@ -69,7 +82,7 @@
}
};

return (
const table: ReactElement | null = (
<TableContainer component={Paper} sx={style} id={id}>
<MuiTable stickyHeader={stickyHeader}>
<TableHead>
Expand Down Expand Up @@ -107,4 +120,20 @@
</MuiTable>
</TableContainer>
);

const isSkeletonRequired = skeletonProps !== undefined;
if (!isSkeletonRequired) {
return table;
}
const skeletonId = id + "-skeleton";

Check warning on line 128 in chartlets.js/packages/lib/src/plugins/mui/Table.tsx

View check run for this annotation

Codecov / codecov/patch

chartlets.js/packages/lib/src/plugins/mui/Table.tsx#L128

Added line #L128 was not covered by tests
return (
<Skeleton
isLoading={isLoading}
id={skeletonId}
style={style}
{...skeletonProps}
>
{table}
</Skeleton>
);
};
17 changes: 15 additions & 2 deletions chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
<VegaChart
Expand All @@ -35,12 +35,25 @@ describe("VegaChart", () => {
// 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(<VegaChart type={"VegaChart"} chart={chart} onChange={onChange} />);
expect(recordedEvents.length).toBe(0);
const test_chart = screen.queryByTestId("vega-test-id");
expect(test_chart).toBeNull();
});
});

const chart: TopLevelSpec = {
Expand Down
Loading