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"