diff --git a/common/types.ts b/common/types.ts index 25029a106..910a32bbd 100644 --- a/common/types.ts +++ b/common/types.ts @@ -87,7 +87,8 @@ export type AgencySettingType = | "HOMEPAGE_URL" | "ZIPCODE" | "DATA_SHARING_TYPE" - | "BIOLOGICAL_SEX_RACE_ETHNICITY_DATA_SOURCE"; + | "BIOLOGICAL_SEX_RACE_ETHNICITY_DATA_SOURCE" + | "SECTOR_INCLUDES_EXCLUDES"; export interface AgencySetting { setting_type: AgencySettingType; diff --git a/publisher/src/components/AgencySettings/AgencySettings.styles.tsx b/publisher/src/components/AgencySettings/AgencySettings.styles.tsx index f708d9df8..5d34a9153 100644 --- a/publisher/src/components/AgencySettings/AgencySettings.styles.tsx +++ b/publisher/src/components/AgencySettings/AgencySettings.styles.tsx @@ -444,6 +444,13 @@ export const SupervisionSystemRow = styled(AgencySettingsInfoRow)<{ border: none; `; +// Agency Definition +export const DefinitionDescriptionInputWrapper = styled.div` + :not(:last-child) { + padding-bottom: 24px; + } +`; + // Data Source export const DataSourceContainer = styled.div` border-top: 1px solid ${palette.highlight.grey5}; @@ -453,6 +460,7 @@ export const DataSourceContainer = styled.div` padding: 16px 40px; `; export const DataSourceTitle = styled.div` + text-transform: capitalize; font-weight: 700; padding-bottom: 8px; `; diff --git a/publisher/src/components/AgencySettings/AgencySettings.tsx b/publisher/src/components/AgencySettings/AgencySettings.tsx index 7d283fbe3..1c6add13f 100644 --- a/publisher/src/components/AgencySettings/AgencySettings.tsx +++ b/publisher/src/components/AgencySettings/AgencySettings.tsx @@ -27,6 +27,7 @@ import { } from "./AgencySettings.styles"; import { AgencySettingsBasicInfo } from "./AgencySettingsBasicInfo"; import AgencySettingsDataSource from "./AgencySettingsDataSource"; +import AgencySettingsDefinition from "./AgencySettingsDefinition"; // TODO(#1537) Ungate zipcode and agency data sharing fields // import AgencySettingsDataSharingType from "./AgencySettingsDataSharingType"; import AgencySettingsDescription from "./AgencySettingsDescription"; @@ -44,6 +45,7 @@ export enum ActiveSetting { Supervisions = "SUPERVISIONS", Jurisdictions = "JURISDICTIONS", DataSource = "BIOLOGICAL_SEX_RACE_ETHNICITY_DATA_SOURCE", + Definition = "SECTOR_INCLUDES_EXCLUDES", } export type SettingProps = { @@ -97,6 +99,9 @@ export const AgencySettings: React.FC = observer(() => { + {/* TODO(#1537) Ungate zipcode and agency data sharing fields */} {/* */} diff --git a/publisher/src/components/AgencySettings/AgencySettingsDefinition.tsx b/publisher/src/components/AgencySettings/AgencySettingsDefinition.tsx new file mode 100644 index 000000000..5fe9a6be3 --- /dev/null +++ b/publisher/src/components/AgencySettings/AgencySettingsDefinition.tsx @@ -0,0 +1,375 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2024 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 { Button } from "@justice-counts/common/components/Button"; +import { CheckboxOptions } from "@justice-counts/common/components/CheckboxOptions"; +import { NewInput } from "@justice-counts/common/components/Input"; +import { Modal } from "@justice-counts/common/components/Modal"; +import { + AgencySystems, + SupervisionSubsystems, +} from "@justice-counts/common/types"; +import { removeSnakeCase } from "@justice-counts/common/utils"; +import { observer } from "mobx-react-lite"; +import React, { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; + +import { useStore } from "../../stores"; +import { ChooseDefaultSettings } from "../MetricsConfiguration/ModalForm.styled"; +import { SettingProps } from "./AgencySettings"; +import { + AgencyInfoBlockDescription, + AgencySettingsBlock, + BasicInfoBlockTitle, + DataSourceContainer, + DataSourceQuestionWrapper, + DataSourceTitle, + DefinitionDescriptionInputWrapper, + EditButtonContainer, +} from "./AgencySettings.styles"; +import { AgencySettingsEditModeModal } from "./AgencySettingsEditModeModal"; +import { + AgencyIncludesExcludes, + boolToYesNoEnum, +} from "./IncludesExcludes/includesExcludes"; +import { + AgencySystemKeys, + IncludesExcludesEnum, +} from "./IncludesExcludes/types"; + +type IncludedValue = `${IncludesExcludesEnum}`; + +type AgencyDefinitionSetting = { + sector: AgencySystemKeys; + settings: { + key: string; + included?: IncludedValue; + value?: string; + }[]; +}; + +const getDefaultSetting = ( + systems: AgencySystemKeys[], + unconfiguredDefault?: IncludedValue +): AgencyDefinitionSetting[] => { + if (!systems.length) return []; + + return systems.map((system) => { + return { + sector: system, + settings: Object.entries(AgencyIncludesExcludes[system]).map( + ([key, obj]) => { + return { + key, + included: unconfiguredDefault ?? obj.default, + }; + } + ), + }; + }); +}; + +const AgencySettingsDefinition: React.FC<{ + settingProps: SettingProps; +}> = ({ settingProps }) => { + const { isSettingInEditMode, openSetting, removeEditMode, allowEdit } = + settingProps; + + const { agencyId } = useParams() as { agencyId: string }; + const { agencyStore } = useStore(); + const { + currentAgencySystems, + currentAgencySettings, + updateAgencySettings, + saveAgencySettings, + } = agencyStore; + + const isSupervisionAgencyWithEnabledSubpopulations = + currentAgencySystems?.includes(AgencySystems.SUPERVISION) && + currentAgencySystems?.some((system) => + SupervisionSubsystems.includes(system) + ); + const isCourtAgency = currentAgencySystems?.includes( + AgencySystems.COURTS_AND_PRETRIAL + ); + const isCombinedAgency = + isSupervisionAgencyWithEnabledSubpopulations && isCourtAgency; + + const discreteAgencyTitle = isSupervisionAgencyWithEnabledSubpopulations + ? "Supervision" + : "Court"; + const agencyTitle = isCombinedAgency ? "Combined" : discreteAgencyTitle; + + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + const [currentSystems, setCurrentSystems] = useState([]); + + const agencyDefinitionSetting = useMemo(() => { + return ( + (currentAgencySettings?.find( + (setting) => setting.setting_type === "SECTOR_INCLUDES_EXCLUDES" + )?.value as AgencyDefinitionSetting[]) || [] + ); + }, [currentAgencySettings]); + + useEffect(() => { + // When we select agency without applicable sectors we need to reset currentSystems, otherwise it can have values from previous agencies in it. + // We also can't do it in reset effect below, because it depends on currentSystems and it'll cause infinite loop. + setCurrentSystems([]); + + const supervisionSystems = + (currentAgencySystems?.filter((system) => + SupervisionSubsystems.includes(system) + ) as AgencySystemKeys[]) || []; + + if (isCombinedAgency) { + setCurrentSystems([ + ...supervisionSystems, + AgencySystems.COURTS_AND_PRETRIAL, + ]); + } else { + if (isSupervisionAgencyWithEnabledSubpopulations) + setCurrentSystems(supervisionSystems); + if (isCourtAgency) setCurrentSystems([AgencySystems.COURTS_AND_PRETRIAL]); + } + }, [ + currentAgencySystems, + isCourtAgency, + isSupervisionAgencyWithEnabledSubpopulations, + isCombinedAgency, + ]); + + const configuredSystems = agencyDefinitionSetting.map( + (setting) => setting.sector + ); + const unconfiguredSystems = currentSystems.filter( + (sector) => !configuredSystems.includes(sector) + ); + + const initialSetting = [ + ...agencyDefinitionSetting, + ...getDefaultSetting(unconfiguredSystems, IncludesExcludesEnum.NO), + ]; + + const [updatedSetting, setUpdatedSetting] = useState(initialSetting); + + // This is a reset effect and it's used to reset setting for UI to work properly. + // The absence of this effect causes visual bug where setting from the previous agency may be displayed. + useEffect(() => { + setUpdatedSetting(initialSetting); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agencyDefinitionSetting, currentSystems]); + + const handleSaveClick = () => { + const updatedSettings = updateAgencySettings( + "SECTOR_INCLUDES_EXCLUDES", + updatedSetting, + parseInt(agencyId) + ); + saveAgencySettings(updatedSettings, agencyId); + removeEditMode(); + }; + + const handleCancelClick = () => { + if (JSON.stringify(updatedSetting) === JSON.stringify(initialSetting)) { + removeEditMode(); + } else { + setIsConfirmModalOpen(true); + } + }; + + const handleCancelModalConfirm = () => { + setUpdatedSetting(initialSetting); + setIsConfirmModalOpen(false); + removeEditMode(); + }; + + const handleCheckboxChange = ( + key: string, + checked: boolean, + system: AgencySystemKeys + ) => { + setUpdatedSetting((prevSettings) => { + const updates = prevSettings.map((prevSetting) => { + if (prevSetting.sector === system) { + return { + ...prevSetting, + settings: prevSetting.settings.map((setting) => + setting.key === key + ? { + ...setting, + included: boolToYesNoEnum(!checked), + } + : setting + ), + }; + } + return prevSetting; + }); + + return updates; + }); + }; + + const handleDescriptionChange = (system: AgencySystemKeys, value: string) => { + setUpdatedSetting((prevSettings) => { + return prevSettings.map((prevSetting) => { + if (prevSetting.sector === system) { + return { + ...prevSetting, + settings: [ + ...prevSetting.settings.filter( + (setting) => setting.key !== "ADDITIONAL_CONTEXT" + ), // Ensure no duplicate keys + { + key: "ADDITIONAL_CONTEXT", + value, + }, + ], + }; + } + return prevSetting; + }); + }); + }; + + if (!currentSystems.length) return null; + + return ( + <> + {isSettingInEditMode && ( + setIsConfirmModalOpen(false)} + handleCancelModalConfirm={handleCancelModalConfirm} + > + + + Indicate which of the following categories best defines your + agency. Or, choose the{" "} + + setUpdatedSetting(getDefaultSetting(currentSystems)) + } + > + Justice Counts definition. + + + + {currentSystems?.map((system) => { + const defaultDescription = initialSetting + .find((setting) => setting.sector === system) + ?.settings.find( + (setting) => setting.key === "ADDITIONAL_CONTEXT" + )?.value; + + return ( + + {isSupervisionAgencyWithEnabledSubpopulations && ( + + {removeSnakeCase(system).toLocaleLowerCase()} + + )} + { + const included = updatedSetting + .find((sector) => sector.sector === system) + ?.settings.find( + (setting) => setting.key === key + )?.included; + + return { + key, + label: option.label, + checked: included === IncludesExcludesEnum.YES, + }; + } + ), + ]} + onChange={({ key, checked }) => + handleCheckboxChange(key, checked, system) + } + /> + + + handleDescriptionChange(system, e.target.value) + } + fullWidth + /> + + + ); + })} + + } + buttons={[ + { + label: "Save", + onClick: () => { + handleSaveClick(); + }, + }, + ]} + maxHeight={900} + modalBackground="opaque" + onClickClose={handleCancelClick} + agencySettingsConfigs + jurisdictionsSettingsConfigs + agencySettingsAndJurisdictionsTitleConfigs + customPadding="4px 40px 24px 40px" + /> + + )} + + + + {agencyTitle} Agency Definition + {allowEdit && ( + +