From bd79aa8746eab52eb4bbf942efd240ae7d95d512 Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 30 Oct 2020 13:48:57 -0700 Subject: [PATCH] Add cohort filter to recidivism chart (#237) --- public-dashboard-client/package.json | 7 +- .../src/assets/icons/checkMark.svg | 21 ++ .../src/controls/CohortSelect.js | 300 ++++++++++++++++++ .../src/controls/CohortSelect.test.js | 255 +++++++++++++++ .../src/controls/Dropdown.js | 54 +--- .../src/controls/Dropdown.test.js | 78 +++++ .../src/controls/shared.js | 49 +++ .../src/detail-page/DetailPage.js | 5 +- .../src/page-sentencing/PageSentencing.js | 33 +- .../src/utils/assignOrderedDatavizColor.js | 7 + public-dashboard-client/src/utils/index.js | 1 + .../VizPopulationOverTime.js | 3 - .../RecidivismRatesChart.js | 37 ++- .../VizRecidivismRates.js | 35 +- .../x-hover-controller/XHoverController.js | 6 +- spotlight-client/src/App.test.tsx | 2 +- yarn.lock | 156 ++++++++- 17 files changed, 966 insertions(+), 83 deletions(-) create mode 100644 public-dashboard-client/src/assets/icons/checkMark.svg create mode 100644 public-dashboard-client/src/controls/CohortSelect.js create mode 100644 public-dashboard-client/src/controls/CohortSelect.test.js create mode 100644 public-dashboard-client/src/controls/Dropdown.test.js create mode 100644 public-dashboard-client/src/utils/assignOrderedDatavizColor.js diff --git a/public-dashboard-client/package.json b/public-dashboard-client/package.json index ff7991d2..a65e8517 100644 --- a/public-dashboard-client/package.json +++ b/public-dashboard-client/package.json @@ -22,6 +22,7 @@ "@reach/router": "^1.3.4", "@sentry/react": "^5.21.4", "@w11r/use-breakpoint": "^1.7.0", + "airbnb-prop-types": "^2.16.0", "classnames": "^2.2.6", "d3-array": "^2.4.0", "d3-color": "^1.4.1", @@ -32,6 +33,7 @@ "d3-scale": "^3.2.1", "date-fns": "^2.14.0", "deepmerge": "^4.2.2", + "downshift": "^6.0.6", "empty-lite": "^1.0.3", "env-cmd": "^10.1.0", "prop-types": "^15.7.2", @@ -46,13 +48,16 @@ "react-sticky": "^6.0.3", "semiotic": "^1.20.5", "set-order": "^0.3.5", - "styled-components": "^5.1.1", + "//": "TODO(#236): unpin & upgrade styled-components after bugfix", + "styled-components": "5.1.1", "styled-normalize": "^8.0.7", "topojson": "^3.0.2" }, "devDependencies": { + "@testing-library/dom": "^7.26.3", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^12.1.10", "jest-date-mock": "^1.0.8", "lint-staged": ">=10" }, diff --git a/public-dashboard-client/src/assets/icons/checkMark.svg b/public-dashboard-client/src/assets/icons/checkMark.svg new file mode 100644 index 00000000..adde8c6e --- /dev/null +++ b/public-dashboard-client/src/assets/icons/checkMark.svg @@ -0,0 +1,21 @@ + + + + diff --git a/public-dashboard-client/src/controls/CohortSelect.js b/public-dashboard-client/src/controls/CohortSelect.js new file mode 100644 index 00000000..ed1efb6f --- /dev/null +++ b/public-dashboard-client/src/controls/CohortSelect.js @@ -0,0 +1,300 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2020 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 useBreakpoint from "@w11r/use-breakpoint"; +import { and } from "airbnb-prop-types"; +import { ascending } from "d3-array"; +import { useSelect } from "downshift"; +import PropTypes from "prop-types"; +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import checkMarkPath from "../assets/icons/checkMark.svg"; +import { + ControlLabel, + ControlValue, + DropdownMenu as DropdownMenuBase, + DropdownMenuItem as DropdownMenuItemBase, + DropdownOptionType, + DropdownWrapper as DropdownWrapperBase, + HiddenSelect, +} from "./shared"; + +const SELECT_ALL_ID = "ALL"; + +const DropdownWrapper = styled(DropdownWrapperBase)` + ${ControlValue} { + border: 0; + cursor: pointer; + } +`; + +const DropdownMenu = styled(DropdownMenuBase)` + margin: 0; + position: absolute; + right: 0; + top: 100%; + + &:focus { + outline: none; + } +`; + +const MenuItemCheckMark = styled.img` + height: 12px; + margin-left: 32px; + visibility: hidden; + width: auto; +`; + +const DropdownMenuItem = styled(DropdownMenuItemBase)` + background-color: ${(props) => props.backgroundColor || "inherit"}; + border-bottom: 1px solid ${(props) => props.theme.colors.controlBackground}; + + &[aria-selected="true"] { + color: ${(props) => props.theme.colors.bodyLight}; + + ${MenuItemCheckMark} { + visibility: visible; + } + } +`; + +const MenuItemContents = styled.div` + align-items: baseline; + + display: flex; + justify-content: space-between; + width: 100%; +`; + +const OPTIONS_PROP_TYPE = PropTypes.arrayOf( + and([DropdownOptionType, PropTypes.shape({ color: PropTypes.string })]) +); + +function CustomSelect({ + buttonContents, + onHighlight, + options: optionsFromData, + selected, + setSelected, +}) { + const visibleOptions = [ + { id: SELECT_ALL_ID, label: "Select all" }, + ...optionsFromData, + ]; + + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getItemProps, + highlightedIndex, + } = useSelect({ + items: visibleOptions, + selectedItem: null, + stateReducer: (state, actionAndChanges) => { + const { changes, type } = actionAndChanges; + switch (type) { + case useSelect.stateChangeTypes.MenuKeyDownEnter: + case useSelect.stateChangeTypes.MenuKeyDownSpaceButton: + case useSelect.stateChangeTypes.ItemClick: + return { + ...changes, + // keep menu open after selection (it closes by default) + isOpen: true, + // keep the clicked item highlighted (highlight is cleared by default) + highlightedIndex: state.highlightedIndex, + }; + default: + return changes; + } + }, + onSelectedItemChange: ({ selectedItem }) => { + if (!selectedItem) { + return; + } + let newSelection; + + if (selectedItem.id === SELECT_ALL_ID) { + // if all are already selected, deselect all + if (selected.length === optionsFromData.length) { + newSelection = []; + } else { + newSelection = [...optionsFromData]; + } + } else { + newSelection = [...selected]; + + const index = selected.indexOf(selectedItem); + + if (index === -1) { + newSelection.push(selectedItem); + } else { + newSelection.splice(index, 1); + } + // need to keep selection sorted or labels and colors will get out of sync + newSelection.sort((a, b) => ascending(a.label, b.label)); + } + + setSelected(newSelection); + }, + }); + + useEffect(() => { + // index 0 is select all and should be ignored here + if (highlightedIndex < 1) { + onHighlight(undefined); + } else { + // offset by one due to select all + onHighlight(optionsFromData[highlightedIndex - 1]); + } + }, [highlightedIndex, onHighlight, optionsFromData]); + + const labelProps = getLabelProps(); + const toggleButtonProps = getToggleButtonProps(); + return ( + <> + + Cohort + + + {buttonContents} + + + {isOpen && + visibleOptions.map((opt, index) => { + const isSelected = selected.includes(opt); + const itemProps = getItemProps({ item: opt, index }); + return ( + + + {opt.label} + + + + ); + })} + + + ); +} + +CustomSelect.propTypes = { + buttonContents: PropTypes.node.isRequired, + onHighlight: PropTypes.func.isRequired, + options: OPTIONS_PROP_TYPE.isRequired, + selected: OPTIONS_PROP_TYPE.isRequired, + setSelected: PropTypes.func.isRequired, +}; + +function NativeSelect({ buttonContents, options, selected, setSelected }) { + return ( + <> + Cohort + {buttonContents} + { + const currentlySelectedIds = [...event.target.options] + .filter((opt) => opt.selected) + .map((opt) => opt.value); + setSelected( + options.filter((opt) => currentlySelectedIds.includes(opt.id)) + ); + // toggleSelected(options.find((opt) => opt.id === event.target.value)); + }} + value={selected.map((opt) => opt.id)} + > + {options.map((opt) => ( + + ))} + + + ); +} + +NativeSelect.propTypes = { + buttonContents: PropTypes.node.isRequired, + options: OPTIONS_PROP_TYPE.isRequired, + selected: OPTIONS_PROP_TYPE.isRequired, + setSelected: PropTypes.func.isRequired, +}; + +export default function CohortSelectMenu({ onChange, onHighlight, options }) { + const [selected, setSelected] = useState(options); + + useEffect(() => { + onChange(selected); + }, [onChange, selected]); + + const firstSelected = selected[0]; + const buttonContents = ( + <> + {!firstSelected && "Select …"} + {firstSelected && firstSelected.label} + {selected.length > 1 && ( + +  and {selected.length - 1} other{selected.length > 2 ? "s" : ""} + + )} + + ); + + const renderNativeSelect = useBreakpoint(false, ["mobile-", true]); + + return ( + + {renderNativeSelect ? ( + + ) : ( + + )} + + ); +} + +CohortSelectMenu.propTypes = { + onChange: PropTypes.func.isRequired, + onHighlight: PropTypes.func.isRequired, + options: PropTypes.arrayOf( + and([DropdownOptionType, PropTypes.shape({ color: PropTypes.string })]) + ).isRequired, +}; diff --git a/public-dashboard-client/src/controls/CohortSelect.test.js b/public-dashboard-client/src/controls/CohortSelect.test.js new file mode 100644 index 00000000..84345506 --- /dev/null +++ b/public-dashboard-client/src/controls/CohortSelect.test.js @@ -0,0 +1,255 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2020 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 userEvent from "@testing-library/user-event"; +import useBreakpoint from "@w11r/use-breakpoint"; +import React from "react"; +import { act, render, within } from "../testUtils"; +import CohortSelect from "./CohortSelect"; + +jest.mock("@w11r/use-breakpoint"); + +const mockOnChange = jest.fn(); +const mockOnHighlight = jest.fn(); +let testOptions; + +beforeEach(() => { + testOptions = [ + { id: "2009", label: "2009", color: "#C0FFEE" }, + { id: "2010", label: "2010", color: "#F4E192" }, + { id: "2011", label: "2011", color: "#AAF268" }, + { id: "2012", label: "2012", color: "#149E2B" }, + { id: "2013", label: "2013", color: "#7ACCD6" }, + { id: "2014", label: "2014", color: "#B54F01" }, + { id: "2015", label: "2015", color: "#8C0536" }, + { id: "2016", label: "2016", color: "#DDE03E" }, + { id: "2017", label: "2017", color: "#C5E276" }, + { id: "2018", label: "2018", color: "#8CFFED" }, + ]; + // mock breakpoint hook to simulate screen size (not natively supported by JSDOM) + useBreakpoint.mockReturnValue(false); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +/** + * Convenience function for opening the cohort menu, + * which most tests need to do + */ +function openMenu() { + const renderResult = render( + + ); + const menuButton = renderResult.getByRole("button", { name: /cohort/i }); + act(() => userEvent.click(menuButton)); + // give callers access to all queries etc. + return renderResult; +} + +test("triggers menu from button", () => { + const { getByRole } = render( + + ); + const menuButton = getByRole("button", { name: /^cohort/i }); + expect(menuButton).toBeVisible(); + userEvent.click(menuButton); + const menu = getByRole("listbox", { name: /cohort/i }); + expect(menu).toBeVisible(); + const { getByRole: getByRoleWithinMenu } = within(menu); + testOptions.forEach((opt) => { + expect(getByRoleWithinMenu("option", { name: opt.label })).toBeVisible(); + }); +}); + +test("invisible menu on mobile", () => { + // hook returns true on mobile + useBreakpoint.mockReturnValue(true); + + const { getByRole } = render( + + ); + // the listbox is always present but not visible (interacting with it will trigger native OS UI) + const menu = getByRole("listbox", { name: /cohort/i }); + expect(menu).not.toBeVisible(); + const { getByRole: getByRoleWithinMenu } = within(menu); + testOptions.forEach((opt) => { + expect( + getByRoleWithinMenu("option", { name: opt.label, selected: true }) + ).toBeInTheDocument(); + }); +}); + +test("selects all by default", () => { + const { getByRole } = openMenu(); + testOptions.forEach((opt) => { + expect( + getByRole("option", { name: opt.label, selected: true }) + ).toBeVisible(); + }); +}); + +test("toggles selection", () => { + const { getByRole } = openMenu(); + const firstOption = getByRole("option", { name: testOptions[0].label }); + act(() => userEvent.click(firstOption)); + expect(firstOption.getAttribute("aria-selected")).toBe("false"); + const menuButton = getByRole("button"); + expect(menuButton).toHaveTextContent( + `${testOptions[1].label} and ${testOptions.length - 2} others` + ); + act(() => userEvent.click(firstOption)); + expect(firstOption.getAttribute("aria-selected")).toBe("true"); + expect(menuButton).toHaveTextContent( + `${testOptions[0].label} and ${testOptions.length - 1} others` + ); +}); + +test("toggles selection on mobile", () => { + // hook returns true on mobile + useBreakpoint.mockReturnValue(true); + + const { getByRole, getByText } = render( + + ); + const menu = getByRole("listbox", { name: /cohort/i }); + const valueEl = getByText((content, element) => { + // this pattern is permissive about spaces between words because textContent + // concatenated from arbitrary children may have random line breaks, etc + return new RegExp( + String.raw`^${testOptions[0].label}\s+and\s+${ + testOptions.length - 1 + }\s+others$` + ).test(element.textContent); + }); + + act(() => { + userEvent.deselectOptions(menu, testOptions[0].id); + }); + expect(valueEl).toHaveTextContent( + `${testOptions[1].label} and ${testOptions.length - 2} others` + ); + act(() => { + userEvent.selectOptions(menu, testOptions[0].id); + }); + expect(valueEl).toHaveTextContent( + `${testOptions[0].label} and ${testOptions.length - 1} others` + ); +}); + +test("sends initial selection to callback", () => { + render( + + ); + expect(mockOnChange.mock.calls.length).toBe(1); + // pass only the selected IDs, not the entire options object + expect(mockOnChange.mock.calls[0][0]).toEqual(testOptions); +}); + +test("sends updated selections to callback", () => { + const { getByRole } = openMenu(); + const firstOption = getByRole("option", { name: testOptions[0].label }); + act(() => userEvent.click(firstOption)); + expect(mockOnChange.mock.calls[1][0]).toEqual(testOptions.slice(1)); +}); + +test("applies colors to selected items", () => { + const { getByRole } = openMenu(); + testOptions.forEach((opt) => { + expect( + getByRole("option", { name: opt.label, selected: true }) + ).toHaveStyle(`background-color: ${opt.color}`); + }); + const firstOption = getByRole("option", { name: testOptions[0].label }); + act(() => userEvent.click(firstOption)); + expect(firstOption).not.toHaveStyle( + `background-color: ${testOptions[0].color}` + ); +}); + +test("passes highlighted option to callback", () => { + const { getByRole } = openMenu(); + testOptions.forEach((opt) => { + mockOnHighlight.mockClear(); + const menuItem = getByRole("option", { name: opt.label }); + act(() => userEvent.hover(menuItem)); + expect(mockOnHighlight.mock.calls[0][0]).toBe(opt); + }); +}); + +test("supports select-all", () => { + const { getByRole } = openMenu(); + const selectAll = getByRole("option", { name: /select all/i }); + + act(() => userEvent.click(selectAll)); + + // de-selects all + testOptions.forEach((opt) => { + expect(getByRole("option", { name: opt.label })).toHaveAttribute( + "aria-selected", + "false" + ); + }); + + // click again to select all + act(() => userEvent.click(selectAll)); + testOptions.forEach((opt) => { + expect(getByRole("option", { name: opt.label })).toHaveAttribute( + "aria-selected", + "true" + ); + }); + + // now de-select some manually and try again + testOptions.slice(2, 6).forEach((opt) => { + act(() => userEvent.click(getByRole("option", { name: opt.label }))); + expect(getByRole("option", { name: opt.label })).toHaveAttribute( + "aria-selected", + "false" + ); + }); + + act(() => userEvent.click(selectAll)); + // everything is selected again + testOptions.forEach((opt) => { + expect(getByRole("option", { name: opt.label })).toHaveAttribute( + "aria-selected", + "true" + ); + }); +}); diff --git a/public-dashboard-client/src/controls/Dropdown.js b/public-dashboard-client/src/controls/Dropdown.js index f5d5f150..8a4a0e20 100644 --- a/public-dashboard-client/src/controls/Dropdown.js +++ b/public-dashboard-client/src/controls/Dropdown.js @@ -15,21 +15,14 @@ import { ControlContainer, ControlLabel, ControlValue, - controlTypeProperties, + DropdownMenu as DropdownMenuBase, + DropdownMenuItem, DropdownOptionType, + DropdownWrapper as DropdownWrapperBase, + HiddenSelect, } from "./shared"; -const DropdownWrapper = styled(ControlContainer)` - position: relative; - z-index: ${(props) => props.theme.zIndex.control}; - - &.Dropdown--highlighted { - ${ControlValue} { - background: ${(props) => props.theme.colors.highlight}; - color: ${(props) => props.theme.colors.bodyLight}; - } - } - +const DropdownWrapper = styled(DropdownWrapperBase)` [data-reach-menu-button] { background: none; border: none; @@ -38,43 +31,12 @@ const DropdownWrapper = styled(ControlContainer)` } `; -const DropdownMenu = styled.div` - ${controlTypeProperties} - - background: ${(props) => props.theme.colors.controlBackground}; - border-radius: 15px; - list-style: none; - padding: 12px 0; - position: relative; - white-space: nowrap; - z-index: ${(props) => props.theme.zIndex.menu}; - +const DropdownMenu = styled(DropdownMenuBase)` [data-reach-menu-items] { &:focus { outline: none; } } - - [data-reach-menu-item] { - cursor: pointer; - padding: 6px 18px; - transition: all ${(props) => props.theme.transition.defaultTimeSettings}; - - &[data-selected], - &:hover { - background: ${(props) => props.theme.colors.highlight}; - color: ${(props) => props.theme.colors.bodyLight}; - } - } -`; - -const HiddenSelect = styled.select` - height: 100%; - left: 0; - opacity: 0; - position: absolute; - top: 0; - width: 100%; `; // if selectedId prop is provided, this behaves like a controlled component; @@ -136,7 +98,9 @@ export default function Dropdown({ key={option.id} onSelect={() => setCurrentOptionId(option.id)} > - {option.label} + + {option.label} + ))} diff --git a/public-dashboard-client/src/controls/Dropdown.test.js b/public-dashboard-client/src/controls/Dropdown.test.js new file mode 100644 index 00000000..6cf91966 --- /dev/null +++ b/public-dashboard-client/src/controls/Dropdown.test.js @@ -0,0 +1,78 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2020 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 userEvent from "@testing-library/user-event"; +import React from "react"; +import { render } from "../testUtils"; +import Dropdown from "./Dropdown"; + +const testLabel = "Test Label"; +const mockOnChange = jest.fn(); +let testOptions; + +beforeEach(() => { + testOptions = [ + { id: "1", label: "option one" }, + { id: "2", label: "option two" }, + { id: "3", label: "option three" }, + ]; + + jest.resetAllMocks(); +}); + +test("triggers menu from button", () => { + const { getByRole } = render( + + ); + + const menuButton = getByRole("button", { name: testLabel }); + expect(menuButton).toBeInTheDocument(); + + userEvent.click(menuButton); + + testOptions.forEach((opt) => { + expect(getByRole("menuitem", { name: opt.label })).toBeVisible(); + }); +}); + +test("selects first option by default", () => { + const { getAllByText } = render( + + ); + + // the expectations here are unfortunately somewhat indirect: + // the selected label appears twice; once in the menu, + // and once in the button displaying the selected value + expect(getAllByText(testOptions[0].label).length).toBe(2); + // an unselected label appears only once, in the menu + expect(getAllByText(testOptions[1].label).length).toBe(1); +}); + +test("passes selections to callback", () => { + const { getByRole } = render( + + ); + expect(mockOnChange.mock.calls[0][0]).toBe(testOptions[0].id); + + const menuButton = getByRole("button", { name: testLabel }); + userEvent.click(menuButton); + + const newOption = getByRole("menuitem", { name: testOptions[2].label }); + userEvent.click(newOption); + + expect(mockOnChange.mock.calls[1][0]).toBe(testOptions[2].id); +}); diff --git a/public-dashboard-client/src/controls/shared.js b/public-dashboard-client/src/controls/shared.js index 4376428e..843d19cc 100644 --- a/public-dashboard-client/src/controls/shared.js +++ b/public-dashboard-client/src/controls/shared.js @@ -30,3 +30,52 @@ export const DropdownOptionType = PropTypes.shape({ label: PropTypes.string.isRequired, hidden: PropTypes.bool, }); + +export const DropdownWrapper = styled(ControlContainer)` + position: relative; + z-index: ${(props) => props.theme.zIndex.control}; + + &.Dropdown--highlighted { + ${ControlValue} { + background: ${(props) => props.theme.colors.highlight}; + color: ${(props) => props.theme.colors.bodyLight}; + } + } +`; + +export const DropdownMenu = styled.div` + ${controlTypeProperties} + + background: ${(props) => props.theme.colors.controlBackground}; + border-radius: 15px; + list-style: none; + padding: 12px 0; + position: relative; + white-space: nowrap; + z-index: ${(props) => props.theme.zIndex.menu}; +`; + +export const DropdownMenuItem = styled.div` + cursor: pointer; + padding: 6px 18px; + transition: all ${(props) => props.theme.transition.defaultTimeSettings}; + + /* + because we use multiple dropdown libraries that represent menu UI states + in different ways, taking a selector as a prop lets us target highlight state generically + */ + &:hover${(props) => + props.highlightedSelector ? `, ${props.highlightedSelector}` : ""} { + background: ${(props) => props.theme.colors.highlight}; + color: ${(props) => props.theme.colors.bodyLight}; + } +`; + +export const HiddenSelect = styled.select` + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; +`; diff --git a/public-dashboard-client/src/detail-page/DetailPage.js b/public-dashboard-client/src/detail-page/DetailPage.js index 2841aa9d..2c189348 100644 --- a/public-dashboard-client/src/detail-page/DetailPage.js +++ b/public-dashboard-client/src/detail-page/DetailPage.js @@ -221,9 +221,8 @@ DetailSection.propTypes = { otherControls: PropTypes.node, stickyOffset: PropTypes.number, VizComponent: PropTypes.func, - vizData: PropTypes.objectOf( - PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.string]) - ), + // this will be passed through to a component that can validate for itself + vizData: PropTypes.objectOf(PropTypes.any), }; DetailSection.defaultProps = { diff --git a/public-dashboard-client/src/page-sentencing/PageSentencing.js b/public-dashboard-client/src/page-sentencing/PageSentencing.js index 9edf9c48..aaa497b9 100644 --- a/public-dashboard-client/src/page-sentencing/PageSentencing.js +++ b/public-dashboard-client/src/page-sentencing/PageSentencing.js @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import React from "react"; +import React, { useMemo, useState } from "react"; import DetailPage from "../detail-page"; import useChartData from "../hooks/useChartData"; import Loading from "../loading"; @@ -23,9 +23,31 @@ import VizRecidivismRates from "../viz-recidivism-rates"; import VizSentencePopulation from "../viz-sentence-population"; import VizSentenceTypes from "../viz-sentence-types"; import { PATHS, ALL_PAGES, SECTION_TITLES } from "../constants"; +import CohortSelect from "../controls/CohortSelect"; +import { assignOrderedDatavizColor } from "../utils"; + +function getCohortOptions(data) { + const cohortsFromData = new Set(data.map((d) => d.release_cohort)); + return [...cohortsFromData] + .map((cohort) => ({ + id: cohort, + label: cohort, + })) + .map(assignOrderedDatavizColor); +} export default function PageSentencing() { const { apiData, isLoading } = useChartData("us_nd/sentencing"); + // lifted state for the recidivism section + const cohortOptions = useMemo( + () => + isLoading + ? [] + : getCohortOptions(apiData.recidivism_rates_by_cohort_by_year), + [apiData.recidivism_rates_by_cohort_by_year, isLoading] + ); + const [selectedCohorts, setSelectedCohorts] = useState(); + const [highlightedCohort, setHighlightedCohort] = useState(); if (isLoading) { return ; @@ -92,9 +114,18 @@ export default function PageSentencing() { some point after their release. ), + otherControls: ( + + ), VizComponent: VizRecidivismRates, vizData: { + highlightedCohort, recidivismRates: apiData.recidivism_rates_by_cohort_by_year, + selectedCohorts, }, }, ]; diff --git a/public-dashboard-client/src/utils/assignOrderedDatavizColor.js b/public-dashboard-client/src/utils/assignOrderedDatavizColor.js new file mode 100644 index 00000000..1a627435 --- /dev/null +++ b/public-dashboard-client/src/utils/assignOrderedDatavizColor.js @@ -0,0 +1,7 @@ +import { THEME } from "../theme"; + +const DATAVIZ_COLORS = THEME.colors.dataViz; + +export default function assignOrderedDatavizColor(record, i) { + return { ...record, color: DATAVIZ_COLORS[i % DATAVIZ_COLORS.length] }; +} diff --git a/public-dashboard-client/src/utils/index.js b/public-dashboard-client/src/utils/index.js index 8c6f55f3..b130336e 100644 --- a/public-dashboard-client/src/utils/index.js +++ b/public-dashboard-client/src/utils/index.js @@ -1,4 +1,5 @@ export { default as addEmptyMonthsToData } from "./addEmptyMonthsToData"; +export { default as assignOrderedDatavizColor } from "./assignOrderedDatavizColor"; export { default as categoryIsNotUnknown } from "./categoryIsNotUnknown"; export { default as demographicsAscending } from "./demographicsAscending"; export { default as fluidFontSizeStyles } from "./fluidFontSizeStyles"; diff --git a/public-dashboard-client/src/viz-population-over-time/VizPopulationOverTime.js b/public-dashboard-client/src/viz-population-over-time/VizPopulationOverTime.js index c07e2ada..645a6023 100644 --- a/public-dashboard-client/src/viz-population-over-time/VizPopulationOverTime.js +++ b/public-dashboard-client/src/viz-population-over-time/VizPopulationOverTime.js @@ -186,10 +186,7 @@ export default function VizPopulationOverTime({ }} pointStyle={{ display: "none" }} xAccessor="time" - xExtent={[dateRangeStart, dateRangeEnd]} - xScaleType={scaleTime()} yAccessor="population" - yExtent={[0]} /> diff --git a/public-dashboard-client/src/viz-recidivism-rates/RecidivismRatesChart.js b/public-dashboard-client/src/viz-recidivism-rates/RecidivismRatesChart.js index 03fb2dac..af800569 100644 --- a/public-dashboard-client/src/viz-recidivism-rates/RecidivismRatesChart.js +++ b/public-dashboard-client/src/viz-recidivism-rates/RecidivismRatesChart.js @@ -16,7 +16,7 @@ // ============================================================================= import PropTypes from "prop-types"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Measure from "react-measure"; import XYFrame from "semiotic/lib/XYFrame"; import styled from "styled-components"; @@ -34,7 +34,6 @@ const BASE_MARK_PROPS = { }; const CHART_TITLE = "Cumulative Recidivism Rate"; -const LINE_COLORS = THEME.colors.dataViz; const MARGIN = { bottom: 65, left: 56, right: 16, top: 48 }; const ChartWrapper = styled(ChartWrapperBase)` @@ -51,21 +50,19 @@ const LegendWrapper = styled.div` const Wrapper = styled.div``; -function addColorToRecord(record, i) { - return { ...record, color: LINE_COLORS[i % LINE_COLORS.length] }; -} - -export default function RecidivismRatesChart({ data }) { +export default function RecidivismRatesChart({ data, highlightedCohort }) { const [highlighted, setHighlighted] = useState(); - const chartData = data.map(addColorToRecord); + useEffect(() => { + setHighlighted(highlightedCohort); + }, [highlightedCohort]); const points = highlighted - ? chartData.find((d) => d.label === highlighted.label).coordinates + ? (data.find((d) => d.label === highlighted.label) || {}).coordinates || [] : []; const pointColor = highlighted - ? chartData.find((d) => d.label === highlighted.label).color + ? (data.find((d) => d.label === highlighted.label) || {}).color : undefined; return ( @@ -79,8 +76,11 @@ export default function RecidivismRatesChart({ data }) { { @@ -128,12 +128,16 @@ export default function RecidivismRatesChart({ data }) { strokeWidth: 2, }; }} - lines={chartData} + lines={data} points={points} pointStyle={{ fill: pointColor, r: 5, }} + renderKey={(d) => + // if it has a label, it's a line; if not, it's a point + d.label || `${d.releaseCohort}-${d.followupYears}` + } title={ {CHART_TITLE} @@ -148,7 +152,7 @@ export default function RecidivismRatesChart({ data }) { @@ -170,4 +174,11 @@ RecidivismRatesChart.propTypes = { ).isRequired, }) ).isRequired, + highlightedCohort: PropTypes.shape({ + label: PropTypes.string.isRequired, + }), +}; + +RecidivismRatesChart.defaultProps = { + highlightedCohort: undefined, }; diff --git a/public-dashboard-client/src/viz-recidivism-rates/VizRecidivismRates.js b/public-dashboard-client/src/viz-recidivism-rates/VizRecidivismRates.js index 4cf6b780..70ae2be2 100644 --- a/public-dashboard-client/src/viz-recidivism-rates/VizRecidivismRates.js +++ b/public-dashboard-client/src/viz-recidivism-rates/VizRecidivismRates.js @@ -18,6 +18,7 @@ import { group } from "d3-array"; import PropTypes from "prop-types"; import React from "react"; +import { assignOrderedDatavizColor } from "../utils"; import RecidivismRatesChart from "./RecidivismRatesChart"; function typeCast(recidivismRecord) { @@ -36,22 +37,39 @@ function typeCast(recidivismRecord) { }; } -function prepareChartData(rawData) { +function prepareChartData({ data, selectedCohorts }) { return Array.from( - group(rawData.map(typeCast), (d) => d.releaseCohort), + group(data.map(typeCast), (d) => d.releaseCohort), ([key, value]) => { return { label: key, coordinates: value, }; } - ); + ) + .map(assignOrderedDatavizColor) + .filter((record) => { + if (!selectedCohorts) { + return true; + } + return selectedCohorts.some(({ id }) => id === record.label); + }); } -export default function VizRecidivismRates({ data: { recidivismRates } }) { - const chartData = prepareChartData(recidivismRates); +export default function VizRecidivismRates({ + data: { recidivismRates, selectedCohorts, highlightedCohort }, +}) { + const chartData = prepareChartData({ + data: recidivismRates, + selectedCohorts, + }); - return ; + return ( + + ); } VizRecidivismRates.propTypes = { @@ -63,5 +81,10 @@ VizRecidivismRates.propTypes = { release_cohort: PropTypes.string.isRequired, }) ).isRequired, + selectedCohorts: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string.isRequired }) + ), + // this will be passed through to the chart, let that component validate it + highlightedCohort: PropTypes.any, }).isRequired, }; diff --git a/public-dashboard-client/src/x-hover-controller/XHoverController.js b/public-dashboard-client/src/x-hover-controller/XHoverController.js index 134b4244..0168faba 100644 --- a/public-dashboard-client/src/x-hover-controller/XHoverController.js +++ b/public-dashboard-client/src/x-hover-controller/XHoverController.js @@ -25,9 +25,10 @@ import ResponsiveTooltipController from "../responsive-tooltip-controller"; const TOOLTIP_OFFSET = 8; const OverlayContainer = styled.div` + left: 0; position: absolute; top: 0; - left: 0; + z-index: ${(props) => props.theme.zIndex.base}; .frame { .annotation-xy-label { @@ -56,7 +57,6 @@ const OverlayContainer = styled.div` const Wrapper = styled.div` position: relative; - z-index: ${(props) => props.theme.zIndex.tooltip}; `; /** @@ -92,7 +92,7 @@ export default function XHoverController({ If the child has different settings for these, they will be clobbered. */} {React.Children.map(children, (child) => - React.cloneElement(child, { margin, size }) + React.cloneElement(child, { margin, size, ...otherChartProps }) )} { const { getByRole } = render(); // seems like a pretty safe bet this word will always be there somewhere! - const websiteName = getByRole("heading", /spotlight/i); + const websiteName = getByRole("heading", { name: /spotlight/i }); expect(websiteName).toBeInTheDocument(); }); diff --git a/yarn.lock b/yarn.lock index b2598fd1..56648677 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,6 +1151,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.11.2": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.1.tgz#b4116a6b6711d010b2dad3b7b6e43bf1b9954740" + integrity sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.4.0", "@babel/template@^7.8.6": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" @@ -1866,6 +1873,20 @@ pretty-format "^25.1.0" wait-for-expect "^3.0.2" +"@testing-library/dom@^7.26.3": + version "7.26.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.3.tgz#5554ee985f712d621bd676104b879f85d9a7a0ef" + integrity sha512-/1P6taENE/H12TofJaS3L1J28HnXx8ZFhc338+XPR5y1E3g5ttOgu86DsGnV9/n2iPrfJQVUZ8eiGYZGSxculw== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.10.3" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.1" + lz-string "^1.4.4" + pretty-format "^26.4.2" + "@testing-library/jest-dom@^4.2.4": version "4.2.4" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-4.2.4.tgz#00dfa0cbdd837d9a3c2a7f3f0a248ea6e7b89742" @@ -1890,6 +1911,13 @@ "@testing-library/dom" "^6.15.0" "@types/testing-library__react" "^9.1.2" +"@testing-library/user-event@^12.1.10": + version "12.1.10" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-12.1.10.tgz#e043ef5aa10e4b3e56b434e383d2fbfef1fcfb7f" + integrity sha512-StlNdKHp2Rpb7yrny/5/CGpz8bR3jLa1Ge59ODGU6TmAhkrxSpvR6tCD1gaMFkkjEUWkmmye8BaXsZPcaiJ6Ug== + dependencies: + "@babel/runtime" "^7.10.2" + "@testing-library/user-event@^7.1.2": version "7.2.1" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-7.2.1.tgz#2ad4e844175a3738cb9e7064be5ea070b8863a1c" @@ -2473,6 +2501,21 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +airbnb-prop-types@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2" + integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg== + dependencies: + array.prototype.find "^2.1.1" + function.prototype.name "^1.1.2" + is-regex "^1.1.0" + object-is "^1.1.2" + object.assign "^4.1.0" + object.entries "^1.1.2" + prop-types "^15.7.2" + prop-types-exact "^1.2.0" + react-is "^16.13.1" + ajv-errors@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" @@ -2694,6 +2737,14 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +array.prototype.find@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.1.tgz#3baca26108ca7affb08db06bf0be6cb3115a969c" + integrity sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.4" + array.prototype.flat@^1.2.1: version "1.2.3" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" @@ -3852,6 +3903,11 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.14: + version "1.0.16" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" + integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -5089,6 +5145,16 @@ dotenv@8.2.0, dotenv@^8.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +downshift@^6.0.6: + version "6.0.6" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-6.0.6.tgz#82aee8e2e260d7ad99df8a0969bd002dd523abe8" + integrity sha512-tmLab3cXCn6PtZYl9V8r/nB2m+7/nCNrwo0B3kTHo/2lRBHr+1en1VNOQt2wIt0ajanAnxquZ00WPCyxe6cNFQ== + dependencies: + "@babel/runtime" "^7.11.2" + compute-scroll-into-view "^1.0.14" + prop-types "^15.7.2" + react-is "^16.13.1" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -5271,6 +5337,23 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstrac string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" +es-abstract@^1.17.4: + version "1.17.7" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-abstract@^1.18.0-next.0: version "1.18.0-next.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc" @@ -5289,6 +5372,24 @@ es-abstract@^1.18.0-next.0: string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" +es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + es-cookie@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/es-cookie/-/es-cookie-1.3.2.tgz#80e831597f72a25721701bdcb21d990319acd831" @@ -6259,11 +6360,25 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function.prototype.name@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.2.tgz#5cdf79d7c05db401591dfde83e3b70c5123e9a45" + integrity sha512-C8A+LlHBJjB2AdcRPorc5JvJ5VUoWlXdEHLOJdCI7kjHEtGTpHQUiqMvCIKUwIsGwZX2jZJy761AXsn356bJQg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + functions-have-names "^1.2.0" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +functions-have-names@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.1.tgz#a981ac397fa0c9964551402cdc5533d7a4d52f91" + integrity sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA== + gaxios@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-3.2.0.tgz#11b6f0e8fb08d94a10d4d58b044ad3bec6dd486a" @@ -7198,7 +7313,7 @@ is-buffer@^1.0.2, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.4, is-callable@^1.2.0: +is-callable@^1.1.4, is-callable@^1.2.0, is-callable@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== @@ -8506,6 +8621,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -9155,6 +9275,14 @@ object-is@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" +object-is@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81" + integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -9177,7 +9305,7 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" -object.assign@^4.1.0: +object.assign@^4.1.0, object.assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd" integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA== @@ -10519,6 +10647,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.4" +prop-types-exact@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" + integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== + dependencies: + has "^1.0.3" + object.assign "^4.1.0" + reflect.ownkeys "^0.2.0" + prop-types@15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" @@ -10819,7 +10956,7 @@ react-helmet@^6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" -react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: +react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -11030,6 +11167,11 @@ referrer-policy@1.2.0: resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e" integrity sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA== +reflect.ownkeys@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" + integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= + regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -12266,10 +12408,10 @@ style-loader@0.23.1: loader-utils "^1.1.0" schema-utils "^1.0.0" -styled-components@^5.1.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.2.0.tgz#6dcb5aa8a629c84b8d5ab34b7167e3e0c6f7ed74" - integrity sha512-9qE8Vgp8C5cpGAIdFaQVAl89Zgx1TDM4Yf4tlHbO9cPijtpSXTMLHy9lmP0lb+yImhgPFb1AmZ1qMUubmg3HLg== +styled-components@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d" + integrity sha512-1ps8ZAYu2Husx+Vz8D+MvXwEwvMwFv+hqqUwhNlDN5ybg6A+3xyW1ECrAgywhvXapNfXiz79jJyU0x22z0FFTg== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/traverse" "^7.4.5"