Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add demographic breakdown to recidivism chart #238

Merged
merged 29 commits into from
Oct 30, 2020
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
65739c9
Cohort select functionality (unstyled)
macfarlandian Oct 23, 2020
3e76281
tests for dropdown before refactoring
macfarlandian Oct 23, 2020
48a0e4e
fix excessive z-index in chart overlay
macfarlandian Oct 23, 2020
e75971c
menu UI placement and styling
macfarlandian Oct 23, 2020
de6b235
XHoverController should pass through otherChartProps
macfarlandian Oct 23, 2020
46968a0
connect cohort control to chart
macfarlandian Oct 23, 2020
ad68d74
proper button cursor
macfarlandian Oct 23, 2020
9d33c30
cleanup and comments
macfarlandian Oct 24, 2020
ed37a07
fix typing error in spotlight tests
macfarlandian Oct 24, 2020
a706efb
add a select all option
macfarlandian Oct 24, 2020
05a5cbb
use native select element on mobile
macfarlandian Oct 26, 2020
15a2b43
test mobile menu interaction
macfarlandian Oct 26, 2020
c20e29d
update demo data with demographic breakdowns
macfarlandian Oct 27, 2020
c06be24
enable dimension control for single selected cohort
macfarlandian Oct 27, 2020
fb03070
pass demographic breakdown to chart when active
macfarlandian Oct 28, 2020
d878e2d
unique render keys for demographic line points
macfarlandian Oct 28, 2020
3dc5fbd
fix lint error
macfarlandian Oct 28, 2020
eeb4680
select all clears selection when all are selected
macfarlandian Oct 28, 2020
dd73e81
Merge branch 'master' into ian/233-recidivism-filters
macfarlandian Oct 28, 2020
3bd8b3b
vary colors in cohort select test
macfarlandian Oct 28, 2020
4ca77be
somehow I introduced a gremlin while resolving a merge conflict!
macfarlandian Oct 28, 2020
2fe23c7
Merge branch 'ian/233-recidivism-filters' into ian/233-recidivism-dem…
macfarlandian Oct 28, 2020
20fa41c
add license snippet to all touched files
macfarlandian Oct 28, 2020
cd06b52
make sure all points and lines have a "key" property for use as rende…
macfarlandian Oct 29, 2020
d241289
add some basic chart tests
macfarlandian Oct 29, 2020
fa88dbd
remove commented out code
macfarlandian Oct 29, 2020
71418f5
fix z-index issue with multiple menus
macfarlandian Oct 29, 2020
e28efed
Merge branch 'master' into ian/233-recidivism-demographics
macfarlandian Oct 30, 2020
e05a373
remove duplicate code from merge conflict
macfarlandian Oct 30, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public-dashboard-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down
21 changes: 21 additions & 0 deletions public-dashboard-client/src/assets/icons/checkMark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
300 changes: 300 additions & 0 deletions public-dashboard-client/src/controls/CohortSelect.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// =============================================================================

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 (
<>
<ControlLabel as="label" {...labelProps}>
Cohort
</ControlLabel>
<ControlValue as="button" type="button" {...toggleButtonProps}>
{buttonContents}
</ControlValue>
<DropdownMenu {...getMenuProps()} as="ul">
{isOpen &&
visibleOptions.map((opt, index) => {
const isSelected = selected.includes(opt);
const itemProps = getItemProps({ item: opt, index });
return (
<DropdownMenuItem
{...itemProps}
aria-selected={isSelected}
as="li"
backgroundColor={isSelected ? opt.color : undefined}
highlightedSelector={
highlightedIndex === index ? `&#${itemProps.id}` : undefined
}
key={opt.id}
>
<MenuItemContents>
{opt.label}
<MenuItemCheckMark src={checkMarkPath} />
</MenuItemContents>
</DropdownMenuItem>
);
})}
</DropdownMenu>
</>
);
}

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 (
<>
<ControlLabel aria-hidden>Cohort</ControlLabel>
<ControlValue aria-hidden>{buttonContents}</ControlValue>
<HiddenSelect
aria-label="Cohort"
multiple
onChange={(event) => {
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) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</HiddenSelect>
</>
);
}

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 && (
<em>
&nbsp;and {selected.length - 1} other{selected.length > 2 ? "s" : ""}
</em>
)}
</>
);

const renderNativeSelect = useBreakpoint(false, ["mobile-", true]);

return (
<DropdownWrapper>
{renderNativeSelect ? (
<NativeSelect
buttonContents={buttonContents}
options={options}
selected={selected}
setSelected={setSelected}
/>
) : (
<CustomSelect
buttonContents={buttonContents}
onHighlight={onHighlight}
options={options}
selected={selected}
setSelected={setSelected}
/>
)}
</DropdownWrapper>
);
}

CohortSelectMenu.propTypes = {
onChange: PropTypes.func.isRequired,
onHighlight: PropTypes.func.isRequired,
options: PropTypes.arrayOf(
and([DropdownOptionType, PropTypes.shape({ color: PropTypes.string })])
).isRequired,
};
Loading