From 7a1c92c54dc924097f737ee5cfe7e18f40fe7dce Mon Sep 17 00:00:00 2001 From: Terry Tsai Date: Mon, 24 Oct 2022 16:06:12 -0700 Subject: [PATCH] implement basic agency routing + view metric data viz w/ live data (#105) * implement basic agency routing + view metric data viz w/ live data * move toast to common * add jest tests * fix importing png type --- agency-dashboard/.env.example | 1 + agency-dashboard/package.json | 23 +-- .../src/AgencyOverview.styles.tsx | 35 ++++ agency-dashboard/src/AgencyOverview.test.tsx | 98 ++++++++++++ agency-dashboard/src/AgencyOverview.tsx | 72 +++++++++ agency-dashboard/src/App.tsx | 11 +- agency-dashboard/src/DashboardView.styles.tsx | 9 +- agency-dashboard/src/DashboardView.test.tsx | 77 +++++++++ agency-dashboard/src/DashboardView.tsx | 139 +++++++--------- agency-dashboard/src/index.tsx | 8 +- agency-dashboard/src/react-app-env.d.ts | 2 + .../src/{App.test.tsx => setupProxy.js} | 18 ++- agency-dashboard/src/setupTests.ts | 4 + .../src/stores/DatapointsStore.ts | 149 ++++++++++++++++++ agency-dashboard/src/stores/RootStore.ts | 29 ++++ agency-dashboard/src/stores/StoreProvider.tsx | 42 +++++ agency-dashboard/src/stores/index.ts | 20 +++ agency-dashboard/src/utils/networking.ts | 46 ++++++ .../assets/status-check-white-icon.png | Bin .../src => common}/components/Toast/Toast.ts | 2 +- .../src => common}/components/Toast/index.ts | 0 common/{utils/index copy.ts => index.d.ts} | 4 +- common/tsconfig.json | 3 +- publisher/package.json | 3 +- .../src/components/DataUpload/DataUpload.tsx | 2 +- .../src/components/DataUpload/UploadFile.tsx | 2 +- .../components/DataUpload/UploadedFiles.tsx | 2 +- .../components/MetricsView/MetricsView.tsx | 2 +- .../src/components/Onboarding/Onboarding.tsx | 2 +- .../src/components/Reports/CreateReport.tsx | 2 +- .../src/components/Reports/DataEntryForm.tsx | 2 +- .../Reports/PublishConfirmation.tsx | 2 +- .../components/Reports/ReportDataEntry.tsx | 2 +- publisher/src/stores/API.ts | 2 +- publisher/src/stores/DatapointsStore.ts | 3 + publisher/src/stores/UserStore.ts | 2 +- yarn.lock | 5 - 37 files changed, 701 insertions(+), 124 deletions(-) create mode 100644 agency-dashboard/.env.example create mode 100644 agency-dashboard/src/AgencyOverview.styles.tsx create mode 100644 agency-dashboard/src/AgencyOverview.test.tsx create mode 100644 agency-dashboard/src/AgencyOverview.tsx create mode 100644 agency-dashboard/src/DashboardView.test.tsx rename agency-dashboard/src/{App.test.tsx => setupProxy.js} (75%) create mode 100644 agency-dashboard/src/stores/DatapointsStore.ts create mode 100644 agency-dashboard/src/stores/RootStore.ts create mode 100644 agency-dashboard/src/stores/StoreProvider.tsx create mode 100644 agency-dashboard/src/stores/index.ts create mode 100644 agency-dashboard/src/utils/networking.ts rename {publisher/src/components => common}/assets/status-check-white-icon.png (100%) rename {publisher/src => common}/components/Toast/Toast.ts (98%) rename {publisher/src => common}/components/Toast/index.ts (100%) rename common/{utils/index copy.ts => index.d.ts} (89%) diff --git a/agency-dashboard/.env.example b/agency-dashboard/.env.example new file mode 100644 index 000000000..5009a3245 --- /dev/null +++ b/agency-dashboard/.env.example @@ -0,0 +1 @@ +REACT_APP_PROXY_HOST=http://localhost:5001 diff --git a/agency-dashboard/package.json b/agency-dashboard/package.json index 22e78dce3..c8e302aa8 100644 --- a/agency-dashboard/package.json +++ b/agency-dashboard/package.json @@ -4,18 +4,14 @@ "private": true, "dependencies": { "@justice-counts/common": "*", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.5.2", - "@types/node": "^16.11.64", - "@types/react": "^18.0.21", - "@types/react-dom": "^18.0.6", + "http-proxy-middleware": "^2.0.6", + "mobx": "^6.4.2", + "mobx-react-lite": "^3.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6", "styled-components": "^5.3.6", - "typescript": "^4.8.4", - "web-vitals": "^2.1.4" + "typescript": "^4.8.4" }, "scripts": { "dev": "react-app-rewired start", @@ -43,7 +39,16 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.11.64", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "@types/react-router-dom": "^5.3.3", "customize-cra": "^1.0.0", + "jest-fetch-mock": "^3.0.3", "react-app-rewired": "^2.2.1", "react-scripts": "5.0.1", "resize-observer-polyfill": "^1.5.1" diff --git a/agency-dashboard/src/AgencyOverview.styles.tsx b/agency-dashboard/src/AgencyOverview.styles.tsx new file mode 100644 index 000000000..a014028c0 --- /dev/null +++ b/agency-dashboard/src/AgencyOverview.styles.tsx @@ -0,0 +1,35 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +import { palette } from "@justice-counts/common/components/GlobalStyles"; +import styled from "styled-components/macro"; + +export const MetricCategory = styled.div` + height: 100px; + width: 100%; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: ${palette.solid.blue}; + + &:hover { + cursor: pointer; + text-decoration: underline; + } +`; diff --git a/agency-dashboard/src/AgencyOverview.test.tsx b/agency-dashboard/src/AgencyOverview.test.tsx new file mode 100644 index 000000000..4c4d90c30 --- /dev/null +++ b/agency-dashboard/src/AgencyOverview.test.tsx @@ -0,0 +1,98 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; + +import AgencyOverview from "./AgencyOverview"; +import { StoreProvider } from "./stores"; + +beforeEach(() => { + fetchMock.resetMocks(); +}); + +// test("renders loading state", () => { +// fetchMock.mockResponseOnce( +// JSON.stringify({ +// datapoints: [], +// dimension_names_by_metric_and_disaggregation: {}, +// }) +// ); + +// render( +// +// +// +// +// +// ); +// const loadingElement = screen.getByText(/Loading.../i); +// expect(loadingElement).toBeInTheDocument(); +// }); + +test("renders 'No published metrics' state", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + datapoints: [], + dimension_names_by_metric_and_disaggregation: {}, + }) + ); + + render( + + + + + + ); + + const textElement = await screen.findByText(/No published metrics./i); + expect(textElement).toBeInTheDocument(); +}); + +test("renders list of metrics", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + datapoints: [{}], + dimension_names_by_metric_and_disaggregation: { + LAW_ENFORCEMENT_ARRESTS: {}, + LAW_ENFORCEMENT_BUDGET: {}, + LAW_ENFORCEMENT_CALLS_FOR_SERVICE: {}, + }, + }) + ); + + render( + + + + + + ); + const textElement1 = await screen.findByText( + /Click on a metric to view chart:/i + ); + expect(textElement1).toBeInTheDocument(); + const textElement2 = await screen.findByText(/LAW_ENFORCEMENT_ARRESTS/i); + expect(textElement2).toBeInTheDocument(); + const textElement3 = await screen.findByText(/LAW_ENFORCEMENT_BUDGET/i); + expect(textElement3).toBeInTheDocument(); + const textElement4 = await screen.findByText( + /LAW_ENFORCEMENT_CALLS_FOR_SERVICE/i + ); + expect(textElement4).toBeInTheDocument(); +}); diff --git a/agency-dashboard/src/AgencyOverview.tsx b/agency-dashboard/src/AgencyOverview.tsx new file mode 100644 index 000000000..28cde03b0 --- /dev/null +++ b/agency-dashboard/src/AgencyOverview.tsx @@ -0,0 +1,72 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +import { showToast } from "@justice-counts/common/components/Toast"; +import { observer } from "mobx-react-lite"; +import React, { useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { MetricCategory } from "./AgencyOverview.styles"; +import { useStore } from "./stores"; + +const AgencyOverview = () => { + const navigate = useNavigate(); + const params = useParams(); + const agencyId = Number(params.id); + const { datapointsStore } = useStore(); + + const fetchDatapoints = async () => { + try { + await datapointsStore.getDatapoints(agencyId); + } catch (error) { + showToast("Error fetching data.", false, "red", 4000); + } + }; + useEffect(() => { + fetchDatapoints(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (datapointsStore.loading) { + return <>Loading...; + } + + const metrics = Object.keys(datapointsStore.datapointsByMetric); + if (metrics.length === 0) { + return <>No published metrics.; + } + + return ( + <> + Click on a metric to view chart: + {Object.keys(datapointsStore.dimensionNamesByMetricAndDisaggregation).map( + (metricKey) => ( + { + navigate(`/agency/${agencyId}/dashboard?metric=${metricKey}`); + }} + > + {datapointsStore.metricKeyToDisplayName[metricKey] || metricKey} + + ) + )} + + ); +}; + +export default observer(AgencyOverview); diff --git a/agency-dashboard/src/App.tsx b/agency-dashboard/src/App.tsx index 6c026e5fa..88227a8f5 100644 --- a/agency-dashboard/src/App.tsx +++ b/agency-dashboard/src/App.tsx @@ -16,11 +16,18 @@ // ============================================================================= import React from "react"; +import { Route, Routes } from "react-router-dom"; -import { DashboardView } from "./DashboardView"; +import AgencyOverview from "./AgencyOverview"; +import DashboardView from "./DashboardView"; function App() { - return ; + return ( + + } /> + } /> + + ); } export default App; diff --git a/agency-dashboard/src/DashboardView.styles.tsx b/agency-dashboard/src/DashboardView.styles.tsx index e6d1f3800..291b274a5 100644 --- a/agency-dashboard/src/DashboardView.styles.tsx +++ b/agency-dashboard/src/DashboardView.styles.tsx @@ -15,14 +15,19 @@ // along with this program. If not, see . // ============================================================================= +import { typography } from "@justice-counts/common/components/GlobalStyles"; import styled from "styled-components/macro"; export const Container = styled.div` - height: 100%; + height: 800px; width: 100%; - position: absolute; + position: relative; display: flex; flex-direction: column; justify-content: stretch; align-items: stretch; `; + +export const MetricTitle = styled.div` + ${typography.sizeCSS.title} +`; diff --git a/agency-dashboard/src/DashboardView.test.tsx b/agency-dashboard/src/DashboardView.test.tsx new file mode 100644 index 000000000..2de920ee3 --- /dev/null +++ b/agency-dashboard/src/DashboardView.test.tsx @@ -0,0 +1,77 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; + +import DashboardView from "./DashboardView"; +import { StoreProvider } from "./stores"; + +beforeEach(() => { + fetchMock.resetMocks(); +}); + +// test("renders loading state", () => { +// fetchMock.mockResponseOnce( +// JSON.stringify({ +// datapoints: [{}], +// dimension_names_by_metric_and_disaggregation: { +// LAW_ENFORCEMENT_ARRESTS: {}, +// LAW_ENFORCEMENT_BUDGET: {}, +// LAW_ENFORCEMENT_CALLS_FOR_SERVICE: {}, +// }, +// }) +// ); + +// render( +// +// +// +// +// +// ); +// const loadingElement = screen.getByText(/Loading.../i); +// expect(loadingElement).toBeInTheDocument(); +// }); + +test("renders 'No reported data for this metric.' state", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + datapoints: [{}], + dimension_names_by_metric_and_disaggregation: { + LAW_ENFORCEMENT_ARRESTS: {}, + LAW_ENFORCEMENT_BUDGET: {}, + LAW_ENFORCEMENT_CALLS_FOR_SERVICE: {}, + }, + }) + ); + + render( + + + + + + ); + + const textElement = await screen.findByText(/LAW_ENFORCEMENT_ARRESTS/i); + expect(textElement).toBeInTheDocument(); +}); diff --git a/agency-dashboard/src/DashboardView.tsx b/agency-dashboard/src/DashboardView.tsx index 8c5dfe023..9106e0136 100644 --- a/agency-dashboard/src/DashboardView.tsx +++ b/agency-dashboard/src/DashboardView.tsx @@ -16,87 +16,68 @@ // ============================================================================= import { DatapointsView } from "@justice-counts/common/components/DataViz/DatapointsView"; -import { DatapointsGroupedByAggregateAndDisaggregations } from "@justice-counts/common/types"; -import React from "react"; +import { observer } from "mobx-react-lite"; +import React, { useEffect } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { Container } from "./DashboardView.styles"; +import { Container, MetricTitle } from "./DashboardView.styles"; +import { useStore } from "./stores"; -const exampleDatapointsGroupedByAggregateAndDisaggregations = { - aggregate: [ - { - Total: 39166, - start_date: "Wed, 01 Jan 2020 00:00:00 GMT", - end_date: "Fri, 01 Jan 2021 00:00:00 GMT", - frequency: "ANNUAL", - dataVizMissingData: 0, - }, - { - Total: 45905, - start_date: "Fri, 01 Jan 2021 00:00:00 GMT", - end_date: "Sat, 01 Jan 2022 00:00:00 GMT", - frequency: "ANNUAL", - dataVizMissingData: 0, - }, - { - Total: 65836, - start_date: "Sat, 01 Jan 2022 00:00:00 GMT", - end_date: "Sun, 01 Jan 2023 00:00:00 GMT", - frequency: "ANNUAL", - dataVizMissingData: 0, - }, - ], - disaggregations: { - "Correctional Facility Staff Type": { - "Wed, 01 Jan 2020 00:00:00 GMT": { - start_date: "Wed, 01 Jan 2020 00:00:00 GMT", - end_date: "Fri, 01 Jan 2021 00:00:00 GMT", - Unknown: 58386, - frequency: "ANNUAL", - dataVizMissingData: 0, - Other: 61476, - Support: 52531, - Security: 92124, - }, - "Fri, 01 Jan 2021 00:00:00 GMT": { - start_date: "Fri, 01 Jan 2021 00:00:00 GMT", - end_date: "Sat, 01 Jan 2022 00:00:00 GMT", - Security: 10560, - frequency: "ANNUAL", - dataVizMissingData: 0, - Support: 3163, - Other: null, - Unknown: null, - }, - "Sat, 01 Jan 2022 00:00:00 GMT": { - start_date: "Sat, 01 Jan 2022 00:00:00 GMT", - end_date: "Sun, 01 Jan 2023 00:00:00 GMT", - Other: null, - frequency: "ANNUAL", - dataVizMissingData: 0, - Security: 8664, - Support: 2975, - Unknown: null, - }, - }, - }, -} as DatapointsGroupedByAggregateAndDisaggregations; +const DashboardView = () => { + const navigate = useNavigate(); + const params = useParams(); + const agencyId = Number(params.id); + const { datapointsStore } = useStore(); -const exampleDimensionsNamesByDisaggregations = { - "Correctional Facility Staff Type": [ - "Security", - "Support", - "Other", - "Unknown", - ], + const { search } = useLocation(); + const query = new URLSearchParams(search); + const metricKey = query.get("metric"); + useEffect(() => { + datapointsStore.getDatapoints(agencyId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if ( + metricKey && + !datapointsStore.loading && + !datapointsStore.dimensionNamesByMetricAndDisaggregation[metricKey] + ) { + navigate(`/agency/${agencyId}`); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [datapointsStore.loading]); + + if ( + !metricKey || + (!datapointsStore.loading && + !datapointsStore.dimensionNamesByMetricAndDisaggregation[metricKey]) + ) { + return null; + } + + if (datapointsStore.loading) { + return <>Loading...; + } + + return ( + <> + + + {datapointsStore.metricKeyToDisplayName[metricKey] || metricKey} + + + + + ); }; -export const DashboardView = () => ( - - - -); +export default observer(DashboardView); diff --git a/agency-dashboard/src/index.tsx b/agency-dashboard/src/index.tsx index 72d577d9b..62903064e 100644 --- a/agency-dashboard/src/index.tsx +++ b/agency-dashboard/src/index.tsx @@ -18,8 +18,10 @@ import { GlobalStyle } from "@justice-counts/common/components/GlobalStyles"; import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import { StoreProvider } from "./stores"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement @@ -27,6 +29,10 @@ const root = ReactDOM.createRoot( root.render( - + + + + + ); diff --git a/agency-dashboard/src/react-app-env.d.ts b/agency-dashboard/src/react-app-env.d.ts index ac0d8866e..9daf6dc9f 100644 --- a/agency-dashboard/src/react-app-env.d.ts +++ b/agency-dashboard/src/react-app-env.d.ts @@ -14,3 +14,5 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . // ============================================================================= + +/// diff --git a/agency-dashboard/src/App.test.tsx b/agency-dashboard/src/setupProxy.js similarity index 75% rename from agency-dashboard/src/App.test.tsx rename to agency-dashboard/src/setupProxy.js index 43b4caf59..8ac0f311a 100644 --- a/agency-dashboard/src/App.test.tsx +++ b/agency-dashboard/src/setupProxy.js @@ -14,13 +14,15 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . // ============================================================================= -import { render, screen } from "@testing-library/react"; -import React from "react"; -import App from "./App"; +const { createProxyMiddleware } = require("http-proxy-middleware"); -test("renders dashboard", () => { - render(); - const linkElement = screen.getByText(/Date Range/i); - expect(linkElement).toBeInTheDocument(); -}); +module.exports = function (app) { + app.use( + ["/api"], + createProxyMiddleware({ + target: process.env.REACT_APP_PROXY_HOST, + changeOrigin: true, + }) + ); +}; diff --git a/agency-dashboard/src/setupTests.ts b/agency-dashboard/src/setupTests.ts index cda53d019..af939fd9e 100644 --- a/agency-dashboard/src/setupTests.ts +++ b/agency-dashboard/src/setupTests.ts @@ -16,6 +16,10 @@ // ============================================================================= import "@testing-library/jest-dom"; +import { enableFetchMocks } from "jest-fetch-mock"; + +enableFetchMocks(); + global.ResizeObserver = require("resize-observer-polyfill"); // polyfill for when running jest tests diff --git a/agency-dashboard/src/stores/DatapointsStore.ts b/agency-dashboard/src/stores/DatapointsStore.ts new file mode 100644 index 000000000..a14db0167 --- /dev/null +++ b/agency-dashboard/src/stores/DatapointsStore.ts @@ -0,0 +1,149 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +import { + DatapointsByMetric, + DataVizAggregateName, + DimensionNamesByMetricAndDisaggregation, + RawDatapoint, +} from "@justice-counts/common/types"; +import { isPositiveNumber } from "@justice-counts/common/utils"; +import { makeAutoObservable, runInAction } from "mobx"; + +import { request } from "../utils/networking"; + +class DatapointsStore { + rawDatapoints: RawDatapoint[]; + + dimensionNamesByMetricAndDisaggregation: DimensionNamesByMetricAndDisaggregation; + + loading: boolean; + + constructor() { + makeAutoObservable(this); + this.rawDatapoints = []; + this.dimensionNamesByMetricAndDisaggregation = {}; + this.loading = true; + } + + get metricKeyToDisplayName(): { [metricKey: string]: string | null } { + const mapping: { [metricKey: string]: string | null } = {}; + this.rawDatapoints.forEach((dp) => { + mapping[dp.metric_definition_key] = dp.metric_display_name; + }); + return mapping; + } + + /** + * Transforms raw data from the server into Datapoints keyed by metric, + * grouped by aggregate values and disaggregations. + * Aggregate is an array of objects each containing start_date, end_date, and the aggregate value. + * Disaggregations are keyed by disaggregation name and each value is an object + * with the key being the start_date and the value being an object + * containing start_date, end_date and key value pairs for each dimension and their values. + * See the DatapointsByMetric type for details. + */ + get datapointsByMetric(): DatapointsByMetric { + return this.rawDatapoints.reduce((res: DatapointsByMetric, dp) => { + if (!res[dp.metric_definition_key]) { + res[dp.metric_definition_key] = { + aggregate: [], + disaggregations: {}, + }; + } + + const sanitizedValue = + dp.value !== null && isPositiveNumber(dp.value) + ? Number(dp.value) + : null; + + if ( + dp.disaggregation_display_name === null || + dp.dimension_display_name === null + ) { + res[dp.metric_definition_key].aggregate.push({ + [DataVizAggregateName]: sanitizedValue, + start_date: dp.start_date, + end_date: dp.end_date, + frequency: dp.frequency, + dataVizMissingData: 0, + }); + } else { + if ( + !res[dp.metric_definition_key].disaggregations[ + dp.disaggregation_display_name + ] + ) { + res[dp.metric_definition_key].disaggregations[ + dp.disaggregation_display_name + ] = {}; + } + res[dp.metric_definition_key].disaggregations[ + dp.disaggregation_display_name + ][dp.start_date] = { + ...res[dp.metric_definition_key].disaggregations[ + dp.disaggregation_display_name + ][dp.start_date], + start_date: dp.start_date, + end_date: dp.end_date, + [dp.dimension_display_name]: sanitizedValue, + frequency: dp.frequency, + dataVizMissingData: 0, + }; + } + return res; + }, {}); + } + + async getDatapoints(agencyId: number): Promise { + try { + const response = (await request({ + path: `/api/agencies/${agencyId}/published_datapoints`, + method: "GET", + })) as Response; + if (response.status === 200) { + const result = await response.json(); + runInAction(() => { + this.rawDatapoints = result.datapoints; + this.dimensionNamesByMetricAndDisaggregation = + result.dimension_names_by_metric_and_disaggregation; + }); + } else { + const error = await response.json(); + throw new Error(error.description); + } + runInAction(() => { + this.loading = false; + }); + } catch (error) { + runInAction(() => { + this.loading = false; + }); + throw error; + } + } + + resetState() { + // reset the state + runInAction(() => { + this.rawDatapoints = []; + this.loading = true; + }); + } +} + +export default DatapointsStore; diff --git a/agency-dashboard/src/stores/RootStore.ts b/agency-dashboard/src/stores/RootStore.ts new file mode 100644 index 000000000..00b0eb602 --- /dev/null +++ b/agency-dashboard/src/stores/RootStore.ts @@ -0,0 +1,29 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= +import DatapointsStore from "./DatapointsStore"; + +class RootStore { + datapointsStore: DatapointsStore; + + constructor() { + this.datapointsStore = new DatapointsStore(); + } +} + +export default new RootStore(); + +export type { RootStore }; diff --git a/agency-dashboard/src/stores/StoreProvider.tsx b/agency-dashboard/src/stores/StoreProvider.tsx new file mode 100644 index 000000000..404401c62 --- /dev/null +++ b/agency-dashboard/src/stores/StoreProvider.tsx @@ -0,0 +1,42 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +import React, { useContext } from "react"; + +import rootStore from "./RootStore"; + +const StoreContext = React.createContext( + undefined +); + +export const StoreProvider: React.FC = ({ + children, +}): React.ReactElement => { + return ( + {children} + ); +}; + +export function useStore(): typeof rootStore { + const context = useContext(StoreContext); + + if (context === undefined) { + throw new Error("useStore must be used within a StoreProvider"); + } + + return context; +} diff --git a/agency-dashboard/src/stores/index.ts b/agency-dashboard/src/stores/index.ts new file mode 100644 index 000000000..0534b7850 --- /dev/null +++ b/agency-dashboard/src/stores/index.ts @@ -0,0 +1,20 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +export * from "./DatapointsStore"; +export { default as rootStore } from "./RootStore"; +export * from "./StoreProvider"; diff --git a/agency-dashboard/src/utils/networking.ts b/agency-dashboard/src/utils/networking.ts new file mode 100644 index 000000000..d096309a4 --- /dev/null +++ b/agency-dashboard/src/utils/networking.ts @@ -0,0 +1,46 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 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 . +// ============================================================================= + +interface RequestProps { + path: string; + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + body?: FormData | Record; +} + +export const request = async ({ + path, + method, + body, +}: RequestProps): Promise => { + // Files are sent as FormData and not JSON + const jsonOrFormDataBody = + body instanceof FormData ? body : JSON.stringify(body); + + const headers: HeadersInit = {}; + + if (!(body instanceof FormData)) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(path, { + body: method !== "GET" ? jsonOrFormDataBody : null, + method, + headers, + }); + + return response; +}; diff --git a/publisher/src/components/assets/status-check-white-icon.png b/common/assets/status-check-white-icon.png similarity index 100% rename from publisher/src/components/assets/status-check-white-icon.png rename to common/assets/status-check-white-icon.png diff --git a/publisher/src/components/Toast/Toast.ts b/common/components/Toast/Toast.ts similarity index 98% rename from publisher/src/components/Toast/Toast.ts rename to common/components/Toast/Toast.ts index 15fae52b5..d6e5a7bd9 100644 --- a/publisher/src/components/Toast/Toast.ts +++ b/common/components/Toast/Toast.ts @@ -21,7 +21,7 @@ import { typography, } from "@justice-counts/common/components/GlobalStyles"; -import checkIconWhite from "../assets/status-check-white-icon.png"; +import checkIconWhite from "../../assets/status-check-white-icon.png"; type ToastColor = "blue" | "red" | "grey"; diff --git a/publisher/src/components/Toast/index.ts b/common/components/Toast/index.ts similarity index 100% rename from publisher/src/components/Toast/index.ts rename to common/components/Toast/index.ts diff --git a/common/utils/index copy.ts b/common/index.d.ts similarity index 89% rename from common/utils/index copy.ts rename to common/index.d.ts index 3d8962b73..4d7a74852 100644 --- a/common/utils/index copy.ts +++ b/common/index.d.ts @@ -15,6 +15,4 @@ // along with this program. If not, see . // ============================================================================= -export * from "./conversionUtils"; -export * from "./dateUtils"; -export * from "./helperUtils"; +declare module "*.png"; diff --git a/common/tsconfig.json b/common/tsconfig.json index c37853f2e..b640d44b5 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "@recidiviz/tsconfig/react" + "extends": "@recidiviz/tsconfig/react", + "include": ["index.d.ts"] /// <-- Like this!! } diff --git a/publisher/package.json b/publisher/package.json index 137612a34..aee9b2c5d 100644 --- a/publisher/package.json +++ b/publisher/package.json @@ -22,8 +22,7 @@ "react-router-dom": "^6", "recharts": "^2.1.13", "styled-components": "^5.3.6", - "typescript": "^4.4.2", - "web-vitals": "^2.1.0" + "typescript": "^4.4.2" }, "scripts": { "dev": "react-app-rewired start", diff --git a/publisher/src/components/DataUpload/DataUpload.tsx b/publisher/src/components/DataUpload/DataUpload.tsx index f33d25d8c..1a982db80 100644 --- a/publisher/src/components/DataUpload/DataUpload.tsx +++ b/publisher/src/components/DataUpload/DataUpload.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { AgencySystems } from "@justice-counts/common/types"; import { observer } from "mobx-react-lite"; import React, { useEffect, useState } from "react"; @@ -24,7 +25,6 @@ import { useStore } from "../../stores"; import logoImg from "../assets/jc-logo-vector.png"; import { Logo, LogoContainer } from "../Header"; import { Loader } from "../Loading"; -import { showToast } from "../Toast"; import { Button, DataUploadContainer, diff --git a/publisher/src/components/DataUpload/UploadFile.tsx b/publisher/src/components/DataUpload/UploadFile.tsx index d43d6d3ca..b766915d3 100644 --- a/publisher/src/components/DataUpload/UploadFile.tsx +++ b/publisher/src/components/DataUpload/UploadFile.tsx @@ -15,12 +15,12 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { AgencySystems } from "@justice-counts/common/types"; import React, { Fragment, useEffect, useRef, useState } from "react"; import { removeSnakeCase } from "../../utils"; import { ReactComponent as FileIcon } from "../assets/file-icon.svg"; -import { showToast } from "../Toast"; import { DragDropContainer, GeneralInstructions, diff --git a/publisher/src/components/DataUpload/UploadedFiles.tsx b/publisher/src/components/DataUpload/UploadedFiles.tsx index 131185c04..f50eca860 100644 --- a/publisher/src/components/DataUpload/UploadedFiles.tsx +++ b/publisher/src/components/DataUpload/UploadedFiles.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { Permission } from "@justice-counts/common/types"; import { when } from "mobx"; import { observer } from "mobx-react-lite"; @@ -25,7 +26,6 @@ import { removeSnakeCase } from "../../utils"; import downloadIcon from "../assets/download-icon.png"; import { Badge, BadgeColorMapping, BadgeColors } from "../Badge"; import { Loader } from "../Loading"; -import { showToast } from "../Toast"; import { ActionButton, ActionsContainer, diff --git a/publisher/src/components/MetricsView/MetricsView.tsx b/publisher/src/components/MetricsView/MetricsView.tsx index 3d756030a..a298aa8c9 100644 --- a/publisher/src/components/MetricsView/MetricsView.tsx +++ b/publisher/src/components/MetricsView/MetricsView.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { AgencySystems, FormError, @@ -43,7 +44,6 @@ import { } from "../Forms"; import { Loading } from "../Loading"; import { PageTitle, TabbedBar, TabbedItem, TabbedOptions } from "../Reports"; -import { showToast } from "../Toast"; import { ActiveMetricSettingHeader, Dimension, diff --git a/publisher/src/components/Onboarding/Onboarding.tsx b/publisher/src/components/Onboarding/Onboarding.tsx index 48179b0bd..564c6f56d 100644 --- a/publisher/src/components/Onboarding/Onboarding.tsx +++ b/publisher/src/components/Onboarding/Onboarding.tsx @@ -19,6 +19,7 @@ import { palette, typography, } from "@justice-counts/common/components/GlobalStyles"; +import { showToast } from "@justice-counts/common/components/Toast"; import React, { useEffect, useRef, useState } from "react"; import styled, { keyframes } from "styled-components/macro"; @@ -30,7 +31,6 @@ import { SIDE_PANEL_WIDTH, TWO_PANEL_MAX_WIDTH, } from "../Reports/ReportDataEntry.styles"; -import { showToast } from "../Toast"; export const OnboardingContainer = styled.div` width: 100vw; diff --git a/publisher/src/components/Reports/CreateReport.tsx b/publisher/src/components/Reports/CreateReport.tsx index e5bb40b8f..a9e5d3b95 100644 --- a/publisher/src/components/Reports/CreateReport.tsx +++ b/publisher/src/components/Reports/CreateReport.tsx @@ -19,6 +19,7 @@ import { palette, typography, } from "@justice-counts/common/components/GlobalStyles"; +import { showToast } from "@justice-counts/common/components/Toast"; import { CreateReportFormValuesType, ReportOverview, @@ -45,7 +46,6 @@ import { TitleWrapper, } from "../Forms"; import { Dropdown } from "../Forms/Dropdown"; -import { showToast } from "../Toast"; import { PublishButton, PublishDataWrapper, diff --git a/publisher/src/components/Reports/DataEntryForm.tsx b/publisher/src/components/Reports/DataEntryForm.tsx index 9343825b0..08edde940 100644 --- a/publisher/src/components/Reports/DataEntryForm.tsx +++ b/publisher/src/components/Reports/DataEntryForm.tsx @@ -20,6 +20,7 @@ import { palette, typography, } from "@justice-counts/common/components/GlobalStyles"; +import { showToast } from "@justice-counts/common/components/Toast"; import { reaction, runInAction } from "mobx"; import { observer } from "mobx-react-lite"; import React, { Fragment, useEffect, useRef, useState } from "react"; @@ -56,7 +57,6 @@ import { Title, } from "../Forms"; import { Onboarding, OnboardingDataEntrySummary } from "../Onboarding"; -import { showToast } from "../Toast"; import { AdditionalContextInput, BinaryRadioButtonInputs, diff --git a/publisher/src/components/Reports/PublishConfirmation.tsx b/publisher/src/components/Reports/PublishConfirmation.tsx index c31a95327..dd0cff86b 100644 --- a/publisher/src/components/Reports/PublishConfirmation.tsx +++ b/publisher/src/components/Reports/PublishConfirmation.tsx @@ -16,6 +16,7 @@ // ============================================================================= import { palette } from "@justice-counts/common/components/GlobalStyles"; +import { showToast } from "@justice-counts/common/components/Toast"; import { MetricContextWithErrors, MetricDisaggregationDimensionsWithErrors, @@ -32,7 +33,6 @@ import { useStore } from "../../stores"; import { printReportTitle, rem } from "../../utils"; import errorIcon from "../assets/status-error-icon.png"; import { Button } from "../Forms"; -import { showToast } from "../Toast"; import { PublishButton } from "./ReportDataEntry.styles"; const CONTAINER_WIDTH = 912; diff --git a/publisher/src/components/Reports/ReportDataEntry.tsx b/publisher/src/components/Reports/ReportDataEntry.tsx index b01081395..eaabd9daf 100644 --- a/publisher/src/components/Reports/ReportDataEntry.tsx +++ b/publisher/src/components/Reports/ReportDataEntry.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { Report } from "@justice-counts/common/types"; import { when } from "mobx"; import { observer } from "mobx-react-lite"; @@ -26,7 +27,6 @@ import { useStore } from "../../stores"; import { printReportTitle } from "../../utils"; import { PageWrapper } from "../Forms"; import { Loading } from "../Loading"; -import { showToast } from "../Toast"; import DataEntryForm from "./DataEntryForm"; import PublishConfirmation from "./PublishConfirmation"; import PublishDataPanel from "./PublishDataPanel"; diff --git a/publisher/src/stores/API.ts b/publisher/src/stores/API.ts index 8f67b89d4..ebef14124 100644 --- a/publisher/src/stores/API.ts +++ b/publisher/src/stores/API.ts @@ -15,11 +15,11 @@ // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { makeAutoObservable, runInAction, when } from "mobx"; import { trackLoadTime, trackNetworkError } from "../analytics"; import { AuthStore } from "../components/Auth"; -import { showToast } from "../components/Toast"; export interface RequestProps { path: string; diff --git a/publisher/src/stores/DatapointsStore.ts b/publisher/src/stores/DatapointsStore.ts index 5b9e9fb19..bdd78d202 100644 --- a/publisher/src/stores/DatapointsStore.ts +++ b/publisher/src/stores/DatapointsStore.ts @@ -165,6 +165,9 @@ class DatapointsStore { const error = await response.json(); throw new Error(error.description); } + runInAction(() => { + this.loading = false; + }); } catch (error) { runInAction(() => { this.loading = false; diff --git a/publisher/src/stores/UserStore.ts b/publisher/src/stores/UserStore.ts index 71219903e..9e55d1768 100644 --- a/publisher/src/stores/UserStore.ts +++ b/publisher/src/stores/UserStore.ts @@ -14,12 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . // ============================================================================= +import { showToast } from "@justice-counts/common/components/Toast"; import { UserAgency } from "@justice-counts/common/types"; import { makeAutoObservable, runInAction, when } from "mobx"; import { makePersistable } from "mobx-persist-store"; import { APP_METADATA_CLAIM, AuthStore } from "../components/Auth"; -import { showToast } from "../components/Toast"; import API from "./API"; type UserSettingsRequestBody = { diff --git a/yarn.lock b/yarn.lock index cba05187a..0d6168439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10351,11 +10351,6 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" -web-vitals@^2.1.0, web-vitals@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c" - integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg== - webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"