Skip to content

Commit

Permalink
Merge 0026392 into ff3fa3e
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian committed Feb 3, 2021
2 parents ff3fa3e + 0026392 commit 67a42b0
Show file tree
Hide file tree
Showing 26 changed files with 653 additions and 75 deletions.
8 changes: 7 additions & 1 deletion spotlight-client/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@
],

// support typescript as well as javascript file extensions
"react/jsx-filename-extension": ["error", { "extensions": [".tsx", ".js"] }]
"react/jsx-filename-extension": [
"error",
{ "extensions": [".tsx", ".js"] }
],

// this can conflict with Prettier
"react/jsx-indent": "off"
},
"settings": {
"import/resolver": {
Expand Down
5 changes: 2 additions & 3 deletions spotlight-client/src/DataStore/UiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@
// =============================================================================

import { makeAutoObservable, observable } from "mobx";
import { ProjectedDataPoint } from "../charts/types";
import type RootStore from "./RootStore";

export default class UiStore {
rootStore: RootStore;

tooltipMobileData?: ProjectedDataPoint;
tooltipMobileData?: Record<string, unknown>;

renderTooltipMobile?: (props: ProjectedDataPoint) => React.ReactNode;
renderTooltipMobile?: (props: Record<string, unknown>) => React.ReactNode;

constructor({ rootStore }: { rootStore: RootStore }) {
makeAutoObservable(this, {
Expand Down
9 changes: 5 additions & 4 deletions spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import PopulationBreakdownByLocationMetric from "../contentModels/PopulationBrea
import VizPopulationBreakdownByLocation from "../VizPopulationBreakdownByLocation";
import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric";
import VizDemographicsByCategory from "../VizDemographicsByCategory";
import VizPrisonStayLengths from "../VizPrisonStayLengths";

type MetricVizMapperProps = {
metric: Metric<MetricRecord>;
Expand All @@ -36,10 +37,10 @@ const MetricVizMapper: React.FC<MetricVizMapperProps> = ({ metric }) => {
if (metric instanceof PopulationBreakdownByLocationMetric) {
return <VizPopulationBreakdownByLocation metric={metric} />;
}
if (
metric instanceof DemographicsByCategoryMetric &&
metric.id !== "PrisonStayLengthAggregate"
) {
if (metric instanceof DemographicsByCategoryMetric) {
if (metric.id === "PrisonStayLengthAggregate") {
return <VizPrisonStayLengths metric={metric} />;
}
return <VizDemographicsByCategory metric={metric} />;
}
return <h3>Placeholder for {metric.name}</h3>;
Expand Down
3 changes: 2 additions & 1 deletion spotlight-client/src/NoMetricData/NoMetricData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import { observer } from "mobx-react-lite";
import React from "react";
import Metric from "../contentModels/Metric";
import { MetricRecord } from "../contentModels/types";
Expand All @@ -30,4 +31,4 @@ const NoMetricData: React.FC<NoMetricDataProps> = ({ metric }) => {
return <Loading />;
};

export default NoMetricData;
export default observer(NoMetricData);
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@

import { observer } from "mobx-react-lite";
import { rem } from "polished";
import React, { useState } from "react";
import React from "react";
import Measure from "react-measure";
import { animated, useSpring, useTransition } from "react-spring/web.cjs";
import styled from "styled-components/macro";
import { ItemToHighlight, ProportionalBar } from "../charts";
import BubbleChart from "../charts/BubbleChart";
import { BubbleChart, ProportionalBar } from "../charts";
import { useHighlightedItem } from "../charts/utils";
import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric";
import DemographicFilterSelect from "../DemographicFilterSelect";
import FiltersWrapper from "../FiltersWrapper";
Expand Down Expand Up @@ -51,7 +51,7 @@ type VizDemographicsByCategoryProps = {
const VizDemographicsByCategory: React.FC<VizDemographicsByCategoryProps> = ({
metric,
}) => {
const [highlighted, setHighlighted] = useState<ItemToHighlight>();
const { highlighted, setHighlighted } = useHighlightedItem();

const { demographicView, dataSeries } = metric;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Recidiviz - a data platform for criminal justice reform
// Copyright (C) 2021 Recidiviz, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import {
fireEvent,
screen,
waitForElementToBeRemoved,
within,
} from "@testing-library/react";
import { runInAction, when } from "mobx";
import React from "react";
import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric";
import DataStore from "../DataStore";
import { reactImmediately, renderWithStore } from "../testUtils";
import { colors } from "../UiLibrary";
import VizPrisonStayLengths from "./VizPrisonStayLengths";

jest.mock("../MeasureWidth/MeasureWidth");

let metric: DemographicsByCategoryMetric;

beforeEach(() => {
runInAction(() => {
DataStore.tenantStore.currentTenantId = "US_ND";
});
reactImmediately(() => {
const metricToTest = DataStore.tenant?.metrics.get(
"PrisonStayLengthAggregate"
);
// it will be
if (metricToTest instanceof DemographicsByCategoryMetric) {
metric = metricToTest;
}
});
});

afterEach(() => {
runInAction(() => {
// reset data store
metric.demographicView = "total";
DataStore.tenantStore.currentTenantId = undefined;
});
});

test("loading", () => {
renderWithStore(<VizPrisonStayLengths metric={metric} />);
expect(screen.getByText(/loading/i)).toBeVisible();
});

test("total chart", async () => {
renderWithStore(<VizPrisonStayLengths metric={metric} />);

await when(() => !metric.isLoading);

const chart = screen.getByRole("group", { name: "7 bars in a bar chart" });
expect(chart).toBeVisible();
expect(
within(chart).getByRole("img", { name: "<1 year bar value 15%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "1–2 bar value 1%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "2–3 bar value 17%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "3–5 bar value 31%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "5–10 bar value 26%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "10–20 bar value 1%" })
).toBeVisible();
expect(
within(chart).getByRole("img", { name: "20+ bar value 9%" })
).toBeVisible();
});

test("demographic charts", async () => {
renderWithStore(<VizPrisonStayLengths metric={metric} />);

await when(() => !metric.isLoading);

const totalChart = screen.getByRole("group", {
name: "7 bars in a bar chart",
});

const menuButton = screen.getByRole("button", {
name: "View Total",
});
fireEvent.click(menuButton);
fireEvent.click(screen.getByRole("option", { name: "Race or Ethnicity" }));

// pause for animated transition
await waitForElementToBeRemoved(totalChart);

const raceCharts = screen.getAllByRole("group", {
name: "7 bars in a bar chart",
});
expect(raceCharts.length).toBe(5);

fireEvent.click(menuButton);
fireEvent.click(screen.getByRole("option", { name: "Gender" }));

// pause for animated transition
await waitForElementToBeRemoved(raceCharts[0]);

const genderCharts = screen.getAllByRole("group", {
name: "7 bars in a bar chart",
});
expect(genderCharts.length).toBe(2);

fireEvent.click(menuButton);
fireEvent.click(screen.getByRole("option", { name: "Age Group" }));

// pause for animated transition
await waitForElementToBeRemoved(genderCharts[0]);

expect(
screen.getAllByRole("group", { name: "7 bars in a bar chart" }).length
).toBe(5);
});

test("all bars are the same color", async () => {
renderWithStore(<VizPrisonStayLengths metric={metric} />);

await when(() => !metric.isLoading);

const chart = screen.getByRole("group", { name: "7 bars in a bar chart" });

within(chart)
.getAllByRole("img")
.forEach((el) =>
expect(el).toHaveStyle(`fill: ${colors.dataVizNamed.get("teal")}`)
);

expect.hasAssertions();
});
138 changes: 138 additions & 0 deletions spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Recidiviz - a data platform for criminal justice reform
// Copyright (C) 2021 Recidiviz, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import { observer } from "mobx-react-lite";
import React from "react";
import Measure from "react-measure";
import { animated, useSpring, useTransition } from "react-spring/web.cjs";
import styled from "styled-components/macro";
import {
CommonDataPoint,
BarChartTrellis,
singleChartHeight,
TooltipContentFunction,
} from "../charts";
import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric";
import DemographicFilterSelect from "../DemographicFilterSelect";
import FiltersWrapper from "../FiltersWrapper";
import { prisonStayLengthFields } from "../metricsApi";
import NoMetricData from "../NoMetricData";

const ChartsWrapper = styled.div`
position: relative;
`;

const getTooltipProps: TooltipContentFunction = (columnData) => {
const {
summary: [
{
data: { label, pct, value },
},
],
} = columnData as {
// can't find any Semiotic type definition that describes what is actually
// passed to this function, but the part we care about looks like this
summary: { data: CommonDataPoint }[];
};

return {
title: `${label}${
// special case: the first category already has "year" in it
label !== prisonStayLengthFields[0].categoryLabel ? " years" : ""
}`,
records: [
{
pct,
value,
},
],
};
};

type VizPrisonStayLengthsProps = {
metric: DemographicsByCategoryMetric;
};

const VizPrisonStayLengths: React.FC<VizPrisonStayLengthsProps> = ({
metric,
}) => {
const { dataSeries, demographicView } = metric;

const [chartContainerStyles, setChartContainerStyles] = useSpring(() => ({
from: { height: singleChartHeight },
height: singleChartHeight,
config: { friction: 40, tension: 220, clamp: true },
}));

const chartTransitions = useTransition(
{ demographicView, dataSeries },
(item) => item.demographicView,
{
initial: { opacity: 1 },
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0, position: "absolute" },
config: { friction: 40, tension: 280 },
}
);

if (demographicView === "nofilter")
throw new Error(
"Unable to display this metric without demographic filter."
);

if (dataSeries) {
return (
<Measure
bounds
onResize={({ bounds }) => {
if (bounds) setChartContainerStyles({ height: bounds.height });
}}
>
{({ measureRef }) => (
<>
<FiltersWrapper
filters={[<DemographicFilterSelect metric={metric} />]}
/>
<animated.div style={chartContainerStyles}>
{chartTransitions.map(({ item, key, props }) => (
<ChartsWrapper key={key} ref={measureRef}>
<animated.div style={{ ...props, top: 0 }}>
{
// for type safety we have to check this again
// but it should always be defined if we've gotten this far
item.dataSeries && (
<BarChartTrellis
data={item.dataSeries}
getTooltipProps={getTooltipProps}
/>
)
}
</animated.div>
</ChartsWrapper>
))}
</animated.div>
</>
)}
</Measure>
);
}

return <NoMetricData metric={metric} />;
};

export default observer(VizPrisonStayLengths);

0 comments on commit 67a42b0

Please sign in to comment.