Skip to content

Commit

Permalink
Added breakdown implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
atrincas authored and Andrés González committed Nov 13, 2024
1 parent 3caf3f8 commit 615f78e
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 36 deletions.
4 changes: 3 additions & 1 deletion client/src/containers/sandbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";

export default function Sandbox() {
const { visualization, setVisualization, widget } = useSandboxWidget();
const { breakdown, visualization, setVisualization, widget } =
useSandboxWidget();
const menuItems = (
<>
<Button
Expand Down Expand Up @@ -39,6 +40,7 @@ export default function Sandbox() {
<Card className="p-0">
{widget && (
<Widget
breakdown={breakdown || undefined}
indicator={widget.indicator}
question={widget.question}
visualization={visualization || widget.defaultVisualization}
Expand Down
85 changes: 85 additions & 0 deletions client/src/containers/sidebar/breakdown-selector/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FC, useState } from "react";

import { BaseWidget } from "@shared/dto/widgets/base-widget.entity";
import { useSetAtom } from "jotai";
import { useQueryState } from "nuqs";

import { client } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queryKeys";

import { showOverlayAtom } from "@/containers/overlay/store";
import SearchableList from "@/containers/searchable-list";

import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SIDEBAR_POPOVER_CLASS } from "@/constants";

const BreakdownSelector: FC = () => {
const [breakdown, setBreakdown] = useQueryState("breakdown");
const { data } = client.widgets.getWidgets.useQuery(
queryKeys.widgets.all.queryKey,
{ query: {} },
{ select: (res) => res.body.data },
);
const [showIndicators, setShowIndicators] = useState(false);
const setshowOverlay = useSetAtom(showOverlayAtom);
const widgets = data as BaseWidget[];

return (
<Popover
open={showIndicators}
onOpenChange={(o) => {
setShowIndicators(o);
setshowOverlay(o);
}}
>
<PopoverTrigger asChild>
<Button
variant="clean"
className="inline-block h-full w-full whitespace-pre-wrap rounded-none px-4 py-3.5 text-left font-normal transition-colors hover:bg-secondary"
>
{breakdown ? (
<>
<span>Breakdown by&nbsp;</span>
<span className="font-bold">{`${breakdown}`}&nbsp;</span>
<span
className="h-full p-0 align-text-bottom text-xs transition-all hover:font-extrabold"
onClick={(e) => {
e.stopPropagation();
setBreakdown(null);
}}
>
x
</span>
</>
) : (
<span>Add a data breakdown</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className={SIDEBAR_POPOVER_CLASS}
>
<SearchableList
items={widgets.filter((w) =>
w.visualisations.some((v) => v !== "horizontal_bar_chart"),
)}
itemKey="indicator"
onItemClick={(w) => {
setBreakdown(w.indicator);
setShowIndicators(false);
setshowOverlay(false);
}}
/>
</PopoverContent>
</Popover>
);
};

export default BreakdownSelector;
12 changes: 2 additions & 10 deletions client/src/containers/sidebar/filter-settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { queryKeys } from "@/lib/queryKeys";

import useFilters from "@/hooks/use-filters";

import BreakdownSelector from "@/containers/sidebar/breakdown-selector";
import FilterPopup from "@/containers/sidebar/filter-settings/filter-popup";

import { Button } from "@/components/ui/button";

const DEFAULT_FILTERS = ["location-country-region", "sector"];
const DEFAULT_FILTERS_LABEL_MAP: {
[key: string]: { selected: string; unSelected: string };
Expand Down Expand Up @@ -65,14 +64,7 @@ const FilterSettings: FC<{ withDataBreakdown?: boolean }> = ({
name="Add a custom filter"
items={customFilters}
/>
{withDataBreakdown && (
<Button
variant="clean"
className="w-full justify-start rounded-none px-4 py-3.5 font-normal transition-colors hover:bg-secondary"
>
Add a data breakdown
</Button>
)}
{withDataBreakdown && <BreakdownSelector />}
</>
);
};
Expand Down
28 changes: 15 additions & 13 deletions client/src/containers/widget/breakdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";
import React, { FC } from "react";

import { WidgetChartData } from "@shared/dto/widgets/base-widget-data.interface";
import { WidgetBreakdownData } from "@shared/dto/widgets/base-widget-data.interface";
import { Bar, BarChart, Cell, XAxis, YAxis } from "recharts";

import { CSS_CHART_COLORS, TW_CHART_COLORS } from "@/lib/constants";
Expand All @@ -16,7 +16,7 @@ import {
} from "@/components/ui/chart";

interface BreakdownProps {
data?: { label: string; data: WidgetChartData }[];
data?: WidgetBreakdownData;
}

const Breakdown: FC<BreakdownProps> = ({ data }) => {
Expand Down Expand Up @@ -81,17 +81,19 @@ const Breakdown: FC<BreakdownProps> = ({ data }) => {
data-testid="breakdown-chart-legend"
className="flex items-center gap-6 pl-6"
>
{data[0].data.map((d, i) => (
<p
key={`breakdown-chart-legend-${d.label}`}
className="flex items-center gap-x-1 text-xs"
>
<span
className={cn("block h-3 w-3 rounded-full", TW_CHART_COLORS[i])}
></span>
<span>{d.label}</span>
</p>
))}
{[...data]
.sort((a, b) => b.data.length - a.data.length)[0]
.data.map((d, i) => (
<p
key={`breakdown-chart-legend-${d.label}`}
className="flex items-center gap-x-1 text-xs"
>
<span
className={cn("block h-3 w-3 rounded-full", TW_CHART_COLORS[i])}
></span>
<span>{d.label}</span>
</p>
))}
</div>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions client/src/containers/widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import MenuButton from "@/containers/menu-button";
import NoData from "@/containers/no-data";
import { showOverlayAtom } from "@/containers/overlay/store";
import AreaChart from "@/containers/widget/area-chart";
import Breakdown from "@/containers/widget/breakdown";
import Filter from "@/containers/widget/filter";
import HorizontalBarChart from "@/containers/widget/horizontal-bar-chart";
import Map from "@/containers/widget/map";
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface WidgetProps {
indicator: string;
data: BaseWidgetWithData["data"];
visualization: WidgetVisualizationsType;
breakdown?: string;
visualisations?: WidgetVisualizationsType[];
question?: string;
menuItems?: React.ReactNode;
Expand All @@ -68,6 +70,7 @@ export default function Widget({
indicator,
visualization,
visualisations,
breakdown,
data,
question,
menuItems,
Expand Down Expand Up @@ -130,6 +133,21 @@ export default function Widget({
);
}

if (breakdown) {
return (
<Card
className={cn(
"relative min-h-80 p-0 pb-7",
showOverlay && "z-50",
className,
)}
>
<WidgetHeader indicator={indicator} question={question} menu={menu} />
<Breakdown data={data.breakdown} />
</Card>
);
}

switch (selectedVisualization) {
case WIDGET_VISUALIZATIONS.SINGLE_VALUE:
return (
Expand Down
14 changes: 12 additions & 2 deletions client/src/hooks/use-sandbox-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
} from "@shared/dto/widgets/widget-visualizations.constants";
import { parseAsStringEnum, useQueryState } from "nuqs";

import { normalizeWidgetData } from "@/lib/normalize-widget-data";
import { client } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queryKeys";

import useFilters from "@/hooks/use-filters";

function useSandboxWidget() {
const [breakdown] = useQueryState("breakdown");
const [indicator, setIndicator] = useQueryState("indicator", {
defaultValue: "",
});
Expand All @@ -20,19 +22,27 @@ function useSandboxWidget() {
);
const { filters } = useFilters();
const { data } = client.widgets.getWidget.useQuery(
queryKeys.widgets.one(indicator, filters).queryKey,
queryKeys.widgets.one(indicator, filters, breakdown || undefined).queryKey,
{
params: { id: indicator },
query: {
filters,
breakdown: breakdown || undefined,
},
},
{ enabled: !!indicator, select: (res) => res.body.data },
{
enabled: !!indicator,
select: (res) => ({
...res.body.data,
data: normalizeWidgetData(res.body.data.data),
}),
},
);

return {
indicator,
visualization,
breakdown,
setIndicator,
setVisualization,
widget: data,
Expand Down
7 changes: 7 additions & 0 deletions client/src/lib/normalize-widget-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ function normalizeWidgetData(widgetData: WidgetData): WidgetData {
result.chart = normalizeChartData(result.chart);
}

if (result.breakdown) {
result.breakdown = result.breakdown.map((b) => ({
...b,
data: normalizeChartData(b.data),
}));
}

return result;
}

Expand Down
3 changes: 2 additions & 1 deletion client/src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ export const pageFiltersKeys = createQueryKeys("pageFilters", {

export const widgetsKeys = createQueryKeys("widgets", {
all: null,
one: (indicator: string, filters: FilterQueryParam[]) => [
one: (indicator: string, filters: FilterQueryParam[], breakdown?: string) => [
indicator,
{ filters },
breakdown,
],
});

Expand Down
1 change: 1 addition & 0 deletions client/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const getInPageLinkId = (slug?: string): string => `inPage-${slug}-link`;
export function isEmptyWidget(data: BaseWidgetWithData["data"]): boolean {
return (
!data.counter &&
!data.breakdown &&
!data.navigation &&
!data.chart?.length &&
!data.map?.length
Expand Down
23 changes: 14 additions & 9 deletions client/tests/widget/breakdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import BreakdownChart from "@/containers/widget/breakdown";
import { CSS_CHART_COLORS, TW_CHART_COLORS } from "@/lib/constants";
import type { ResponsiveContainerProps } from "recharts";
import { WidgetBreakdownData } from "@shared/dto/widgets/base-widget-data.interface";

vi.mock("recharts", async () => {
const mockRecharts = await vi.importActual<any>("recharts");
Expand All @@ -20,21 +21,25 @@ vi.mock("@/containers/no-data", () => ({
default: () => <div data-testid="no-data">No data</div>,
}));

const mockData = [
const mockData: WidgetBreakdownData = [
{
label: "Category 1",
data: [{ label: "Option A", value: 100, total: 100 }],
},
{
label: "Category 2",
data: [
{ label: "Option A", value: 50 },
{ label: "Option B", value: 30 },
{ label: "Option C", value: 20 },
{ label: "Option A", value: 60, total: 100 },
{ label: "Option B", value: 25, total: 100 },
{ label: "Option C", value: 15, total: 100 },
],
},
{
label: "Category 2",
label: "Category 3",
data: [
{ label: "Option A", value: 60 },
{ label: "Option B", value: 25 },
{ label: "Option C", value: 15 },
{ label: "Option A", value: 50, total: 100 },
{ label: "Option B", value: 30, total: 100 },
{ label: "Option C", value: 20, total: 100 },
],
},
];
Expand Down Expand Up @@ -91,7 +96,7 @@ describe("BreakdownChart", () => {

await waitFor(() => {
const legendItems = screen.getByTestId("breakdown-chart-legend").children;
const categoryData = mockData[0].data;
const categoryData = mockData[1].data;

expect(legendItems).toHaveLength(categoryData.length);

Expand Down
23 changes: 23 additions & 0 deletions client/tests/widget/widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ vi.mock("@/containers/no-data", () => ({
default: () => <div data-testid="no-data">No data</div>,
}));

vi.mock("@/containers/widget/breakdown", () => ({
default: () => <div data-testid="breakdown">Breakdown Chart</div>,
}));

describe("Widget", () => {
const mockProps: WidgetProps = {
indicator: "Test Indicator",
Expand Down Expand Up @@ -151,6 +155,25 @@ describe("Widget", () => {
});
});

it("renders breakdown chart when breakdown props is passed", () => {
render(
<Widget
{...mockProps}
breakdown="another-indicator"
data={{
breakdown: [
{
label: "Category 1",
data: [{ label: "Option A", value: 100, total: 100 }],
},
],
}}
/>,
);

expect(screen.getByTestId("breakdown")).toBeInTheDocument();
});

it("renders null for unsupported visualization type", () => {
const consoleWarnSpy = vi
.spyOn(console, "warn")
Expand Down

0 comments on commit 615f78e

Please sign in to comment.