Skip to content
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
18 changes: 12 additions & 6 deletions src/components/DrugInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode } from "react";
import React, { memo, ReactNode } from "react";
import { Card, CardContent, Container, Grid, Typography } from "@mui/material";
import {
Dosage,
Expand Down Expand Up @@ -199,6 +199,9 @@ const addDurations = (drugData: Drug) => {
const option = {
chart: {
id: "durationChart",
// The grow-in animation keeps the browser drawing frames for about two seconds after a
// factsheet row is expanded. Draw the chart once instead.
animations: { enabled: false },
},
plotOptions: {
bar: {
Expand Down Expand Up @@ -525,6 +528,7 @@ const addDosages = (drugData: Drug) => {
const option = {
chart: {
id: "doseChart",
animations: { enabled: false }, // See the duration chart above
},
plotOptions: {
bar: {
Expand Down Expand Up @@ -660,11 +664,7 @@ const addDosages = (drugData: Drug) => {
);
};

export default function DrugInfoCard({
drugData,
}: {
drugData: Drug;
}): ReactNode {
function DrugInfoCard({ drugData }: { drugData: Drug }): ReactNode {
// This function goes through all the optional data in the drug and creates a nice and fancy expandable row

// This is the array that will hold all the elements that will be displayed in the expanded row
Expand Down Expand Up @@ -1159,3 +1159,9 @@ export default function DrugInfoCard({
</Container>
);
}

// The factsheets table re-renders on every state change (each keystroke in a filter, for
// example) and asks for the detail panel of every row each time. Without memo, an open card
// re-renders too, and react-apexcharts treats the fresh options object (its tooltip functions
// never compare equal) as a change and redraws both charts from scratch.
export default memo(DrugInfoCard);
6 changes: 5 additions & 1 deletion src/components/Factsheets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@ const Factsheets = () => {
{InfoBar}
</>
),
renderDetailPanel: ({ row }) => DrugInfoCard({ drugData: row.original }),
// MRT calls this for every row on the page on every render, expanded or not, and only
// mounts the result once the row is expanded. Return an element rather than calling
// DrugInfoCard directly, so the card (its charts, combos and dictionary tooltips) is only
// built for expanded rows.
renderDetailPanel: ({ row }) => <DrugInfoCard drugData={row.original} />,
});

return <MaterialReactTable table={table} data-bs-theme="dark" />;
Expand Down
24 changes: 24 additions & 0 deletions src/components/__tests__/DrugInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import DrugInfo from "../DrugInfo";
jest.mock("next/router", () => ({ useRouter: jest.fn() }));
jest.mock("@/utils/drugs", () => ({ useDrugs: jest.fn() }));
jest.mock("../Header", () => () => null);
// Called on every chart render. In the browser, react-apexcharts redraws the chart whenever it
// re-renders with a new options object, so a render here stands for a redraw there.
const mockChartRender = jest.fn();
jest.mock(
"next/dynamic",
() => () =>
Expand All @@ -17,6 +20,7 @@ jest.mock(
}: {
series: unknown;
options: {
chart: unknown;
tooltip: {
custom: (input: {
seriesIndex: number;
Expand All @@ -26,6 +30,7 @@ jest.mock(
};
};
}) {
mockChartRender(options.chart);
return (
<>
<output aria-label="Chart series">{JSON.stringify(series)}</output>
Expand Down Expand Up @@ -127,6 +132,25 @@ test("keeps dose text and charts the ranges for each route", () => {
]);
});

test("draws an open card's charts once, without animation", () => {
mockChartRender.mockClear();
const drugData = {
...drug,
formatted_dose: { oral: { common: "1-2mg" } },
formatted_duration: { oral: "1-2", _unit: "hours" as Unit },
} as Drug;
const { rerender } = render(<DrugInfo drugData={drugData} />);
expect(mockChartRender).toHaveBeenCalledTimes(2);
expect(mockChartRender.mock.calls).toEqual([
[expect.objectContaining({ animations: { enabled: false } })],
[expect.objectContaining({ animations: { enabled: false } })],
]);

// The factsheets table re-renders the panel of an expanded row on every keystroke in a filter
rerender(<DrugInfo drugData={drugData} />);
expect(mockChartRender).toHaveBeenCalledTimes(2);
});

test("keeps unplottable doses in the table", () => {
render(
<DrugInfo
Expand Down
51 changes: 51 additions & 0 deletions src/components/__tests__/FactsheetsDetailPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Drug } from "tripsit_drug_db";
import FactsheetsPage from "@/pages/factsheets";

jest.mock("@heroui/react", () => ({ Accordion: () => null }));
const mockDrugInfoCard = jest.fn(({ drugData }: { drugData: Drug }) => (
<p>Details for {drugData.pretty_name}</p>
));
jest.mock("../DrugInfo", () => ({
__esModule: true,
default: (props: { drugData: Drug }) => mockDrugInfoCard(props),
}));

const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

test("builds a factsheet card only for the expanded row", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
first: { name: "first", pretty_name: "First compound", properties: {} },
second: {
name: "second",
pretty_name: "Second compound",
properties: {},
},
}),
});
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<FactsheetsPage />
</QueryClientProvider>,
);
expect(await screen.findByText("Second compound")).toBeVisible();
// The table asks for every row's detail panel on each render; collapsed rows must not build one
expect(mockDrugInfoCard).not.toHaveBeenCalled();

fireEvent.click(screen.getAllByRole("button", { name: "Expand" })[1]);
expect(await screen.findByText("Details for Second compound")).toBeVisible();
expect(
new Set(mockDrugInfoCard.mock.calls.map(([props]) => props.drugData.name)),
).toEqual(new Set(["second"]));
});
Loading