diff --git a/common/components/DataViz/DatapointsView.styles.tsx b/common/components/DataViz/DatapointsView.styles.tsx index 8d3d7ca10..d43f79815 100644 --- a/common/components/DataViz/DatapointsView.styles.tsx +++ b/common/components/DataViz/DatapointsView.styles.tsx @@ -88,6 +88,7 @@ export const DatapointsViewControlsContainer = styled.div` display: flex; flex-direction: row; border-bottom: 1px solid ${palette.highlight.grey9}; + border-top: 1px solid ${palette.highlight.grey9}; `; const DatapointsViewDropdown = styled(Dropdown)` diff --git a/common/types.ts b/common/types.ts index 426f7eaa7..69a4ba0c6 100644 --- a/common/types.ts +++ b/common/types.ts @@ -81,6 +81,15 @@ export type MetricDisaggregationDimensionsWithErrors = error?: string; }; +export type MetricConfigurationSettingsOptions = "Yes" | "No" | "N/A"; + +export type MetricConfigurationSettings = { + key: string; + label: string; + included: MetricConfigurationSettingsOptions; + default: MetricConfigurationSettingsOptions; +}; + export interface Metric { key: string; system: AgencySystems; @@ -95,6 +104,8 @@ export interface Metric { contexts: MetricContext[]; disaggregations: MetricDisaggregations[]; enabled?: boolean; + settings?: MetricConfigurationSettings[]; + frequency?: ReportFrequency; } export interface MetricDefinition { @@ -107,7 +118,7 @@ export interface MetricContext { display_name: string | null | undefined; reporting_note: string | null | undefined; required: boolean; - type: "TEXT" | "NUMBER" | "MULTIPLE_CHOICE"; + type: "TEXT" | "NUMBER" | "MULTIPLE_CHOICE" | "BOOLEAN"; value: string | number | boolean | null | undefined; multiple_choice_options: string[]; } @@ -127,6 +138,8 @@ export interface MetricDisaggregationDimensions { value: string | number | boolean | null | undefined; reporting_note: string; enabled?: boolean; + settings?: MetricConfigurationSettings[]; + display_name?: string; } export interface CreateReportFormValuesType extends Record { diff --git a/publisher/src/App.tsx b/publisher/src/App.tsx index e367f83a7..6b1c85e00 100644 --- a/publisher/src/App.tsx +++ b/publisher/src/App.tsx @@ -23,12 +23,12 @@ import { trackNavigation } from "./analytics"; import { DataUpload } from "./components/DataUpload"; import { PageWrapper } from "./components/Forms"; import Header from "./components/Header"; -import { MetricsView } from "./components/MetricsView"; +import { MetricsView } from "./components/MetricConfiguration/MetricsView"; import CreateReports from "./components/Reports/CreateReport"; import ReportDataEntry from "./components/Reports/ReportDataEntry"; import ReviewMetrics from "./components/ReviewMetrics/ReviewMetrics"; -import AccountSettings from "./pages/AccountSettings"; import Reports from "./pages/Reports"; +import Settings from "./pages/Settings"; const App: React.FC = (): ReactElement => { const location = useLocation(); @@ -43,10 +43,10 @@ const App: React.FC = (): ReactElement => { } /> + } /> } /> } /> - } /> - } /> + } /> } /> } /> diff --git a/publisher/src/components/Badge/Badge.tsx b/publisher/src/components/Badge/Badge.tsx index 9f05021ec..12f6be440 100644 --- a/publisher/src/components/Badge/Badge.tsx +++ b/publisher/src/components/Badge/Badge.tsx @@ -28,11 +28,13 @@ export type BadgeProps = { color: BadgeColors; disabled?: boolean; loading?: boolean; + noMargin?: boolean; }; export const BadgeElement = styled.div<{ color?: BadgeColors; disabled?: boolean; + noMargin?: boolean; }>` height: 24px; display: flex; @@ -40,7 +42,7 @@ export const BadgeElement = styled.div<{ align-items: center; background: ${({ color, disabled }) => { if (color === "GREY" || disabled) { - return palette.highlight.grey9; + return palette.highlight.grey8; } if (color === "RED") { return palette.solid.red; @@ -55,21 +57,22 @@ export const BadgeElement = styled.div<{ }}; color: ${palette.solid.white}; padding: 4px 8px; - margin-left: 10px; font-size: 0.65rem; font-weight: 600; white-space: nowrap; text-transform: capitalize; + ${({ noMargin }) => !noMargin && `margin-left: 10px;`}; `; export const Badge: React.FC> = ({ color, disabled, loading, + noMargin, children, }) => { return ( - + {children} {loading && } diff --git a/publisher/src/components/DataUpload/DataUpload.styles.tsx b/publisher/src/components/DataUpload/DataUpload.styles.tsx index 7560f1afd..9b006378c 100644 --- a/publisher/src/components/DataUpload/DataUpload.styles.tsx +++ b/publisher/src/components/DataUpload/DataUpload.styles.tsx @@ -517,19 +517,14 @@ export const ConfirmationPageContainer = styled.div` align-items: center; `; -export const UploadedFilesContainer = styled.div` - max-height: 50vh; - overflow-y: scroll; -`; +export const UploadedFilesContainer = styled.div``; export const UploadedFilesWrapper = styled.div` - margin-top: 50px; position: relative; `; export const UploadedFilesTable = styled(Table)` padding: unset; - max-height: 40vh; overflow-y: scroll; padding-bottom: 50px; `; diff --git a/publisher/src/components/DataUpload/UploadedFiles.tsx b/publisher/src/components/DataUpload/UploadedFiles.tsx index f50eca860..408995015 100644 --- a/publisher/src/components/DataUpload/UploadedFiles.tsx +++ b/publisher/src/components/DataUpload/UploadedFiles.tsx @@ -25,6 +25,7 @@ import { useStore } from "../../stores"; import { removeSnakeCase } from "../../utils"; import downloadIcon from "../assets/download-icon.png"; import { Badge, BadgeColorMapping, BadgeColors } from "../Badge"; +import { Title, TitleWrapper } from "../Forms"; import { Loader } from "../Loading"; import { ActionButton, @@ -33,6 +34,7 @@ import { ExtendedCell, ExtendedLabelCell, ExtendedLabelRow, + ExtendedOpacityGradient, ExtendedRow, UploadedFile, UploadedFilesContainer, @@ -40,6 +42,7 @@ import { UploadedFilesLoading, UploadedFilesTable, UploadedFileStatus, + UploadedFilesWrapper, } from "."; export const UploadedFileRow: React.FC<{ @@ -315,26 +318,35 @@ export const UploadedFiles: React.FC = observer(() => { } return ( - - - {dataUploadColumnTitles.map((title) => ( - {title} - ))} - - - {uploadedFiles.map((fileDetails) => { - const fileRowDetails = getFileRowDetails(fileDetails); - - return ( - - ); - })} - - + + + Uploaded Files + + + + + {dataUploadColumnTitles.map((title) => ( + {title} + ))} + + + + {uploadedFiles.map((fileDetails) => { + const fileRowDetails = getFileRowDetails(fileDetails); + + return ( + + ); + })} + + + + + ); }); diff --git a/publisher/src/components/Forms/BinaryRadioButton.tsx b/publisher/src/components/Forms/BinaryRadioButton.tsx index 2a6625446..95519810d 100644 --- a/publisher/src/components/Forms/BinaryRadioButton.tsx +++ b/publisher/src/components/Forms/BinaryRadioButton.tsx @@ -90,9 +90,8 @@ export const RadioButtonLabel = styled.label<{ display: flex; justify-content: center; align-items: center; - background: ${palette.highlight.grey1}; padding: 16px 24px; - border: 1px solid ${palette.highlight.grey3}; + border: 1px solid ${palette.highlight.grey4}; border-radius: 2px; transition: 0.2s ease; diff --git a/publisher/src/components/Forms/NotReportedIcon.tsx b/publisher/src/components/Forms/NotReportedIcon.tsx index fa357ed42..7b06ba4c1 100644 --- a/publisher/src/components/Forms/NotReportedIcon.tsx +++ b/publisher/src/components/Forms/NotReportedIcon.tsx @@ -117,8 +117,8 @@ export const NotReportedIcon: React.FC<{ This has been disabled by an admin because the data is unavailable.{" "} If you have the data for this, consider changing the configuration in the{" "} - navigate("/metrics")}> - Metrics View + navigate("/settings")}> + Settings . diff --git a/publisher/src/components/Menu/Menu.tsx b/publisher/src/components/Menu/Menu.tsx index b0f40a933..8c138bb9a 100644 --- a/publisher/src/components/Menu/Menu.tsx +++ b/publisher/src/components/Menu/Menu.tsx @@ -37,7 +37,7 @@ enum MenuItems { LearnMore = "LEARN MORE", Settings = "SETTINGS", Agencies = "AGENCIES", - Metrics = "METRICS", + Data = "DATA", } const Menu = () => { @@ -77,8 +77,8 @@ const Menu = () => { setActiveMenuItem(MenuItems.CreateReport); } else if (location.pathname === "/settings") { setActiveMenuItem(MenuItems.Settings); - } else if (location.pathname === "/metrics") { - setActiveMenuItem(MenuItems.Metrics); + } else if (location.pathname === "/data") { + setActiveMenuItem(MenuItems.Data); } else { setActiveMenuItem(undefined); } @@ -92,14 +92,6 @@ const Menu = () => { `Welcome, ${userStore.nameOrEmail} at ${userStore.currentAgency.name}`} - {/* Metrics View */} - navigate("/metrics")} - active={activeMenuItem === MenuItems.Metrics} - > - Metrics - - {/* Reports */} navigate("/")} @@ -108,6 +100,14 @@ const Menu = () => { Reports + {/* Data (Visualizations) */} + navigate("/data")} + active={activeMenuItem === MenuItems.Data} + > + Data + + {/* Learn More */} . +// ============================================================================= + +import React, { useEffect } from "react"; + +import { + Metric, + MetricDisaggregationDimensions, + MetricDisaggregations as MetricDisaggregationsType, +} from "../../shared/types"; +import { removeSnakeCase } from "../../utils"; +import { ReactComponent as RightArrowIcon } from "../assets/right-arrow.svg"; +import blueCheck from "../assets/status-check-icon.png"; +import { BinaryRadioButton } from "../Forms"; +import { TabbedBar, TabbedItem, TabbedOptions } from "../Reports"; +import { + BlueCheckIcon, + BreakdownHeader, + Checkbox, + CheckboxWrapper, + Dimension, + DimensionTitle, + DimensionTitleWrapper, + Disaggregation, + DisaggregationTab, + Header, + MetricConfigurationContainer, + MetricDisaggregations, + MetricOnOffWrapper, + MetricSettings, + RadioButtonGroupWrapper, + Subheader, +} from "."; + +type MetricConfigurationProps = { + activeMetricKey: string; + filteredMetricSettings: { [key: string]: Metric }; + activeDimension: MetricDisaggregationDimensions | undefined; + activeDisaggregation: MetricDisaggregationsType | undefined; + setActiveDisaggregation: React.Dispatch< + React.SetStateAction + >; + saveAndUpdateMetricSettings: ( + typeOfUpdate: "METRIC" | "DISAGGREGATION" | "DIMENSION" | "CONTEXT", + updatedSetting: MetricSettings, + debounce?: boolean + ) => void; + setActiveDimension: React.Dispatch< + React.SetStateAction + >; +}; + +export const Configuration: React.FC = ({ + activeMetricKey, + filteredMetricSettings, + activeDimension, + activeDisaggregation, + setActiveDisaggregation, + saveAndUpdateMetricSettings, + setActiveDimension, +}): JSX.Element => { + const metricDisplayName = + filteredMetricSettings[activeMetricKey]?.display_name; + const metricEnabled = Boolean( + filteredMetricSettings[activeMetricKey]?.enabled + ); + + useEffect( + () => { + const updatedDisaggregation = + activeDisaggregation && + filteredMetricSettings[activeMetricKey]?.disaggregations?.find( + (disaggregation) => disaggregation.key === activeDisaggregation.key + ); + + if (updatedDisaggregation) + return setActiveDisaggregation(updatedDisaggregation); + setActiveDisaggregation( + filteredMetricSettings[activeMetricKey]?.disaggregations?.[0] + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filteredMetricSettings] + ); + + useEffect( + () => setActiveDimension(undefined), + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeMetricKey] + ); + + return ( + + +
+ Are you currently able to report any part of this metric? +
+ + Answering “No” means that {metricDisplayName} will not appear on + automatically generated reports from here on out. You can change this + later. + + + + saveAndUpdateMetricSettings("METRIC", { + key: activeMetricKey, + enabled: true, + }) + } + /> + + saveAndUpdateMetricSettings("METRIC", { + key: activeMetricKey, + enabled: false, + }) + } + /> + +
+ + {filteredMetricSettings[activeMetricKey]?.disaggregations.length > 0 && ( + + Breakdowns + + Mark (using the checkmark) each of the breakdowns below that your + agency will be able to report. Click the arrow to edit the + definition for each metric. + + + + + {activeDisaggregation && + filteredMetricSettings[activeMetricKey]?.disaggregations?.map( + (disaggregation) => ( + { + setActiveDimension(disaggregation.dimensions[0]); + setActiveDisaggregation(disaggregation); + }} + selected={disaggregation.key === activeDisaggregation.key} + capitalize + > + + + {removeSnakeCase( + disaggregation.display_name.toLowerCase() + )} + + + + + saveAndUpdateMetricSettings("DISAGGREGATION", { + key: activeMetricKey, + disaggregations: [ + { + key: disaggregation.key, + enabled: !disaggregation.enabled, + }, + ], + }) + } + /> + + + + + ) + )} + + + + + {activeDisaggregation?.dimensions.map((dimension) => { + return ( + setActiveDimension(dimension)} + > + + { + saveAndUpdateMetricSettings("DIMENSION", { + key: activeMetricKey, + disaggregations: [ + { + key: activeDisaggregation.key, + dimensions: [ + { + key: dimension.key, + enabled: !dimension.enabled, + }, + ], + }, + ], + }); + }} + /> + + + + + + {dimension.label} + + + + + + ); + })} + + + )} +
+ ); +}; diff --git a/publisher/src/components/MetricConfiguration/ContextConfiguration.tsx b/publisher/src/components/MetricConfiguration/ContextConfiguration.tsx new file mode 100644 index 000000000..a2c518d06 --- /dev/null +++ b/publisher/src/components/MetricConfiguration/ContextConfiguration.tsx @@ -0,0 +1,218 @@ +// 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, { useEffect, useState } from "react"; + +import { FormError, MetricContext } from "../../shared/types"; +import { isPositiveNumber, removeCommaSpaceAndTrim } from "../../utils"; +import { + BinaryRadioButton, + BinaryRadioGroupClearButton, + BinaryRadioGroupContainer, + BinaryRadioGroupQuestion, + TextInput, +} from "../Forms"; +import { + Label, + MetricContextContainer, + MetricContextItem, + MetricSettings, + MetricSettingsUpdateOptions, + MultipleChoiceWrapper, + RadioButtonGroupWrapper, + Subheader, +} from "."; + +type MetricContextConfigurationProps = { + metricKey: string; + contexts: MetricContext[]; + saveAndUpdateMetricSettings: ( + typeOfUpdate: MetricSettingsUpdateOptions, + updatedSetting: MetricSettings, + debounce?: boolean + ) => void; +}; + +export const ContextConfiguration: React.FC< + MetricContextConfigurationProps +> = ({ metricKey, contexts, saveAndUpdateMetricSettings }) => { + const [contextErrors, setContextErrors] = useState<{ + [key: string]: FormError; + }>(); + + const contextNumberValidation = (key: string, value: string) => { + const cleanValue = removeCommaSpaceAndTrim(value); + + if (!isPositiveNumber(cleanValue) && cleanValue !== "") { + setContextErrors({ + [key]: { + message: "Please enter a valid number.", + }, + }); + + return false; + } + + setContextErrors((prev) => { + const otherContextErrors = { ...prev }; + delete otherContextErrors[key]; + + return otherContextErrors; + }); + return true; + }; + + useEffect(() => { + if (contexts) { + contexts.forEach((context) => { + if (context.type === "NUMBER") { + contextNumberValidation(context.key, (context.value || "") as string); + } + }); + } + }, [contexts]); + + return ( + + + Anything entered here will appear as the default value for all reports. + If you are entering data for a particular month, you can still replace + this as necessary. + + + {contexts?.map((context) => ( + + {context.type === "BOOLEAN" && ( + <> + + + + saveAndUpdateMetricSettings("CONTEXT", { + key: metricKey, + contexts: [{ key: context.key, value: "yes" }], + }) + } + /> + + saveAndUpdateMetricSettings("CONTEXT", { + key: metricKey, + contexts: [{ key: context.key, value: "no" }], + }) + } + /> + + + saveAndUpdateMetricSettings("CONTEXT", { + key: metricKey, + contexts: [{ key: context.key, value: "" }], + }) + } + > + Clear Input + + + )} + + {(context.type === "TEXT" || context.type === "NUMBER") && ( + <> + + { + if (context.type === "NUMBER") { + contextNumberValidation(context.key, e.currentTarget.value); + } + + saveAndUpdateMetricSettings( + "CONTEXT", + { + key: metricKey, + contexts: [ + { key: context.key, value: e.currentTarget.value }, + ], + }, + true + ); + }} + /> + + )} + + {context.type === "MULTIPLE_CHOICE" && ( + + + {context.display_name} + + + + {context.multiple_choice_options?.map((option) => ( + + saveAndUpdateMetricSettings("CONTEXT", { + key: metricKey, + contexts: [{ key: context.key, value: option }], + }) + } + /> + ))} + + + + saveAndUpdateMetricSettings("CONTEXT", { + key: metricKey, + contexts: [{ key: context.key, value: "" }], + }) + } + > + Clear Input + + + )} + + ))} + + ); +}; diff --git a/publisher/src/components/MetricConfiguration/MetricBox.tsx b/publisher/src/components/MetricConfiguration/MetricBox.tsx new file mode 100644 index 000000000..d178f8878 --- /dev/null +++ b/publisher/src/components/MetricConfiguration/MetricBox.tsx @@ -0,0 +1,60 @@ +// 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 from "react"; + +import { ReportFrequency } from "../../shared/types"; +import { Badge } from "../Badge"; +import { + MetricBoxContainer, + MetricDescription, + MetricName, + MetricNameBadgeWrapper, +} from "."; + +type MetricBoxProps = { + metricKey: string; + displayName: string; + frequency: ReportFrequency; + description: string; + enabled?: boolean; + setActiveMetricKey: React.Dispatch>; +}; + +export const MetricBox: React.FC = ({ + metricKey, + displayName, + frequency, + description, + enabled, + setActiveMetricKey, +}): JSX.Element => { + return ( + setActiveMetricKey(metricKey)} + enabled={enabled} + > + {displayName} + {description} + + + {!enabled ? "Inactive" : frequency.toLowerCase()} + + + + ); +}; diff --git a/publisher/src/components/MetricConfiguration/MetricConfiguration.styles.tsx b/publisher/src/components/MetricConfiguration/MetricConfiguration.styles.tsx new file mode 100644 index 000000000..0bfeaf9b9 --- /dev/null +++ b/publisher/src/components/MetricConfiguration/MetricConfiguration.styles.tsx @@ -0,0 +1,637 @@ +// 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 styled from "styled-components/macro"; + +import { BinaryRadioGroupWrapper, Button } from "../Forms"; +import { palette, typography } from "../GlobalStyles"; + +const METRICS_VIEW_CONTAINER_BREAKPOINT = 1200; + +export const MetricsViewContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + overflow: hidden; + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + overflow: unset; + } +`; + +export const MetricsViewControlPanel = styled.div` + height: 100%; + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + overflow-y: scroll; + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + flex-direction: column; + flex-wrap: nowrap; + justify-content: unset; + } +`; + +export const MetricsViewControlPanelOverflowHidden = styled( + MetricsViewControlPanel +)` + overflow-y: hidden; +`; + +export const PanelContainerLeft = styled.div` + width: 35%; + height: 100%; + overflow: scroll; + padding: 10px 15px 0 15px; + + @media only screen and (max-width: 1000px) { + margin-right: 50px; + } +`; + +export const PanelContainerRight = styled.div` + width: 65%; + height: 100%; + display: flex; + position: relative; + flex-direction: column; + overflow-y: scroll; +`; + +export const MetricBoxBottomPaddingContainer = styled.div` + display: flex; + flex-wrap: wrap; + padding-bottom: 100px; + overflow-y: scroll; +`; + +type MetricBoxContainerProps = { + enabled?: boolean; +}; + +export const MetricBoxContainer = styled.div` + min-height: 150px; + max-width: 50%; + display: flex; + flex: 1 1 50%; + flex-direction: column; + justify-content: space-between; + border: 1px solid ${palette.highlight.grey2}; + padding: 27px 24px; + transition: 0.2s ease; + color: ${({ enabled }) => + enabled ? palette.solid.darkgrey : palette.highlight.grey10}; + + &:hover { + cursor: pointer; + border: 1px solid ${palette.solid.blue}; + } + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + width: 100%; + max-width: unset; + flex: unset; + } +`; + +export const MetricViewBoxContainer = styled(MetricBoxContainer)<{ + selected?: boolean; +}>` + max-width: 100%; + min-height: 50px; + border: ${({ selected }) => selected && `1px solid ${palette.solid.blue}`}; + margin-bottom: 5px; +`; + +export const MetricBoxWrapper = styled.div` + display: flex; +`; + +export const ActiveMetricSettingHeader = styled.div` + position: relative; + z-index: 1; + background: ${palette.solid.white}; + padding: 10px 15px 0 15px; + margin-bottom: 20px; +`; + +export const MetricNameBadgeToggleWrapper = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 8px; +`; + +export const MetricNameBadgeWrapper = styled.div` + display: flex; + align-items: center; +`; + +export const Metric = styled.div<{ inView: boolean }>` + width: 100%; + display: flex; + gap: 20px; + align-items: center; + justify-content: flex-start; + border-bottom: 1px solid ${palette.solid.darkgrey}; + padding: 12px; + position: relative; + background: ${({ inView }) => + inView ? palette.highlight.lightblue1 : `none`}; + + &:hover { + background: ${palette.highlight.grey1}; + cursor: pointer; + } + + svg { + position: absolute; + opacity: ${({ inView }) => (inView ? `1` : `0`)}; + right: ${({ inView }) => (inView ? `13px` : `-20px`)}; + transition: opacity 0.2s ease, right 0.3s ease; + } + + &:hover svg { + display: block; + right: 13px; + opacity: 1; + } +`; + +type MetricNameProps = { isTitle?: boolean }; + +export const MetricName = styled.div` + ${({ isTitle }) => + isTitle ? typography.sizeCSS.title : typography.sizeCSS.large} +`; + +export const MetricDescription = styled.div` + ${typography.sizeCSS.normal} + height: 100%; + margin: 11px 0; + + @media only screen and (max-width: 1000px) { + ${typography.sizeCSS.small} + } +`; + +export const MetricDetailsDisplay = styled.div` + width: 100%; + overflow-y: scroll; + padding: 24px 12px 50px 0; + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + overflow-y: unset; + padding: 24px 12px 10px 0; + } +`; + +export const MetricOnOffWrapper = styled.div` + margin-bottom: 24px; +`; + +export const Header = styled.div` + ${typography.sizeCSS.medium}; + margin-bottom: 8px; +`; + +export const BreakdownHeader = styled(Header)` + padding-top: 24px; + border-top: 1px solid ${palette.highlight.grey5}; +`; + +export const Subheader = styled.div` + ${typography.sizeCSS.normal}; + margin-bottom: 9px; +`; + +export const RadioButtonGroupWrapper = styled(BinaryRadioGroupWrapper)` + display: flex; +`; + +export const MetricDisaggregations = styled.div<{ enabled?: boolean }>` + display: block; + position: relative; + + ${({ enabled }) => + !enabled && + ` + &::after { + content: ''; + position: absolute; + background: ${palette.solid.white}; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 2; + opacity: 0.5; + } + `} +`; + +export const Disaggregation = styled.div` + display: block; + margin-bottom: 15px; +`; + +export const DisaggregationHeader = styled.div` + display: flex; + justify-content: space-between; + padding: 17px 0; + align-items: center; + + border-bottom: 1px solid ${palette.highlight.grey9}; +`; + +export const DisaggregationName = styled.div<{ enabled?: boolean }>` + ${typography.sizeCSS.large}; + + color: ${({ enabled }) => + enabled ? palette.solid.darkgrey : palette.highlight.grey8}; +`; + +export const DisaggregationTab = styled.div` + display: flex; + justify-content: center; + align-items: center; + + span { + padding-right: 8px; + } +`; + +export const Dimension = styled.div<{ enabled?: boolean; inView?: boolean }>` + ${typography.sizeCSS.medium}; + display: flex; + align-items: center; + gap: 12px; + padding: 17px 10px; + border-bottom: 1px solid ${palette.highlight.grey4}; + position: relative; + background: ${({ inView }) => + inView ? palette.highlight.lightblue1 : `none`}; + + &:hover { + background: ${palette.highlight.grey1}; + cursor: pointer; + } + + svg { + position: absolute; + opacity: ${({ inView }) => (inView ? `1` : `0`)}; + right: ${({ inView }) => (inView ? `13px` : `-20px`)}; + transition: opacity 0.2s ease, right 0.3s ease; + } + + &:hover svg { + display: block; + right: 13px; + opacity: 1; + } + + &:last-child { + border-bottom: none; + } + + ${({ enabled }) => + !enabled && + ` + &::after { + content: ''; + position: absolute; + background: ${palette.solid.white}; + height: 100%; + width: 100%; + top: 0; + left: 0; + opacity: 0.5; + } + `} +`; + +export const DimensionTitleWrapper = styled.div` + display: flex; + align-items: center; +`; + +export const DimensionTitle = styled.div<{ enabled?: boolean }>` + display: block; + color: ${({ enabled }) => + enabled ? palette.solid.darkgrey : palette.highlight.grey8}; +`; + +export const CheckboxWrapper = styled.div` + display: flex; + position: relative; + z-index: 1; +`; + +export const Checkbox = styled.input` + appearance: none; + width: 20px; + height: 20px; + background: transparent; + border: 1px solid ${palette.highlight.grey6}; + border-radius: 100%; + + &:hover { + cursor: pointer; + } + + &:checked { + border: 1px solid transparent; + } + + &:checked + img { + display: block; + } +`; + +export const BlueCheckIcon = styled.img<{ enabled?: boolean }>` + width: 20px; + display: none; + position: absolute; + top: 0; + left: 0; + z-index: -1; +`; + +export const MetricConfigurationContainer = styled.div` + display: block; +`; + +export const MetricContextContainer = styled.div` + display: block; +`; + +export const MetricContextItem = styled.div` + margin-top: 33px; +`; + +export const Label = styled.div<{ noBottomMargin?: boolean }>` + ${typography.sizeCSS.medium}; + margin-bottom: ${({ noBottomMargin }) => (noBottomMargin ? 0 : `16px`)}; +`; + +export const ToggleSwitchWrapper = styled.div` + display: flex; + align-items: center; + padding: 8px 0; +`; + +export const ToggleSwitch = styled.label` + position: relative; + display: inline-block; + width: 38px; + height: 24px; +`; + +export const ToggleSwitchInput = styled.input` + opacity: 0; + width: 0; + height: 0; + + &:checked + span { + background-color: ${palette.solid.blue}; + } + + &:checked + span:before { + transform: translateX(14px); + } +`; + +export const Slider = styled.span` + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: ${palette.solid.grey}; + border-radius: 34px; + transition: 0.3s; + + &:before { + content: ""; + height: 14px; + width: 14px; + position: absolute; + left: 5px; + bottom: 5px; + background-color: ${palette.solid.white}; + border-radius: 50%; + transition: 0.3s; + } +`; + +export const ToggleSwitchLabel = styled.span<{ switchedOn?: boolean }>` + ${typography.sizeCSS.normal} + color: ${({ switchedOn }) => + switchedOn ? palette.solid.blue : palette.solid.grey}; + text-transform: uppercase; + margin-right: 11px; + position: relative; + + &::after { + content: "${({ switchedOn }) => (switchedOn ? "ON" : "OFF")}"; + position: absolute; + top: -11px; + left: -27px; + } +`; + +export const MultipleChoiceWrapper = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + div { + &:nth-child(odd) { + margin: 15px 10px 0 0; + } + + width: 90%; + flex: 40%; + } +`; + +export const MetricSettingsDisplayError = styled.div` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + margin-top: 50px; +`; + +export const StickyHeader = styled.div` + width: 100%; + position: sticky; + top: 0; + background: ${palette.solid.white}; + margin-bottom: 29px; +`; + +export const BackToMetrics = styled.div` + color: ${palette.solid.blue}; + transition: 0.2s ease; + margin-bottom: 24px; + + &:hover { + cursor: pointer; + opacity: 0.85; + } +`; + +export const MetricConfigurationDisplay = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1 1 45%; +`; + +export const MetricConfigurationWrapper = styled.div` + height: 100%; + width: 100%; + display: flex; + justify-content: space-between; + overflow-y: hidden; + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + flex-direction: column; + } +`; + +export const DefinitionsDisplayContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 55%; + padding: 48px 12px 50px 126px; + overflow-y: scroll; + + @media only screen and (max-width: ${METRICS_VIEW_CONTAINER_BREAKPOINT}px) { + border-top: 1px solid ${palette.highlight.grey3}; + padding: 30px 0 50px 0; + overflow-y: unset; + margin-right: 12px; + } +`; + +export const DefinitionsDisplay = styled.div` + width: 100%; +`; + +export const DefinitionsTitle = styled.div` + ${typography.sizeCSS.large} + margin-bottom: 24px; +`; + +export const DefinitionsSubTitle = styled.div` + ${typography.sizeCSS.medium} + margin-bottom: 16px; +`; + +export const DefinitionsDescription = styled.div` + ${typography.sizeCSS.normal} + margin-bottom: 32px; + + span { + display: block; + color: ${palette.solid.orange}; + } +`; + +export const RevertToDefaultButton = styled(Button)` + ${typography.sizeCSS.normal} + background: ${palette.solid.white}; + height: unset; + padding: 9px 0; +`; + +export const Definitions = styled.div` + width: 100%; + display: flex; + flex-direction: column; + margin-top: 16px; + margin-bottom: 32px; +`; + +export const DefinitionItem = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +`; + +export const DefinitionDisplayName = styled.div` + ${typography.sizeCSS.medium} + margin-right: 8px; +`; + +export const DefinitionSelection = styled.div` + display: flex; + gap: 4px; +`; + +export const DefinitionMiniButton = styled(RevertToDefaultButton)<{ + selected?: boolean; + showDefault?: boolean; +}>` + width: unset; + padding: 9px 16px; + + ${({ selected }) => + selected && + ` + color: ${palette.solid.white}; + background: ${palette.highlight.grey9}; + + &:hover { + background: ${palette.highlight.grey9}; + opacity: 0.9; + } + + &:nth-child(3) { + background: ${palette.solid.blue}; + + &:hover { + opacity: 0.9; + } + } + + + `}; + + ${({ showDefault, selected }) => + showDefault && !selected && `color: ${palette.highlight.grey4};`}; +`; + +export const NoDefinitionsSelected = styled.div` + width: 100%; + height: fit-content; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 59px; + border: 1px solid ${palette.highlight.grey5}; + color: ${palette.highlight.grey12}; + text-align: center; +`; diff --git a/publisher/src/components/MetricConfiguration/MetricConfiguration.tsx b/publisher/src/components/MetricConfiguration/MetricConfiguration.tsx new file mode 100644 index 000000000..534c56388 --- /dev/null +++ b/publisher/src/components/MetricConfiguration/MetricConfiguration.tsx @@ -0,0 +1,596 @@ +// 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 { debounce as _debounce } from "lodash"; +import { reaction, when } from "mobx"; +import { observer } from "mobx-react-lite"; +import React, { useEffect, useRef, useState } from "react"; + +import { ListOfMetricsForNavigation } from "../../pages/Settings"; +import { + Metric as MetricType, + MetricConfigurationSettings, + MetricDisaggregationDimensions, + MetricDisaggregations, + ReportFrequency, +} from "../../shared/types"; +import { useStore } from "../../stores"; +import { removeSnakeCase } from "../../utils"; +import { ReactComponent as RightArrowIcon } from "../assets/right-arrow.svg"; +import { Badge } from "../Badge"; +import { Loading } from "../Loading"; +import { TabbedBar, TabbedItem, TabbedOptions } from "../Reports"; +import { showToast } from "../Toast"; +import { + BackToMetrics, + Configuration, + Metric, + MetricBox, + MetricBoxBottomPaddingContainer, + MetricConfigurationDisplay, + MetricConfigurationWrapper, + MetricDefinitions, + MetricDetailsDisplay, + MetricName, + MetricsViewContainer, + MetricsViewControlPanel, + StickyHeader, +} from "."; + +export type MetricSettingsUpdateOptions = + | "METRIC" + | "DISAGGREGATION" + | "DIMENSION" + | "CONTEXT" + | "METRIC_SETTING" + | "DIMENSION_SETTING"; + +export type MetricSettings = { + key: string; + enabled?: boolean; + settings?: MetricConfigurationSettings[]; + contexts?: { + key: string; + value: string; + }[]; + disaggregations?: { + key: string; + enabled?: boolean; + dimensions?: { + key: string; + enabled?: boolean; + settings?: MetricConfigurationSettings[]; + }[]; + }[]; +}; + +export type MetricSettingsObj = { + [key: string]: MetricType; +}; + +export const MetricConfiguration: React.FC<{ + activeMetricKey: string | undefined; + setActiveMetricKey: React.Dispatch>; + setListOfMetrics: React.Dispatch< + React.SetStateAction + >; +}> = observer(({ activeMetricKey, setActiveMetricKey, setListOfMetrics }) => { + const { reportStore, userStore } = useStore(); + + const [isLoading, setIsLoading] = useState(true); + const [loadingError, setLoadingError] = useState(); + const [activeMetricFilter, setActiveMetricFilter] = useState(); + const [metricSettings, setMetricSettings] = useState<{ + [key: string]: MetricType; + }>({}); + const [activeDimension, setActiveDimension] = + useState(); + + const filteredMetricSettings: MetricSettingsObj = Object.values( + metricSettings + ) + .filter( + (metric) => + metric.system.toLowerCase() === activeMetricFilter?.toLowerCase() + ) + ?.reduce((res: MetricSettingsObj, metric) => { + res[metric.key] = metric; + return res; + }, {}); + + const [activeDisaggregation, setActiveDisaggregation] = + useState(); + + useEffect( + () => { + /** Updates shared state `listOfMetrics` so the SettingsMenu component can render the metric navigation */ + const listOfMetricsForMetricNavigation = Object.values( + filteredMetricSettings + ).map((metric) => { + return { + key: metric.key, + display_name: metric.display_name, + }; + }); + + setListOfMetrics(listOfMetricsForMetricNavigation); + + /** Update activeDimension when settings are updated */ + if (activeDimension && activeMetricKey) { + return setActiveDimension((prev) => { + return filteredMetricSettings[activeMetricKey].disaggregations + .find( + (disaggregation) => + disaggregation.key === activeDisaggregation?.key + ) + ?.dimensions.find((dimension) => dimension.key === prev?.key); + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filteredMetricSettings] + ); + + const updateMetricSettings = ( + typeOfUpdate: MetricSettingsUpdateOptions, + updatedSetting: MetricSettings + ) => { + setMetricSettings((prev) => { + const metricKey = updatedSetting.key; + + if (typeOfUpdate === "METRIC") { + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + enabled: Boolean(updatedSetting.enabled), + }, + }; + } + + if (typeOfUpdate === "METRIC_SETTING") { + let updatedSettingsArray; + + if ( + prev[metricKey].settings?.length === updatedSetting.settings?.length + ) { + updatedSettingsArray = updatedSetting.settings; + } else { + updatedSettingsArray = prev[metricKey].settings?.map((setting) => { + if (setting.key === updatedSetting.settings?.[0].key) { + return { + ...setting, + included: updatedSetting.settings[0].included, + }; + } + return setting; + }); + } + + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + settings: updatedSettingsArray, + }, + }; + } + + if (typeOfUpdate === "DISAGGREGATION") { + const updatedDisaggregations = prev[metricKey].disaggregations.map( + (disaggregation) => { + /** Quick Note: for now, all updates happen one at a time thus leaving + * one item in the disaggregations/dimensions/contexts arrays that will + * be updated at any one time. We can update this in the future to handle + * updating multiple settings at one time if necessary. + */ + if ( + disaggregation.key === updatedSetting.disaggregations?.[0].key + ) { + /** + * When disaggregation is switched off, all dimensions are disabled. + * When disaggregation is switched on, all dimensions are enabled. + */ + if (!updatedSetting.disaggregations?.[0].enabled) { + return { + ...disaggregation, + enabled: false, + dimensions: disaggregation.dimensions.map((dimension) => { + return { + ...dimension, + enabled: false, + }; + }), + }; + } + return { + ...disaggregation, + enabled: Boolean(updatedSetting.disaggregations?.[0].enabled), + dimensions: disaggregation.dimensions.map((dimension) => { + return { + ...dimension, + enabled: true, + }; + }), + }; + } + return disaggregation; + } + ); + + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + disaggregations: updatedDisaggregations, + }, + }; + } + + if (typeOfUpdate === "DIMENSION") { + const updatedDisaggregations = prev[metricKey].disaggregations.map( + (disaggregation) => { + if ( + disaggregation.key === updatedSetting.disaggregations?.[0].key + ) { + const lastDimensionDisabled = + disaggregation.dimensions.filter( + (dimension) => dimension.enabled + )?.length === 1; + + /** Disable disaggregation when last dimension toggle is switched off */ + if ( + !updatedSetting.disaggregations?.[0].dimensions?.[0].enabled && + lastDimensionDisabled + ) { + return { + ...disaggregation, + enabled: false, + dimensions: disaggregation.dimensions.map((dimension) => { + if ( + dimension.key === + updatedSetting.disaggregations?.[0].dimensions?.[0].key + ) { + return { + ...dimension, + enabled: Boolean( + updatedSetting.disaggregations?.[0].dimensions?.[0] + .enabled + ), + }; + } + return dimension; + }), + }; + } + + return { + ...disaggregation, + enabled: true, + dimensions: disaggregation.dimensions.map((dimension) => { + if ( + dimension.key === + updatedSetting.disaggregations?.[0].dimensions?.[0].key + ) { + return { + ...dimension, + enabled: Boolean( + updatedSetting.disaggregations?.[0].dimensions?.[0] + .enabled + ), + }; + } + return dimension; + }), + }; + } + return disaggregation; + } + ); + + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + disaggregations: updatedDisaggregations, + }, + }; + } + + if (typeOfUpdate === "DIMENSION_SETTING") { + const updatedDisaggregations = prev[metricKey].disaggregations.map( + (disaggregation) => { + if ( + disaggregation.key !== updatedSetting.disaggregations?.[0].key + ) { + return disaggregation; + } + + return { + ...disaggregation, + dimensions: disaggregation.dimensions.map((dimension) => { + if ( + dimension.key !== + updatedSetting.disaggregations?.[0].dimensions?.[0].key + ) { + return dimension; + } + + let updatedSettingsArray; + + if ( + dimension.settings?.length === + updatedSetting.disaggregations?.[0].dimensions?.[0].settings + ?.length + ) { + updatedSettingsArray = + updatedSetting.disaggregations?.[0].dimensions?.[0] + .settings; + } else { + updatedSettingsArray = dimension.settings?.map((setting) => { + if ( + setting.key !== + updatedSetting.disaggregations?.[0].dimensions?.[0] + .settings?.[0].key + ) { + return setting; + } + return { + ...setting, + included: + updatedSetting.disaggregations?.[0].dimensions?.[0] + .settings[0].included, + }; + }); + } + + return { + ...dimension, + settings: updatedSettingsArray, + }; + }), + }; + } + ); + + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + disaggregations: updatedDisaggregations, + }, + }; + } + + if (typeOfUpdate === "CONTEXT") { + const updatedContext = prev[metricKey].contexts.map((context) => { + if (context.key === updatedSetting.contexts?.[0].key) { + return { + ...context, + value: updatedSetting.contexts?.[0].value, + }; + } + return context; + }); + + return { + ...prev, + [updatedSetting.key]: { + ...prev[metricKey], + contexts: updatedContext, + }, + }; + } + + return prev; + }); + }; + + const saveMetricSettings = async (updatedSetting: MetricSettings) => { + const response = (await reportStore.updateReportSettings([ + updatedSetting, + ])) as Response; + + if (response.status === 200) { + showToast(`Settings saved.`, true, "grey", 4000); + } else { + showToast(`Failed to save.`, true, "red", 4000); + } + }; + + const debouncedSave = useRef(_debounce(saveMetricSettings, 1500)).current; + + const saveAndUpdateMetricSettings = ( + typeOfUpdate: MetricSettingsUpdateOptions, + updatedSetting: MetricSettings, + debounce?: boolean + ) => { + updateMetricSettings(typeOfUpdate, updatedSetting); + if (debounce) { + debouncedSave(updatedSetting); + } else { + saveMetricSettings(updatedSetting); + } + }; + + const fetchAndSetReportSettings = async () => { + const response = (await reportStore.getReportSettings()) as + | Response + | Error; + + setIsLoading(false); + + if (response instanceof Error) { + return setLoadingError(response.message); + } + + const reportSettings = (await response.json()) as MetricType[]; + const metricKeyToMetricMap: { [key: string]: MetricType } = {}; + + reportSettings?.forEach((metric) => { + metricKeyToMetricMap[metric.key] = metric; + }); + + setMetricSettings(metricKeyToMetricMap); + }; + + useEffect( + () => + // return when's disposer so it is cleaned up if it never runs + when( + () => userStore.userInfoLoaded, + async () => { + fetchAndSetReportSettings(); + setActiveMetricFilter( + removeSnakeCase(userStore.currentAgency?.systems[0] as string) + ); + } + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // reload metric overviews when the current agency ID changes + useEffect( + () => + // return disposer so it is cleaned up if it never runs + reaction( + () => userStore.currentAgencyId, + async (currentAgencyId, previousAgencyId) => { + if (previousAgencyId !== undefined) { + setIsLoading(true); + fetchAndSetReportSettings(); + setActiveMetricFilter( + removeSnakeCase(userStore.currentAgency?.systems[0] as string) + ); + setActiveMetricKey(undefined); + } + } + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [userStore] + ); + + if (isLoading) { + return ; + } + + if (loadingError) { + return
Error: {loadingError}
; + } + + return ( + <> + + {!activeMetricKey && + userStore.currentAgency?.systems && + userStore.currentAgency?.systems?.length > 1 && ( + + + + {userStore.currentAgency?.systems.map((filterOption) => ( + + setActiveMetricFilter(removeSnakeCase(filterOption)) + } + capitalize + > + {removeSnakeCase(filterOption.toLowerCase())} + + ))} + + + + )} + + + {/* List Of Metrics */} + {filteredMetricSettings && !activeMetricKey && ( + + {Object.values(filteredMetricSettings).map((metric) => ( + + ))} + + )} + + {/* Metric Configuration */} + {activeMetricKey && ( + + + { + setActiveMetricKey(undefined); + setActiveDimension(undefined); + }} + > + ← Back to Metrics + + + setActiveDimension(undefined)} + inView={!activeDimension} + > + + {metricSettings[activeMetricKey]?.display_name} + + + {metricSettings[activeMetricKey]?.frequency?.toLowerCase()} + + + + + + + + + + {/* Metric/Dimension Definitions (Includes/Excludes) */} + + + )} + + + + ); +}); diff --git a/publisher/src/components/MetricConfiguration/MetricDefinitions.tsx b/publisher/src/components/MetricConfiguration/MetricDefinitions.tsx new file mode 100644 index 000000000..9bbf4852f --- /dev/null +++ b/publisher/src/components/MetricConfiguration/MetricDefinitions.tsx @@ -0,0 +1,239 @@ +// 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, { Fragment, useState } from "react"; + +import { + Metric, + MetricConfigurationSettingsOptions, + MetricContext, + MetricDisaggregationDimensions, + MetricDisaggregations, +} from "../../shared/types"; +import { + ContextConfiguration, + DefinitionDisplayName, + DefinitionItem, + DefinitionMiniButton, + Definitions, + DefinitionsDescription, + DefinitionsDisplay, + DefinitionsDisplayContainer, + DefinitionSelection, + DefinitionsSubTitle, + DefinitionsTitle, + MetricSettings, + MetricSettingsUpdateOptions, + RevertToDefaultButton, +} from "."; + +type MetricDefinitionsProps = { + activeMetricKey: string; + activeMetric: Metric; + activeDimension?: MetricDisaggregationDimensions | undefined; + activeDisaggregation: MetricDisaggregations | undefined; + contexts: MetricContext[]; + saveAndUpdateMetricSettings: ( + typeOfUpdate: MetricSettingsUpdateOptions, + updatedSetting: MetricSettings, + debounce?: boolean + ) => void; +}; + +export const MetricDefinitions: React.FC = ({ + activeMetricKey, + activeMetric, + activeDimension, + activeDisaggregation, + contexts, + saveAndUpdateMetricSettings, +}) => { + const [showDefaultSettings, setShowDefaultSettings] = useState(false); + + const selectionOptions: MetricConfigurationSettingsOptions[] = [ + "N/A", + "No", + "Yes", + ]; + const activeDimensionOrMetric: MetricDisaggregationDimensions | Metric = + activeDimension || activeMetric; + + const isMetricSettings = ( + dimensionOrMetric: MetricDisaggregationDimensions | Metric + ): dimensionOrMetric is Metric => { + return (dimensionOrMetric as Metric).display_name !== undefined; + }; + + const activeSettings = isMetricSettings(activeDimensionOrMetric) + ? activeMetric.settings + : activeDimension?.settings; + const defaultSettings = activeSettings?.map((setting) => ({ + ...setting, + included: setting.default, + })); + + const revertToDefaultValues = () => { + if (isMetricSettings(activeDimensionOrMetric)) { + return saveAndUpdateMetricSettings("METRIC_SETTING", { + key: activeMetricKey, + settings: defaultSettings, + }); + } + + if (activeDisaggregation && activeDimension) { + saveAndUpdateMetricSettings("DIMENSION_SETTING", { + key: activeMetricKey, + disaggregations: [ + { + key: activeDisaggregation.key, + dimensions: [ + { + key: activeDimension.key, + settings: defaultSettings, + }, + ], + }, + ], + }); + } + }; + + return ( + + + + {isMetricSettings(activeDimensionOrMetric) + ? activeDimensionOrMetric.display_name + : activeDimensionOrMetric.label} + + + {Boolean(activeSettings?.length) && ( + <> + Definitions + + Indicate which of the following categories your agency considers + to be part of this metric or breakdown. + + You are NOT required to gather data for these specific + categories. + + + + { + setShowDefaultSettings(false); + revertToDefaultValues(); + }} + onMouseEnter={() => + !showDefaultSettings && setShowDefaultSettings(true) + } + onMouseLeave={() => setShowDefaultSettings(false)} + > + Choose Default Definition + + + + {(showDefaultSettings ? defaultSettings : activeSettings)?.map( + (setting) => ( + + + {setting.label} + + + + {selectionOptions.map((option) => ( + + { + if (isMetricSettings(activeDimensionOrMetric)) { + return saveAndUpdateMetricSettings( + "METRIC_SETTING", + { + key: activeMetricKey, + settings: [ + { ...setting, included: option }, + ], + } + ); + } + + const activeDimensionKey = + activeDimensionOrMetric.key; + + return ( + activeDisaggregation && + saveAndUpdateMetricSettings( + "DIMENSION_SETTING", + { + key: activeMetricKey, + disaggregations: [ + { + key: activeDisaggregation.key, + dimensions: [ + { + key: activeDimensionKey, + settings: [ + { + ...setting, + included: option, + }, + ], + }, + ], + }, + ], + } + ) + ); + }} + > + {option} + + + ))} + + + ) + )} + + + )} + + {/* Display when user is viewing a dimension & there are no settings available */} + {!activeSettings?.length && activeDimension && ( + + This breakdown has no customizations available yet. + + )} + + + {/* Additional Context (only appears on overall metric settings and not individual dimension settings) */} + {!activeDimension && ( + <> + Context + + + )} + + ); +}; diff --git a/publisher/src/components/MetricConfiguration/MetricsView.tsx b/publisher/src/components/MetricConfiguration/MetricsView.tsx new file mode 100644 index 000000000..170679ee5 --- /dev/null +++ b/publisher/src/components/MetricConfiguration/MetricsView.tsx @@ -0,0 +1,265 @@ +// 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 { reaction, when } from "mobx"; +import { observer } from "mobx-react-lite"; +import React, { useEffect, useState } from "react"; + +import { Metric, ReportFrequency } from "../../shared/types"; +import { useStore } from "../../stores"; +import { removeSnakeCase } from "../../utils"; +import { Badge, BadgeColorMapping } from "../Badge"; +import ConnectedDatapointsView from "../DataViz/DatapointsView"; +import { NotReportedIcon } from "../Forms"; +import { Loading } from "../Loading"; +import { PageTitle, TabbedBar, TabbedItem, TabbedOptions } from "../Reports"; +import { + ActiveMetricSettingHeader, + MetricBoxWrapper, + MetricDescription, + MetricName, + MetricNameBadgeToggleWrapper, + MetricNameBadgeWrapper, + MetricSettingsObj, + MetricsViewContainer, + MetricsViewControlPanelOverflowHidden, + MetricViewBoxContainer, + PanelContainerLeft, + PanelContainerRight, +} from "."; + +type MetricBoxProps = { + metricKey: string; + displayName: string; + frequency: ReportFrequency; + description: string; + enabled?: boolean; + activeMetricKey: string; + setActiveMetricKey: React.Dispatch>; +}; + +const reportFrequencyBadgeColors: BadgeColorMapping = { + ANNUAL: "ORANGE", + MONTHLY: "GREEN", +}; + +const MetricBox: React.FC = ({ + metricKey, + displayName, + frequency, + description, + enabled, + activeMetricKey, + setActiveMetricKey, +}): JSX.Element => { + return ( + setActiveMetricKey(metricKey)} + enabled={enabled} + selected={metricKey === activeMetricKey} + > + + + {displayName} + + {frequency} + + + + {!enabled && } + + + {description} + + ); +}; + +export const MetricsView: React.FC = observer(() => { + const { reportStore, userStore, datapointsStore } = useStore(); + + const [activeMetricFilter, setActiveMetricFilter] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [loadingError, setLoadingError] = useState( + undefined + ); + const [activeMetricKey, setActiveMetricKey] = useState(""); + const [metricSettings, setMetricSettings] = useState<{ + [key: string]: Metric; + }>({}); + + const filteredMetricSettings: MetricSettingsObj = Object.values( + metricSettings + ) + .filter( + (metric) => + metric.system.toLowerCase() === activeMetricFilter?.toLowerCase() + ) + ?.reduce((res: MetricSettingsObj, metric) => { + res[metric.key] = metric; + return res; + }, {}); + + const fetchAndSetReportSettings = async () => { + const response = (await reportStore.getReportSettings()) as + | Response + | Error; + + setIsLoading(false); + + if (response instanceof Error) { + return setLoadingError(response.message); + } + + const reportSettings = (await response.json()) as Metric[]; + const metricKeyToMetricMap: { [key: string]: Metric } = {}; + + reportSettings?.forEach((metric) => { + metricKeyToMetricMap[metric.key] = metric; + }); + + setMetricSettings(metricKeyToMetricMap); + setActiveMetricKey(Object.keys(metricKeyToMetricMap)[0]); + }; + + useEffect( + () => + // return when's disposer so it is cleaned up if it never runs + when( + () => userStore.userInfoLoaded, + async () => { + fetchAndSetReportSettings(); + datapointsStore.getDatapoints(); + setActiveMetricFilter( + removeSnakeCase(userStore.currentAgency?.systems[0] as string) + ); + } + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // reload report overviews when the current agency ID changes + useEffect( + () => + // return disposer so it is cleaned up if it never runs + reaction( + () => userStore.currentAgencyId, + async (currentAgencyId, previousAgencyId) => { + // prevents us from calling getDatapoints twice on initial load + if (previousAgencyId !== undefined) { + setIsLoading(true); + fetchAndSetReportSettings(); + await datapointsStore.getDatapoints(); + setActiveMetricFilter( + removeSnakeCase(userStore.currentAgency?.systems[0] as string) + ); + } + } + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [userStore] + ); + + useEffect( + () => { + setActiveMetricKey(Object.keys(filteredMetricSettings)[0]); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeMetricFilter] + ); + + if (isLoading) { + return ; + } + + if (!metricSettings[activeMetricKey]) { + return
Error: {loadingError}
; + } + + return ( + <> + + Metrics + + + + {userStore.currentAgency?.systems.map((filterOption) => ( + + setActiveMetricFilter(removeSnakeCase(filterOption)) + } + capitalize + > + {removeSnakeCase(filterOption.toLowerCase())} + + ))} + + + + + {/* List Of Metrics */} + + {filteredMetricSettings && + Object.values(filteredMetricSettings).map((metric) => ( + + + + ))} + + + {/* Data Visualization */} + + + + + {filteredMetricSettings[activeMetricKey]?.display_name} + + {filteredMetricSettings[activeMetricKey]?.frequency && ( + + {filteredMetricSettings[activeMetricKey].frequency} + + )} + + + + + + + + + ); +}); diff --git a/publisher/src/components/MetricConfiguration/index.ts b/publisher/src/components/MetricConfiguration/index.ts new file mode 100644 index 000000000..06d7cc5eb --- /dev/null +++ b/publisher/src/components/MetricConfiguration/index.ts @@ -0,0 +1,23 @@ +// 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 "./Configuration"; +export * from "./ContextConfiguration"; +export * from "./MetricBox"; +export * from "./MetricConfiguration"; +export * from "./MetricConfiguration.styles"; +export * from "./MetricDefinitions"; diff --git a/publisher/src/components/MetricsView/MetricsView.styles.tsx b/publisher/src/components/MetricsView/MetricsView.styles.tsx deleted file mode 100644 index da9286c6e..000000000 --- a/publisher/src/components/MetricsView/MetricsView.styles.tsx +++ /dev/null @@ -1,333 +0,0 @@ -// 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, - typography, -} from "@justice-counts/common/components/GlobalStyles"; -import styled from "styled-components/macro"; - -import { BinaryRadioGroupWrapper } from "../Forms"; - -export const MetricsViewContainer = styled.div` - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; -`; - -export const MetricsViewControlPanel = styled.div` - height: calc(100% - 170px); - width: 100%; - display: flex; - justify-content: space-between; -`; - -export const PanelContainerLeft = styled.div` - width: 35%; - height: 100%; - overflow: scroll; - padding: 10px 15px 0 15px; - - @media only screen and (max-width: 1000px) { - margin-right: 50px; - } -`; - -export const PanelContainerRight = styled.div` - width: 65%; - height: 100%; - display: flex; - position: relative; - flex-direction: column; -`; - -type MetricBoxContainerProps = { - enabled?: boolean; - selected?: boolean; -}; - -export const MetricBoxContainer = styled.div` - display: flex; - flex-direction: column; - border: 1px solid - ${({ selected }) => - selected ? palette.solid.blue : palette.highlight.grey2}; - border-radius: 12px; - padding: 15px; - margin-bottom: 11px; - transition: 0.2s ease; - color: ${({ enabled }) => - enabled ? palette.solid.darkgrey : palette.highlight.grey7}; - ${({ selected }) => - selected && `box-shadow: 0px 4px 10px ${palette.highlight.blue};`} - - &:hover { - cursor: pointer; - ${({ selected }) => - !selected && `border: 1px solid ${palette.highlight.lightblue2}`}; - } -`; - -export const MetricBoxWrapper = styled.div` - display: block; -`; - -export const ActiveMetricSettingHeader = styled.div` - position: relative; - z-index: 1; - background: ${palette.solid.white}; - padding: 10px 15px 0 15px; -`; - -export const MetricNameBadgeToggleWrapper = styled.div` - display: flex; - justify-content: space-between; - margin-bottom: 8px; -`; - -export const MetricNameBadgeWrapper = styled.div` - display: flex; - align-items: center; -`; - -type MetricNameProps = { isTitle?: boolean }; - -export const MetricName = styled.div` - ${({ isTitle }) => - isTitle ? typography.sizeCSS.title : typography.sizeCSS.medium} -`; - -export const MetricDescription = styled.div` - ${typography.sizeCSS.normal} - color: ${palette.highlight.grey9}; - - @media only screen and (max-width: 1000px) { - ${typography.sizeCSS.small} - } -`; - -export const MetricDetailsDisplay = styled.div` - width: 100%; - overflow-y: scroll; - padding: 24px 15px 0 15px; -`; - -export const MetricOnOffWrapper = styled.div` - margin-bottom: 49px; -`; - -export const Header = styled.div` - ${typography.sizeCSS.medium}; - margin-bottom: 16px; -`; - -export const Subheader = styled.div` - ${typography.sizeCSS.normal}; - color: ${palette.highlight.grey9}; - margin-bottom: 9px; -`; - -export const RadioButtonGroupWrapper = styled(BinaryRadioGroupWrapper)` - display: flex; -`; - -export const MetricDisaggregations = styled.div<{ enabled?: boolean }>` - display: block; - position: relative; - - ${({ enabled }) => - !enabled && - ` - &::after { - content: ''; - position: absolute; - background: ${palette.solid.white}; - height: 100%; - width: 100%; - top: 0; - opacity: 0.5; - } - `} -`; - -export const Disaggregation = styled.div` - display: block; - margin-bottom: 15px; -`; - -export const DisaggregationHeader = styled.div` - display: flex; - justify-content: space-between; - padding: 17px 0; - align-items: center; - - border-bottom: 1px solid ${palette.highlight.grey9}; -`; - -export const DisaggregationName = styled.div<{ enabled?: boolean }>` - ${typography.sizeCSS.large}; - - color: ${({ enabled }) => - enabled ? palette.solid.darkgrey : palette.highlight.grey8}; -`; - -export const Dimension = styled.div<{ enabled?: boolean }>` - ${typography.sizeCSS.medium}; - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 0; - border-bottom: 1px dashed ${palette.highlight.grey9}; - position: relative; - - &:last-child { - border-bottom: none; - } - - ${({ enabled }) => - !enabled && - ` - &::after { - content: ''; - position: absolute; - background: ${palette.solid.white}; - height: 100%; - width: 100%; - top: 0; - opacity: 0.5; - } - `} -`; - -export const DimensionTitleWrapper = styled.div` - display: flex; - align-items: center; -`; - -export const DimensionTitle = styled.div<{ enabled?: boolean }>` - display: block; - color: ${({ enabled }) => - enabled ? palette.solid.darkgrey : palette.highlight.grey8}; -`; - -export const MetricConfigurationContainer = styled.div` - display: block; -`; - -export const MetricContextContainer = styled.div` - display: block; -`; - -export const MetricContextItem = styled.div` - margin-top: 33px; -`; - -export const Label = styled.div<{ noBottomMargin?: boolean }>` - ${typography.sizeCSS.medium}; - margin-bottom: ${({ noBottomMargin }) => (noBottomMargin ? 0 : `16px`)}; -`; - -export const ToggleSwitchWrapper = styled.div` - display: flex; - align-items: center; - padding: 8px 0; -`; - -export const ToggleSwitch = styled.label` - position: relative; - display: inline-block; - width: 38px; - height: 24px; -`; - -export const ToggleSwitchInput = styled.input` - opacity: 0; - width: 0; - height: 0; - - &:checked + span { - background-color: ${palette.solid.blue}; - } - - &:checked + span:before { - transform: translateX(14px); - } -`; - -export const Slider = styled.span` - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: ${palette.solid.grey}; - border-radius: 34px; - transition: 0.3s; - - &:before { - content: ""; - height: 14px; - width: 14px; - position: absolute; - left: 5px; - bottom: 5px; - background-color: ${palette.solid.white}; - border-radius: 50%; - transition: 0.3s; - } -`; - -export const ToggleSwitchLabel = styled.span<{ switchedOn?: boolean }>` - ${typography.sizeCSS.normal} - color: ${({ switchedOn }) => - switchedOn ? palette.solid.blue : palette.solid.grey}; - text-transform: uppercase; - margin-right: 11px; - position: relative; - - &::after { - content: "${({ switchedOn }) => (switchedOn ? "ON" : "OFF")}"; - position: absolute; - top: -11px; - left: -27px; - } -`; - -export const MultipleChoiceWrapper = styled.div` - display: flex; - flex-wrap: wrap; - justify-content: space-between; - - div { - &:nth-child(odd) { - margin: 15px 10px 0 0; - } - - width: 90%; - flex: 40%; - } -`; - -export const MetricSettingsDisplayError = styled.div` - width: 100%; - height: 100%; - display: flex; - justify-content: center; - margin-top: 50px; -`; diff --git a/publisher/src/components/MetricsView/MetricsView.tsx b/publisher/src/components/MetricsView/MetricsView.tsx deleted file mode 100644 index a298aa8c9..000000000 --- a/publisher/src/components/MetricsView/MetricsView.tsx +++ /dev/null @@ -1,961 +0,0 @@ -// 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 { - AgencySystems, - FormError, - ReportFrequency, -} from "@justice-counts/common/types"; -import { debounce as _debounce } from "lodash"; -import { reaction, when } from "mobx"; -import { observer } from "mobx-react-lite"; -import React, { useEffect, useRef, useState } from "react"; - -import { useStore } from "../../stores"; -import { - isPositiveNumber, - removeCommaSpaceAndTrim, - removeSnakeCase, -} from "../../utils"; -import { Badge, BadgeColorMapping } from "../Badge"; -import ConnectedDatapointsView from "../DataViz/ConnectedDatapointsView"; -import { - BinaryRadioButton, - BinaryRadioGroupClearButton, - BinaryRadioGroupContainer, - BinaryRadioGroupQuestion, - NotReportedIcon, - TextInput, -} from "../Forms"; -import { Loading } from "../Loading"; -import { PageTitle, TabbedBar, TabbedItem, TabbedOptions } from "../Reports"; -import { - ActiveMetricSettingHeader, - Dimension, - DimensionTitle, - DimensionTitleWrapper, - Disaggregation, - DisaggregationHeader, - DisaggregationName, - Header, - Label, - MetricBoxContainer, - MetricBoxWrapper, - MetricConfigurationContainer, - MetricContextContainer, - MetricContextItem, - MetricDescription, - MetricDetailsDisplay, - MetricDisaggregations, - MetricName, - MetricNameBadgeToggleWrapper, - MetricNameBadgeWrapper, - MetricOnOffWrapper, - MetricsViewContainer, - MetricsViewControlPanel, - MultipleChoiceWrapper, - PanelContainerLeft, - PanelContainerRight, - RadioButtonGroupWrapper, - Slider, - Subheader, - ToggleSwitch, - ToggleSwitchInput, - ToggleSwitchLabel, - ToggleSwitchWrapper, -} from "."; - -type MetricsViewMetric = { - key: string; - display_name: string; - description: string; - frequency: string; - enabled: boolean; - system: AgencySystems; - contexts: { - key: string; - display_name: string; - reporting_note: string; - required: boolean; - type: string; - value: string | null; - multiple_choice_options?: string[]; - }[]; - disaggregations: { - key: string; - display_name: string; - enabled: boolean; - dimensions: { - key: string; - label: string; - reporting_note: string; - enabled: boolean; - }[]; - }[]; -}; - -type MetricBoxProps = { - metricKey: string; - displayName: string; - frequency: ReportFrequency; - description: string; - enabled?: boolean; - activeMetricKey: string; - setActiveMetricKey: React.Dispatch>; -}; - -const reportFrequencyBadgeColors: BadgeColorMapping = { - ANNUAL: "ORANGE", - MONTHLY: "GREEN", -}; - -const MetricBox: React.FC = ({ - metricKey, - displayName, - frequency, - description, - enabled, - activeMetricKey, - setActiveMetricKey, -}): JSX.Element => { - return ( - setActiveMetricKey(metricKey)} - enabled={enabled} - selected={metricKey === activeMetricKey} - > - - - {displayName} - - {frequency} - - - - {!enabled && } - - - {description} - - ); -}; - -type MetricConfigurationProps = { - activeMetricKey: string; - metricSettings: { [key: string]: MetricsViewMetric }; - saveAndUpdateMetricSettings: ( - typeOfUpdate: "METRIC" | "DISAGGREGATION" | "DIMENSION" | "CONTEXT", - updatedSetting: MetricSettings, - debounce?: boolean - ) => void; -}; - -const MetricConfiguration: React.FC = ({ - activeMetricKey, - metricSettings, - saveAndUpdateMetricSettings, -}): JSX.Element => { - const metricDisplayName = metricSettings[activeMetricKey]?.display_name; - const metricEnabled = Boolean(metricSettings[activeMetricKey]?.enabled); - - return ( - - -
- Are you currently able to report any part of this metric? -
- - Answering “No” means that {metricDisplayName} will not appear on - automatically generated reports from here on out. You can change this - later. - - - - saveAndUpdateMetricSettings("METRIC", { - key: activeMetricKey, - enabled: true, - }) - } - /> - - saveAndUpdateMetricSettings("METRIC", { - key: activeMetricKey, - enabled: false, - }) - } - /> - -
- - - {metricSettings[activeMetricKey]?.disaggregations.length > 0 && ( - <> -
Breakdowns
- - Turning any of these breakdowns “Off” means that they will not - appear on automatically generated reports from here on out. You - can change this later. - - - )} - - {metricSettings[activeMetricKey]?.disaggregations?.map( - (disaggregation) => { - return ( - - - - {disaggregation.display_name} - - - - - - - saveAndUpdateMetricSettings("DISAGGREGATION", { - key: activeMetricKey, - disaggregations: [ - { - key: disaggregation.key, - enabled: !disaggregation.enabled, - }, - ], - }) - } - /> - - - - - - {disaggregation?.dimensions.map((dimension) => { - return ( - - - - {dimension.label} - - - - - - - { - if (disaggregation.enabled) { - saveAndUpdateMetricSettings("DIMENSION", { - key: activeMetricKey, - disaggregations: [ - { - key: disaggregation.key, - dimensions: [ - { - key: dimension.key, - enabled: !dimension.enabled, - }, - ], - }, - ], - }); - } - }} - /> - - - - - ); - })} - - ); - } - )} -
-
- ); -}; - -type MetricSettingsUpdateOptions = - | "METRIC" - | "DISAGGREGATION" - | "DIMENSION" - | "CONTEXT"; - -type MetricContextConfigurationProps = { - metricKey: string; - contexts: { - key: string; - display_name: string; - reporting_note: string; - required: boolean; - type: string; - value: string | null; - multiple_choice_options?: string[]; - }[]; - saveAndUpdateMetricSettings: ( - typeOfUpdate: MetricSettingsUpdateOptions, - updatedSetting: MetricSettings, - debounce?: boolean - ) => void; -}; - -const MetricContextConfiguration: React.FC = ({ - metricKey, - contexts, - saveAndUpdateMetricSettings, -}) => { - const [contextErrors, setContextErrors] = useState<{ - [key: string]: FormError; - }>(); - - const contextNumberValidation = (key: string, value: string) => { - const cleanValue = removeCommaSpaceAndTrim(value); - - if (!isPositiveNumber(cleanValue) && cleanValue !== "") { - setContextErrors({ - [key]: { - message: "Please enter a valid number.", - }, - }); - - return false; - } - - setContextErrors((prev) => { - const otherContextErrors = { ...prev }; - delete otherContextErrors[key]; - - return otherContextErrors; - }); - return true; - }; - - useEffect(() => { - contexts.forEach((context) => { - if (context.type === "NUMBER") { - contextNumberValidation(context.key, context.value || ""); - } - }); - }, [contexts]); - - return ( - - - Anything entered here will appear as the default value for all reports. - If you are entering data for a particular month, you can still replace - this as necessary. - - - {contexts?.map((context) => ( - - {context.type === "BOOLEAN" && ( - <> - - - - saveAndUpdateMetricSettings("CONTEXT", { - key: metricKey, - contexts: [{ key: context.key, value: "yes" }], - }) - } - /> - - saveAndUpdateMetricSettings("CONTEXT", { - key: metricKey, - contexts: [{ key: context.key, value: "no" }], - }) - } - /> - - - saveAndUpdateMetricSettings("CONTEXT", { - key: metricKey, - contexts: [{ key: context.key, value: "" }], - }) - } - > - Clear Input - - - )} - - {(context.type === "TEXT" || context.type === "NUMBER") && ( - <> - - { - if (context.type === "NUMBER") { - contextNumberValidation(context.key, e.currentTarget.value); - } - - saveAndUpdateMetricSettings( - "CONTEXT", - { - key: metricKey, - contexts: [ - { key: context.key, value: e.currentTarget.value }, - ], - }, - true - ); - }} - /> - - )} - - {context.type === "MULTIPLE_CHOICE" && ( - - - {context.display_name} - - - - {context.multiple_choice_options?.map((option) => ( - - saveAndUpdateMetricSettings("CONTEXT", { - key: metricKey, - contexts: [{ key: context.key, value: option }], - }) - } - /> - ))} - - - saveAndUpdateMetricSettings("CONTEXT", { - key: metricKey, - contexts: [{ key: context.key, value: "" }], - }) - } - > - Clear Input - - - )} - - ))} - - ); -}; - -export type MetricSettings = { - key: string; - enabled?: boolean; - contexts?: { - key: string; - value: string; - }[]; - disaggregations?: { - key: string; - enabled?: boolean; - dimensions?: { - key: string; - enabled: boolean; - }[]; - }[]; -}; - -export const MetricsView: React.FC = observer(() => { - const { reportStore, userStore, datapointsStore } = useStore(); - const configPanelRef = useRef(null); - - // TODO(#13805) Temporarily hiding the data tab until it is implemented. Currently it's only visible to Recidiviz admins. - const configSections = ["Data", "Configuration", "Context"]; - type ConfigSections = typeof configSections[number]; - - const [activeMetricFilter, setActiveMetricFilter] = useState(); - - const [activeConfigSection, setActiveConfigSection] = - useState("Data"); - - const [isLoading, setIsLoading] = useState(true); - - const [loadingError, setLoadingError] = useState( - undefined - ); - - const [activeMetricKey, setActiveMetricKey] = useState(""); - - const [metricSettings, setMetricSettings] = useState<{ - [key: string]: MetricsViewMetric; - }>({}); - - const [filteredMetricSettings, setFilteredMetricSettings] = useState<{ - [key: string]: MetricsViewMetric; - }>({}); - - const updateMetricSettings = ( - typeOfUpdate: MetricSettingsUpdateOptions, - updatedSetting: MetricSettings - ) => { - setMetricSettings((prev) => { - const metricKey = updatedSetting.key; - - if (typeOfUpdate === "METRIC") { - return { - ...prev, - [updatedSetting.key]: { - ...prev[metricKey], - enabled: Boolean(updatedSetting.enabled), - }, - }; - } - - if (typeOfUpdate === "DISAGGREGATION") { - const updatedDisaggregations = prev[metricKey].disaggregations.map( - (disaggregation) => { - /** Quick Note: for now, all updates happen one at a time thus leaving - * one item in the disaggregations/dimensions/contexts arrays that will - * be updated at any one time. We can update this in the future to handle - * updating multiple settings at one time if necessary. - */ - if ( - disaggregation.key === updatedSetting.disaggregations?.[0].key - ) { - /** - * When disaggregation is switched off, all dimensions are disabled. - * When disaggregation is switched on, all dimensions are enabled. - */ - if (!updatedSetting.disaggregations?.[0].enabled) { - return { - ...disaggregation, - enabled: false, - dimensions: disaggregation.dimensions.map((dimension) => { - return { - ...dimension, - enabled: false, - }; - }), - }; - } - return { - ...disaggregation, - enabled: Boolean(updatedSetting.disaggregations?.[0].enabled), - dimensions: disaggregation.dimensions.map((dimension) => { - return { - ...dimension, - enabled: true, - }; - }), - }; - } - return disaggregation; - } - ); - - return { - ...prev, - [updatedSetting.key]: { - ...prev[metricKey], - disaggregations: updatedDisaggregations, - }, - }; - } - - if (typeOfUpdate === "DIMENSION") { - const updatedDisaggregations = prev[metricKey].disaggregations.map( - (disaggregation) => { - if ( - disaggregation.key === updatedSetting.disaggregations?.[0].key - ) { - const lastDimensionDisabled = - disaggregation.dimensions.filter( - (dimension) => dimension.enabled - )?.length === 1; - - /** Disable disaggregation when last dimension toggle is switched off */ - if ( - !updatedSetting.disaggregations?.[0].dimensions?.[0].enabled && - lastDimensionDisabled - ) { - return { - ...disaggregation, - enabled: false, - dimensions: disaggregation.dimensions.map((dimension) => { - if ( - dimension.key === - updatedSetting.disaggregations?.[0].dimensions?.[0].key - ) { - return { - ...dimension, - enabled: Boolean( - updatedSetting.disaggregations?.[0].dimensions?.[0] - .enabled - ), - }; - } - return dimension; - }), - }; - } - - return { - ...disaggregation, - dimensions: disaggregation.dimensions.map((dimension) => { - if ( - dimension.key === - updatedSetting.disaggregations?.[0].dimensions?.[0].key - ) { - return { - ...dimension, - enabled: Boolean( - updatedSetting.disaggregations?.[0].dimensions?.[0] - .enabled - ), - }; - } - return dimension; - }), - }; - } - return disaggregation; - } - ); - - return { - ...prev, - [updatedSetting.key]: { - ...prev[metricKey], - disaggregations: updatedDisaggregations, - }, - }; - } - - if (typeOfUpdate === "CONTEXT") { - const updatedContext = prev[metricKey].contexts.map((context) => { - if (context.key === updatedSetting.contexts?.[0].key) { - return { - ...context, - value: updatedSetting.contexts?.[0].value, - }; - } - return context; - }); - - return { - ...prev, - [updatedSetting.key]: { - ...prev[metricKey], - contexts: updatedContext, - }, - }; - } - - return prev; - }); - }; - - const saveMetricSettings = async (updatedSetting: MetricSettings) => { - const response = (await reportStore.updateReportSettings([ - updatedSetting, - ])) as Response; - - if (response.status === 200) { - showToast(`Settings saved.`, true, "grey", 4000); - } else { - showToast(`Failed to save.`, true, "red", 4000); - } - }; - - const debouncedSave = useRef(_debounce(saveMetricSettings, 1500)).current; - - const saveAndUpdateMetricSettings = ( - typeOfUpdate: MetricSettingsUpdateOptions, - updatedSetting: MetricSettings, - debounce?: boolean - ) => { - updateMetricSettings(typeOfUpdate, updatedSetting); - if (debounce) { - debouncedSave(updatedSetting); - } else { - saveMetricSettings(updatedSetting); - } - }; - - const fetchAndSetReportSettings = async () => { - const response = (await reportStore.getReportSettings()) as - | Response - | Error; - - setIsLoading(false); - - if (response instanceof Error) { - return setLoadingError(response.message); - } - - const reportSettings = (await response.json()) as MetricsViewMetric[]; - const metricKeyToMetricMap: { [key: string]: MetricsViewMetric } = {}; - - reportSettings?.forEach((metric) => { - metricKeyToMetricMap[metric.key] = metric; - }); - - setMetricSettings(metricKeyToMetricMap); - setActiveMetricKey(Object.keys(metricKeyToMetricMap)[0]); - }; - - useEffect( - () => - // return when's disposer so it is cleaned up if it never runs - when( - () => userStore.userInfoLoaded, - async () => { - fetchAndSetReportSettings(); - datapointsStore.getDatapoints(); - setActiveMetricFilter( - removeSnakeCase(userStore.currentAgency?.systems[0] as string) - ); - } - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - // reload report overviews when the current agency ID changes - useEffect( - () => - // return disposer so it is cleaned up if it never runs - reaction( - () => userStore.currentAgencyId, - async (currentAgencyId, previousAgencyId) => { - // prevents us from calling getDatapoints twice on initial load - if (previousAgencyId !== undefined) { - setIsLoading(true); - fetchAndSetReportSettings(); - await datapointsStore.getDatapoints(); - setActiveMetricFilter( - removeSnakeCase(userStore.currentAgency?.systems[0] as string) - ); - } - } - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [userStore] - ); - - useEffect(() => { - const filteredMetricKeyToMetricMap: { [key: string]: MetricsViewMetric } = - {}; - - Object.values(metricSettings) - .filter( - (metric) => - metric.system.toLowerCase() === activeMetricFilter?.toLowerCase() - ) - ?.forEach((metric) => { - filteredMetricKeyToMetricMap[metric.key] = metric; - }); - - return setFilteredMetricSettings(filteredMetricKeyToMetricMap); - }, [metricSettings, activeMetricFilter]); - - if (isLoading) { - return ; - } - - if (!metricSettings[activeMetricKey]) { - return
Error: {loadingError}
; - } - - return ( - <> - - Metrics - - - - {userStore.currentAgency?.systems.map((filterOption) => ( - - setActiveMetricFilter(removeSnakeCase(filterOption)) - } - capitalize - > - {removeSnakeCase(filterOption.toLowerCase())} - - ))} - - - - - {/* List Of Metrics */} - - {filteredMetricSettings && - Object.values(filteredMetricSettings).map((metric) => ( - { - if (configPanelRef.current) { - configPanelRef.current.scrollTo({ - top: 0, - behavior: "smooth", - }); - } - }} - > - - - ))} - - - {/* Data | Configuration | Context */} - - - - - {metricSettings[activeMetricKey]?.display_name} - - - {metricSettings[activeMetricKey]?.frequency} - - - - - - {configSections.map((section) => ( - { - setActiveConfigSection(section); - if (configPanelRef.current) { - configPanelRef.current.scrollTo({ - top: 0, - behavior: "smooth", - }); - } - }} - > - {section} - - ))} - - - - - {/* Data */} - {activeConfigSection === "Data" && ( - - )} - - {/* Configuration */} - {activeConfigSection === "Configuration" && ( - - - - )} - - {/* Context */} - {activeConfigSection === "Context" && ( - - - - )} - - - - - ); -}); diff --git a/publisher/src/components/MetricsView/MetricsViewMocks.ts b/publisher/src/components/MetricsView/MetricsViewMocks.ts deleted file mode 100644 index 02dfe368a..000000000 --- a/publisher/src/components/MetricsView/MetricsViewMocks.ts +++ /dev/null @@ -1,163 +0,0 @@ -// 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 const metricsViewMockResponse = [ - { - key: "LAW_ENFORCEMENT_BUDGET__metric/law_enforcement/budget/type", - display_name: "Annual Budget", - description: "Measures the total annual budget (in dollars) of the agency.", - frequency: "ANNUAL", - enabled: true, - contexts: [ - { - key: "PRIMARY_FUNDING_SOURCE", - display_name: "Binary question?", - reporting_note: "put your primary funding source here", - required: false, - type: "BOOLEAN", - value: "government funding", - }, - { - key: "PRIMARY_FUNDING_SOURCE2", - display_name: "Multiple choice", - reporting_note: "put your primary funding source here", - required: false, - type: "MULTIPLE_CHOICE", - multiple_choice_options: [ - "Choice 1", - "Choice 2", - "Choice 3", - "Choice 4", - ], - value: "government funding", - }, - { - key: "ADDITIONAL_CONTEXT", - display_name: "Additional context", - reporting_note: "Any additional context you want to provide", - required: false, - type: "TEXT", - value: "we are special, here's why", - }, - ], - disaggregations: [ - { - key: "law_enforcement/staff/type", - display_name: "Staff Types", - enabled: false, - dimensions: [ - { - key: "SUPPORT", - label: "Support", - reporting_note: "Staff: Support", - enabled: false, - }, - { - key: "SECURITY", - label: "Security", - reporting_note: "Staff: Security", - enabled: true, - }, - ], - }, - ], - }, - { - key: "READMISSION_RATE", - display_name: "Readmission Rate", - description: - "Measure the number of individuals admitted who had at least one other prison admission within the prior year.", - frequency: "MONTHLY", - enabled: false, - contexts: [ - { - key: "DEFINITION_OF_READMISSION", - display_name: "Definition of Readmission", - reporting_note: "Agency's definition of readmission.", - required: false, - type: "NUMBER", - value: null, - }, - { - key: "ADDITIONAL_CONTEXT", - display_name: "Additional Context", - reporting_note: - "Add any additional context that you would like to provide here.", - required: false, - type: "TEXT", - value: null, - }, - ], - disaggregations: [ - { - key: "READMISSION_KEY_TYPE", - display_name: "Readmission Types", - enabled: true, - dimensions: [ - { - key: "NEW_OFFENSE", - label: "New Offense", - reporting_note: "Readmission: New Offense", - enabled: true, - }, - { - key: "VIOLATION_OF_CONDITIONS", - label: "Violation of Conditions", - reporting_note: "Readmission: Violation of Conditions", - enabled: true, - }, - { - key: "OTHER", - label: "Other", - reporting_note: "Readmission: Other", - enabled: true, - }, - { - key: "UNKNOWN", - label: "Unknown", - reporting_note: "Readmission: Unknown", - enabled: true, - }, - { - key: "NEW_OFFENSE2", - label: "New Offense", - reporting_note: "Readmission: New Offense", - enabled: true, - }, - { - key: "VIOLATION_OF_CONDITIONS2", - label: "Violation of Conditions", - reporting_note: "Readmission: Violation of Conditions", - enabled: true, - }, - { - key: "OTHER2", - label: "Other", - reporting_note: "Readmission: Other", - enabled: true, - }, - { - key: "UNKNOWN2", - label: "Unknown", - reporting_note: "Readmission: Unknown", - enabled: true, - }, - ], - }, - ], - }, -]; diff --git a/publisher/src/components/Reports/ReportSummaryPanel.tsx b/publisher/src/components/Reports/ReportSummaryPanel.tsx index 4b4d49498..255e113dc 100644 --- a/publisher/src/components/Reports/ReportSummaryPanel.tsx +++ b/publisher/src/components/Reports/ReportSummaryPanel.tsx @@ -114,7 +114,7 @@ const ReportSummarySection = styled.a` } `; -const MetricDisplayName = styled.div<{ +export const MetricDisplayName = styled.div<{ activeSection?: boolean; }>` ${({ activeSection }) => diff --git a/publisher/src/components/Reports/Reports.styles.tsx b/publisher/src/components/Reports/Reports.styles.tsx index e2987c2ce..a4f781bca 100644 --- a/publisher/src/components/Reports/Reports.styles.tsx +++ b/publisher/src/components/Reports/Reports.styles.tsx @@ -64,7 +64,7 @@ export const TabbedItem = styled.div<{ padding: 24px 0 16px 0; margin-right: 20px; color: ${({ selected }) => - selected ? palette.solid.darkgrey : palette.highlight.grey9}; + selected ? palette.solid.blue : palette.highlight.grey9}; border-bottom: ${({ selected }) => selected ? `3px solid ${palette.solid.blue}` : `3px solid transparent`}; transition: color 0.3s ease; @@ -72,7 +72,7 @@ export const TabbedItem = styled.div<{ &:hover { cursor: pointer; - color: ${palette.solid.darkgrey}; + color: ${palette.solid.blue}; } `; diff --git a/publisher/src/components/Settings/AccountSettings.tsx b/publisher/src/components/Settings/AccountSettings.tsx new file mode 100644 index 000000000..ec1fe0667 --- /dev/null +++ b/publisher/src/components/Settings/AccountSettings.tsx @@ -0,0 +1,69 @@ +// 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 { debounce as _debounce } from "lodash"; +import React, { useRef } from "react"; + +import { useStore } from "../../stores"; +import { TextInput, Title, TitleWrapper } from "../Forms"; +import { InputWrapper, SettingsFormPanel } from "."; + +export const AccountSettings = () => { + const { userStore } = useStore(); + const [email, setEmail] = React.useState(userStore?.email || ""); + const [name, setName] = React.useState(userStore?.name || ""); + + const saveNameEmailChange = (nameUpdate?: string, emailUpdate?: string) => { + if (nameUpdate) { + return userStore.updateUserNameAndEmail(nameUpdate, email); + } + if (emailUpdate) { + return userStore.updateUserNameAndEmail(name, emailUpdate); + } + }; + + const debouncedSave = useRef(_debounce(saveNameEmailChange, 1500)).current; + + return ( + + + Account + + + + { + setName(e.target.value); + debouncedSave(e.target.value, undefined); + }} + /> + { + setEmail(e.target.value); + debouncedSave(undefined, e.target.value); + }} + /> + + + ); +}; diff --git a/publisher/src/components/Settings/Settings.styles.tsx b/publisher/src/components/Settings/Settings.styles.tsx new file mode 100644 index 000000000..7678c3c5f --- /dev/null +++ b/publisher/src/components/Settings/Settings.styles.tsx @@ -0,0 +1,90 @@ +// 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 styled from "styled-components/macro"; + +import { palette, typography } from "../GlobalStyles"; +import { MetricDisplayName } from "../Reports/ReportSummaryPanel"; + +export const SettingsContainer = styled.div` + width: 100%; + display: flex; + align-items: flex-start; + padding: 48px 24px 0 24px; + position: fixed; + overflow-y: scroll; +`; + +export const ContentDisplay = styled.div` + height: 89vh; + display: flex; + flex-direction: column; + justify-content: flex-start; + flex: 10 10 auto; + overflow-y: scroll; +`; + +export const SettingsMenuContainer = styled.div` + ${typography.sizeCSS.headline} + width: fit-content; + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 16px; + margin-right: 100px; +`; + +export const MenuItem = styled.div<{ selected?: boolean }>` + ${typography.sizeCSS.large} + width: fit-content; + padding-bottom: 4px; + color: ${({ selected }) => + selected ? palette.solid.darkgrey : palette.highlight.grey10}; + border-bottom: 2px solid + ${({ selected }) => (selected ? palette.solid.blue : `transparent`)}; + transition: color 0.2s ease; + + &:hover { + cursor: pointer; + color: ${({ selected }) => !selected && palette.solid.darkgrey}; + } +`; + +export const SettingsFormPanel = styled.div``; + +export const InputWrapper = styled.div` + display: flex; + gap: 10px; + + div { + width: 100%; + } +`; + +export const MetricsListContainer = styled.div``; + +export const MetricsListItem = styled(MetricDisplayName)` + ${typography.sizeCSS.normal} + width: fit-content; + color: ${({ activeSection }) => + activeSection ? palette.solid.darkgrey : palette.highlight.grey8}; + + &:hover { + cursor: pointer; + color: ${palette.solid.darkgrey}; + } +`; diff --git a/publisher/src/components/Settings/SettingsMenu.tsx b/publisher/src/components/Settings/SettingsMenu.tsx new file mode 100644 index 000000000..2538f0552 --- /dev/null +++ b/publisher/src/components/Settings/SettingsMenu.tsx @@ -0,0 +1,84 @@ +// 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, { Fragment } from "react"; + +import { + ListOfMetricsForNavigation, + MenuOptions, + menuOptions, +} from "../../pages/Settings"; +import { + MenuItem, + MetricsListContainer, + MetricsListItem, + SettingsMenuContainer, +} from "."; + +export const SettingsMenu: React.FC<{ + activeMenuItem: MenuOptions; + goToMenuItem: (destination: MenuOptions) => void; + activeMetricKey: string | undefined; + setActiveMetricKey: React.Dispatch>; + listOfMetrics: ListOfMetricsForNavigation[] | undefined; +}> = ({ + activeMenuItem, + goToMenuItem, + activeMetricKey, + setActiveMetricKey, + listOfMetrics, +}) => { + return ( + + {menuOptions.map((option) => ( + + { + goToMenuItem(option); + + if (option === "Metric Configuration") { + setActiveMetricKey(undefined); + } + }} + > + {option} + + + {/* Metrics Navigation (appears when a metric has been + selected and allows users to toggle between metrics) */} + {option === "Metric Configuration" && + activeMenuItem === "Metric Configuration" && + activeMetricKey && + listOfMetrics && ( + + {listOfMetrics.map((metric) => ( + setActiveMetricKey(metric.key)} + > + {metric.display_name} + + ))} + + )} + + ))} + + ); +}; diff --git a/publisher/src/components/MetricsView/index.ts b/publisher/src/components/Settings/index.ts similarity index 89% rename from publisher/src/components/MetricsView/index.ts rename to publisher/src/components/Settings/index.ts index 1367ec56e..585b3cb16 100644 --- a/publisher/src/components/MetricsView/index.ts +++ b/publisher/src/components/Settings/index.ts @@ -15,5 +15,6 @@ // along with this program. If not, see . // ============================================================================= -export * from "./MetricsView"; -export * from "./MetricsView.styles"; +export * from "./AccountSettings"; +export * from "./Settings.styles"; +export * from "./SettingsMenu"; diff --git a/publisher/src/components/assets/gears-icon.svg b/publisher/src/components/assets/gears-icon.svg new file mode 100644 index 000000000..82a6172e9 --- /dev/null +++ b/publisher/src/components/assets/gears-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/publisher/src/components/assets/right-arrow.svg b/publisher/src/components/assets/right-arrow.svg new file mode 100644 index 000000000..5b1b065a2 --- /dev/null +++ b/publisher/src/components/assets/right-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/publisher/src/pages/AccountSettings.tsx b/publisher/src/pages/AccountSettings.tsx deleted file mode 100644 index 6525e5183..000000000 --- a/publisher/src/pages/AccountSettings.tsx +++ /dev/null @@ -1,131 +0,0 @@ -// 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 { typography } from "@justice-counts/common/components/GlobalStyles"; -import { debounce as _debounce } from "lodash"; -import React, { useRef } from "react"; -import styled from "styled-components/macro"; - -import { - ExtendedOpacityGradient, - UploadedFiles, - UploadedFilesWrapper, -} from "../components/DataUpload"; -import { TextInput, Title, TitleWrapper } from "../components/Forms"; -import { useStore } from "../stores"; - -const SettingsContainer = styled.div` - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: flex-start; - padding: 39px 24px 0 24px; - position: fixed; - overflow-y: scroll; - - @media only screen and (max-width: 1050px) { - width: unset; - flex-direction: column; - } -`; - -const SettingsFormPanel = styled.div``; - -const InputWrapper = styled.div` - display: flex; - gap: 10px; - - div { - width: 100%; - } -`; - -const SettingsFormUploadedFilesWrapper = styled.div` - display: flex; - flex-direction: column; - flex: 3 1 auto; -`; - -const SettingsTitle = styled.div` - ${typography.sizeCSS.headline} - display: flex; - flex: 1 1 auto; -`; - -const AccountSettings = () => { - const { userStore } = useStore(); - const [email, setEmail] = React.useState(userStore?.email || ""); - const [name, setName] = React.useState(userStore?.name || ""); - - const saveNameEmailChange = (nameUpdate?: string, emailUpdate?: string) => { - if (nameUpdate) { - return userStore.updateUserNameAndEmail(nameUpdate, email); - } - if (emailUpdate) { - return userStore.updateUserNameAndEmail(name, emailUpdate); - } - }; - - const debouncedSave = useRef(_debounce(saveNameEmailChange, 1500)).current; - - return ( - - Settings - - - - - Account - - - - { - setName(e.target.value); - debouncedSave(e.target.value, undefined); - }} - /> - { - setEmail(e.target.value); - debouncedSave(undefined, e.target.value); - }} - /> - - - - - - Uploaded Files - - - - - - - - ); -}; - -export default AccountSettings; diff --git a/publisher/src/pages/Settings.tsx b/publisher/src/pages/Settings.tsx new file mode 100644 index 000000000..e80d5ab9c --- /dev/null +++ b/publisher/src/pages/Settings.tsx @@ -0,0 +1,79 @@ +// 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, { useState } from "react"; + +import { UploadedFiles } from "../components/DataUpload"; +import { MetricConfiguration } from "../components/MetricConfiguration"; +import { + AccountSettings, + ContentDisplay, + SettingsContainer, + SettingsMenu, +} from "../components/Settings"; + +export const menuOptions = [ + "Your Account", + "Uploaded Files", + "Metric Configuration", +] as const; +export type MenuOptions = typeof menuOptions[number]; + +export type ListOfMetricsForNavigation = { + key: string; + display_name: string; +}; + +const Settings = () => { + const [activeMenuItem, setActiveMenuItem] = useState( + menuOptions[0] + ); + + const goToMenuItem = (destination: MenuOptions) => + setActiveMenuItem(destination); + + /** State specific to Metrics Configuration & Settings Menu */ + const [listOfMetrics, setListOfMetrics] = + useState(); + const [activeMetricKey, setActiveMetricKey] = useState(); + + return ( + + + + + {activeMenuItem === "Your Account" && } + {activeMenuItem === "Uploaded Files" && } + {activeMenuItem === "Metric Configuration" && ( + + )} + + + ); +}; + +export default Settings; diff --git a/publisher/src/stores/ReportStore.ts b/publisher/src/stores/ReportStore.ts index 70914e92b..bc48955e4 100644 --- a/publisher/src/stores/ReportStore.ts +++ b/publisher/src/stores/ReportStore.ts @@ -30,7 +30,7 @@ import { } from "mobx"; import { UploadedFileStatus } from "../components/DataUpload"; -import { MetricSettings } from "../components/MetricsView"; +import { MetricSettings } from "../components/MetricConfiguration"; import { groupBy } from "../utils/helperUtils"; import API from "./API"; import UserStore from "./UserStore";