Merge pull request #2476 from ayshiff/feature/filter-component
Feature: Filter component
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
"immer": "^7.0.9",
|
||||
"lodash": "^4.17.15",
|
||||
"material-table": "^1.69.1",
|
||||
"prop-types": "^15.7.2",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CheckboxTree } from '.';
|
||||
|
||||
const CHECKBOX_TREE_ITEMS = [
|
||||
{
|
||||
label: 'Genereic subcategory name 1',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Genereic subcategory name 2',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Genereic subcategory name 3',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'CheckboxTree',
|
||||
component: CheckboxTree,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<CheckboxTree
|
||||
onChange={() => {}}
|
||||
label="default"
|
||||
subCategories={CHECKBOX_TREE_ITEMS}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { CheckboxTree } from '.';
|
||||
|
||||
const CHECKBOX_TREE_ITEMS = [
|
||||
{
|
||||
label: 'Genereic subcategory name 1',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const minProps = {
|
||||
onChange: jest.fn(),
|
||||
label: 'Default',
|
||||
subCategories: CHECKBOX_TREE_ITEMS,
|
||||
};
|
||||
|
||||
describe('<CheckboxTree />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText, getByTestId } = render(<CheckboxTree {...minProps} />);
|
||||
|
||||
expect(getByText('Genereic subcategory name 1')).toBeInTheDocument();
|
||||
const checkbox = await getByTestId('expandable');
|
||||
|
||||
// Simulate click on expandable arrow
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Simulate click on option
|
||||
const option = getByText('Option 1');
|
||||
expect(getByText('Option 1')).toBeInTheDocument();
|
||||
fireEvent.click(option);
|
||||
expect(minProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/* eslint-disable guard-for-in */
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import produce from 'immer';
|
||||
|
||||
type IndexedObject<T> = {
|
||||
[key: string]: T;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
minWidth: 10,
|
||||
maxWidth: 360,
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&:active': {
|
||||
animation: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
nested: {
|
||||
paddingLeft: theme.spacing(5),
|
||||
height: '32px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
listItemIcon: {
|
||||
minWidth: 10,
|
||||
},
|
||||
listItem: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
'& span, & svg': {
|
||||
fontWeight: 'normal',
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/* SUB_CATEGORY */
|
||||
|
||||
type SubCategory = {
|
||||
label: string;
|
||||
isChecked?: boolean;
|
||||
isOpen?: boolean;
|
||||
options?: Option[];
|
||||
};
|
||||
|
||||
type SubCategoryWithIndexedOptions = {
|
||||
label: string;
|
||||
isChecked?: boolean;
|
||||
isOpen?: boolean;
|
||||
options: IndexedObject<Option>;
|
||||
};
|
||||
|
||||
/* OPTION */
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
isChecked?: boolean;
|
||||
};
|
||||
|
||||
export type CheckboxTreeProps = {
|
||||
subCategories: SubCategory[];
|
||||
label: string;
|
||||
triggerReset?: boolean;
|
||||
onChange: (arg: any) => any;
|
||||
};
|
||||
|
||||
/* REDUCER */
|
||||
|
||||
type checkOptionPayload = {
|
||||
subCategoryLabel: string;
|
||||
optionLabel: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'checkOption'; payload: checkOptionPayload }
|
||||
| { type: 'checkCategory'; payload: string }
|
||||
| { type: 'toggleCategory'; payload: string }
|
||||
| { type: 'triggerReset' };
|
||||
|
||||
const reducer = (
|
||||
state: IndexedObject<SubCategoryWithIndexedOptions>,
|
||||
action: Action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'checkOption': {
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload.subCategoryLabel];
|
||||
const option = category.options[action.payload.optionLabel];
|
||||
option.isChecked = !option.isChecked;
|
||||
category.isChecked = Object.values(category.options).every(
|
||||
o => o.isChecked,
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'checkCategory': {
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload];
|
||||
const options = category.options;
|
||||
category.isChecked = !category.isChecked;
|
||||
for (const option in options) {
|
||||
options[option].isChecked = category.isChecked;
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'toggleCategory':
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload];
|
||||
category.isOpen = !category.isOpen;
|
||||
});
|
||||
case 'triggerReset': {
|
||||
return produce(state, newState => {
|
||||
for (const category in newState) {
|
||||
newState[category].isChecked = false;
|
||||
for (const option in newState[category].options) {
|
||||
newState[category].options[option].isChecked =
|
||||
newState[category].isChecked;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const indexer = (
|
||||
arr: SubCategory[],
|
||||
): IndexedObject<SubCategoryWithIndexedOptions> =>
|
||||
arr.reduce((accumulator, el) => {
|
||||
if (el.options) {
|
||||
return {
|
||||
...accumulator,
|
||||
[el.label]: {
|
||||
label: el.label,
|
||||
isChecked: el.isChecked || false,
|
||||
isOpen: false,
|
||||
options: indexer(el.options),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...accumulator,
|
||||
[el.label]: { ...el, isChecked: el.isChecked || false },
|
||||
};
|
||||
}, {});
|
||||
|
||||
export const CheckboxTree = (props: CheckboxTreeProps) => {
|
||||
const { onChange } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, indexer(props.subCategories));
|
||||
|
||||
const handleOpen = (event: any, value: any) => {
|
||||
event.stopPropagation();
|
||||
dispatch({ type: 'toggleCategory', payload: value });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const values = Object.values(state).map(category => ({
|
||||
category: category.isChecked ? category.label : null,
|
||||
selectedChilds: Object.values(category.options)
|
||||
.filter(option => option.isChecked)
|
||||
.map(option => option.value),
|
||||
}));
|
||||
onChange(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'triggerReset' });
|
||||
}, [props.triggerReset]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="button">{props.label}</Typography>
|
||||
<List className={classes.root}>
|
||||
{Object.values(state).map(item => (
|
||||
<div key={item.label}>
|
||||
<ListItem
|
||||
className={classes.listItem}
|
||||
dense
|
||||
button
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkCategory',
|
||||
payload: item.label,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={item.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText className={classes.text} primary={item.label} />
|
||||
{Object.values(item.options).length ? (
|
||||
<>
|
||||
{item.isOpen ? (
|
||||
<ExpandLess
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
) : (
|
||||
<ExpandMore
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ListItem>
|
||||
<Collapse in={item.isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{Object.values(item.options).map(option => (
|
||||
<ListItem
|
||||
button
|
||||
key={option.label}
|
||||
className={classes.nested}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkOption',
|
||||
payload: {
|
||||
subCategoryLabel: item.label,
|
||||
optionLabel: option.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={option.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
className={classes.text}
|
||||
primary={option.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CheckboxTree } from './CheckboxTree';
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Select } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Select',
|
||||
component: Select,
|
||||
};
|
||||
|
||||
const SELECT_ITEMS = [
|
||||
{
|
||||
label: 'test 1',
|
||||
value: 'test_1',
|
||||
},
|
||||
{
|
||||
label: 'test 2',
|
||||
value: 'test_2',
|
||||
},
|
||||
{
|
||||
label: 'test 3',
|
||||
value: 'test_3',
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = () => (
|
||||
<Select
|
||||
onChange={() => {}}
|
||||
placeholder="All results"
|
||||
label="Default"
|
||||
items={SELECT_ITEMS}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Multiple = () => (
|
||||
<Select
|
||||
placeholder="All results"
|
||||
label="Multiple"
|
||||
items={SELECT_ITEMS}
|
||||
multiple
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { Select } from '.';
|
||||
|
||||
const SELECT_ITEMS = [
|
||||
{
|
||||
label: 'test 1',
|
||||
value: 'test_1',
|
||||
},
|
||||
{
|
||||
label: 'test 2',
|
||||
value: 'test_2',
|
||||
},
|
||||
];
|
||||
|
||||
const minProps = {
|
||||
onChange: jest.fn(),
|
||||
label: 'Default',
|
||||
placeholder: 'All results',
|
||||
items: SELECT_ITEMS,
|
||||
};
|
||||
|
||||
describe('<Select />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText, getByTestId } = render(<Select {...minProps} />);
|
||||
|
||||
expect(getByText('Default')).toBeInTheDocument();
|
||||
const input = await getByTestId('select');
|
||||
expect(input.textContent).toBe('All results');
|
||||
|
||||
// Simulate click on input
|
||||
fireEvent.click(input);
|
||||
|
||||
expect(getByText('test 1')).toBeInTheDocument();
|
||||
const option = getByText('test 1');
|
||||
|
||||
// Simulate click on option
|
||||
fireEvent.click(option);
|
||||
expect(input.textContent).toBe('test 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
createStyles,
|
||||
makeStyles,
|
||||
withStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core/styles';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputBase,
|
||||
Chip,
|
||||
Typography,
|
||||
Checkbox,
|
||||
ClickAwayListener,
|
||||
} from '@material-ui/core';
|
||||
|
||||
import ClosedDropdown from './static/ClosedDropdown';
|
||||
import OpenedDropdown from './static/OpenedDropdown';
|
||||
|
||||
const BootstrapInput = withStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
'label + &': {
|
||||
marginTop: theme.spacing(3),
|
||||
},
|
||||
},
|
||||
input: {
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: '1px solid #ced4da',
|
||||
fontSize: 16,
|
||||
padding: '10px 26px 10px 12px',
|
||||
transition: theme.transitions.create(['border-color', 'box-shadow']),
|
||||
fontFamily: 'Helvetica Neue',
|
||||
'&:focus': {
|
||||
background: theme.palette.background.paper,
|
||||
borderRadius: 4,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)(InputBase);
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
formControl: {
|
||||
margin: `${theme.spacing(1)} 0px`,
|
||||
maxWidth: 300,
|
||||
},
|
||||
label: {
|
||||
transform: 'initial',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
color: theme.palette.text.primary,
|
||||
'&.Mui-focused': {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
chips: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
chip: {
|
||||
margin: 2,
|
||||
},
|
||||
checkbox: {},
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type Item = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
multiple?: boolean;
|
||||
items: Item[];
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
onChange: (arg: any) => any;
|
||||
triggerReset?: boolean;
|
||||
};
|
||||
|
||||
export const SelectComponent = (props: SelectProps) => {
|
||||
const { multiple, items, label, placeholder, onChange } = props;
|
||||
const classes = useStyles();
|
||||
const [value, setValue] = useState<any[] | string | number>(
|
||||
multiple ? [] : '',
|
||||
);
|
||||
const [canOpen, setCanOpen] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(multiple ? [] : '');
|
||||
}, [props.triggerReset, multiple]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
|
||||
setValue(event.target.value as any);
|
||||
onChange(event.target.value);
|
||||
};
|
||||
|
||||
const selectHandleOnOpen = () => {
|
||||
setCanOpen(previous => {
|
||||
if (multiple) {
|
||||
return true;
|
||||
}
|
||||
return !previous;
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickAway = (event: React.ChangeEvent<any>) => {
|
||||
if (event.target.id !== 'menu-item') {
|
||||
setCanOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (selectedValue: string | number) => () => {
|
||||
const newValue = (value as any[]).filter(chip => chip !== selectedValue);
|
||||
setValue(newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Typography variant="button">{label}</Typography>
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<FormControl className={classes.formControl}>
|
||||
<Select
|
||||
value={value}
|
||||
data-testid="select"
|
||||
displayEmpty
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
onClick={selectHandleOnOpen}
|
||||
open={canOpen}
|
||||
input={<BootstrapInput />}
|
||||
renderValue={selected =>
|
||||
multiple && (value as any[]).length !== 0 ? (
|
||||
<div className={classes.chips}>
|
||||
{(selected as string[]).map(selectedValue => (
|
||||
<Chip
|
||||
key={items.find(el => el.value === selectedValue)?.value}
|
||||
label={
|
||||
items.find(el => el.value === selectedValue)?.label
|
||||
}
|
||||
clickable
|
||||
onDelete={handleDelete(selectedValue)}
|
||||
className={classes.chip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Typography>
|
||||
{(value as any[]).length === 0
|
||||
? placeholder || ''
|
||||
: items.find(el => el.value === selected)?.label}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
IconComponent={() =>
|
||||
!canOpen ? <ClosedDropdown /> : <OpenedDropdown />
|
||||
}
|
||||
MenuProps={{
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
},
|
||||
getContentAnchorEl: null,
|
||||
}}
|
||||
>
|
||||
{placeholder && !multiple && (
|
||||
<MenuItem value={[]}>{placeholder}</MenuItem>
|
||||
)}
|
||||
{items &&
|
||||
items.map(item => (
|
||||
<MenuItem id="menu-item" key={item.value} value={item.value}>
|
||||
{multiple && (
|
||||
<Checkbox
|
||||
color="primary"
|
||||
checked={(value as any[]).includes(item.value) || false}
|
||||
className={classes.checkbox}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SelectComponent as Select } from './Select';
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(() =>
|
||||
createStyles({
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
right: '4px',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ClosedDropdown = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<SvgIcon
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.5 8L6 9.5L12.0703 15.5703L18.1406 9.5L16.6406 8L12.0703 12.5703L7.5 8Z"
|
||||
fill="#616161"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosedDropdown;
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(() =>
|
||||
createStyles({
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
right: '4px',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const OpenedDropdown = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<SvgIcon
|
||||
className={classes.icon}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.5 16L18 14.5L11.9297 8.42969L5.85938 14.5L7.35938 16L11.9297 11.4297L16.5 16Z"
|
||||
fill="#616161"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenedDropdown;
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import { Select } from '../Select';
|
||||
import { CheckboxTree } from '../CheckboxTree';
|
||||
import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
|
||||
import { SelectProps } from '../Select/Select';
|
||||
|
||||
const useSubvalueCellStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
root: {
|
||||
height: '100%',
|
||||
width: '315px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginRight: theme.spacing(3),
|
||||
},
|
||||
value: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 18,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '60px',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${theme.palette.grey[500]}`,
|
||||
},
|
||||
filters: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > *': {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Without<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Filter = {
|
||||
type: 'select' | 'checkbox-tree' | 'multiple-select';
|
||||
element:
|
||||
| Without<CheckboxTreeProps, 'onChange'>
|
||||
| Without<SelectProps, 'onChange'>;
|
||||
};
|
||||
|
||||
export type SelectedFilters = {
|
||||
[key: string]: string | string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
filters: Filter[];
|
||||
onChangeFilters: (arg: any) => any;
|
||||
};
|
||||
|
||||
export const Filters = (props: Props) => {
|
||||
const classes = useSubvalueCellStyles();
|
||||
|
||||
const { onChangeFilters } = props;
|
||||
|
||||
const [filters, setFilters] = useState(props.filters);
|
||||
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({});
|
||||
const [reset, triggerReset] = useState(false);
|
||||
|
||||
// Trigger re-rendering
|
||||
const handleClick = () => {
|
||||
setSelectedFilters({});
|
||||
setFilters([...props.filters]);
|
||||
triggerReset(el => !el);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onChangeFilters(selectedFilters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedFilters]);
|
||||
|
||||
// As material table doesn't provide a way to add a column filter tab we will make our own filter logic
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.header}>
|
||||
<div className={classes.value}>Filters</div>
|
||||
<Button color="primary" onClick={handleClick}>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
<div className={classes.filters}>
|
||||
{filters?.length &&
|
||||
filters.map(filter =>
|
||||
filter.type === 'checkbox-tree' ? (
|
||||
<CheckboxTree
|
||||
triggerReset={reset}
|
||||
key={filter.element.label}
|
||||
{...(filter.element as CheckboxTreeProps)}
|
||||
onChange={el =>
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filter.element.label]: el
|
||||
.filter(
|
||||
(checkboxFilter: any) =>
|
||||
checkboxFilter.category !== null ||
|
||||
checkboxFilter.selectedChilds.length,
|
||||
)
|
||||
.map((checkboxFilter: any) =>
|
||||
checkboxFilter.category !== null
|
||||
? [
|
||||
...checkboxFilter.selectedChilds,
|
||||
checkboxFilter.category,
|
||||
]
|
||||
: checkboxFilter.selectedChilds,
|
||||
)
|
||||
.flat(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
triggerReset={reset}
|
||||
key={filter.element.label}
|
||||
{...(filter.element as SelectProps)}
|
||||
onChange={el =>
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filter.element.label]: el,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, SubvalueCell, TableColumn } from './';
|
||||
import { TableFilter } from './Table';
|
||||
|
||||
export default {
|
||||
title: 'Table',
|
||||
@@ -220,3 +221,53 @@ export const DenseTable = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterTable = () => {
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
field: 'col1',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
title: 'Column 2',
|
||||
field: 'col2',
|
||||
},
|
||||
{
|
||||
title: 'Numeric value',
|
||||
field: 'number',
|
||||
type: 'numeric',
|
||||
},
|
||||
{
|
||||
title: 'A Date',
|
||||
field: 'date',
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
const filters: TableFilter[] = [
|
||||
{
|
||||
column: 'Column 1',
|
||||
type: 'select',
|
||||
},
|
||||
{
|
||||
column: 'Column 2',
|
||||
type: 'multiple-select',
|
||||
},
|
||||
{
|
||||
column: 'Numeric value',
|
||||
type: 'checkbox-tree',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles, Typography, useTheme } from '@material-ui/core';
|
||||
import {
|
||||
makeStyles,
|
||||
Typography,
|
||||
useTheme,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
|
||||
import AddBox from '@material-ui/icons/AddBox';
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward';
|
||||
@@ -39,7 +44,8 @@ import MTable, {
|
||||
MTableToolbar,
|
||||
Options,
|
||||
} from 'material-table';
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import { Filters, SelectedFilters } from './Filters';
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
@@ -121,6 +127,26 @@ const useToolbarStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const useFilterStyles = makeStyles<BackstageTheme>(() => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 18,
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}));
|
||||
|
||||
const useTableStyles = makeStyles<BackstageTheme>(() => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'start',
|
||||
},
|
||||
}));
|
||||
|
||||
function convertColumns<T extends object>(
|
||||
columns: TableColumn<T>[],
|
||||
theme: BackstageTheme,
|
||||
@@ -148,10 +174,16 @@ export interface TableColumn<T extends object = {}> extends Column<T> {
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export type TableFilter = {
|
||||
column: string;
|
||||
type: 'select' | 'multiple-select' | 'checkbox-tree';
|
||||
};
|
||||
|
||||
export interface TableProps<T extends object = {}>
|
||||
extends MaterialTableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
subtitle?: string;
|
||||
filters?: TableFilter[];
|
||||
}
|
||||
|
||||
export function Table<T extends object = {}>({
|
||||
@@ -159,12 +191,22 @@ export function Table<T extends object = {}>({
|
||||
options,
|
||||
title,
|
||||
subtitle,
|
||||
filters,
|
||||
...props
|
||||
}: TableProps<T>) {
|
||||
const headerClasses = useHeaderStyles();
|
||||
const toolbarClasses = useToolbarStyles();
|
||||
const tableClasses = useTableStyles();
|
||||
const filtersClasses = useFilterStyles();
|
||||
|
||||
const { data, ...propsWithoutData } = props;
|
||||
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
|
||||
const [filtersOpen, toggleFilters] = useState(false);
|
||||
const [selectedFiltersLength, setSelectedFiltersLength] = useState(0);
|
||||
const [tableData, setTableData] = useState(data as any[]);
|
||||
|
||||
const MTColumns = convertColumns(columns, theme);
|
||||
|
||||
const defaultOptions: Options<T> = {
|
||||
@@ -173,30 +215,106 @@ export function Table<T extends object = {}>({
|
||||
},
|
||||
};
|
||||
|
||||
const getFieldByTitle = (titleValue: string | keyof T) =>
|
||||
columns.find(el => el.title === titleValue)?.field;
|
||||
|
||||
const onChangeFilters = (selectedFilters: SelectedFilters) => {
|
||||
const selectedFiltersArray = Object.values(selectedFilters);
|
||||
if (selectedFiltersArray.flat().length) {
|
||||
const newData = (props.data as any[]).filter(
|
||||
el =>
|
||||
!!Object.entries(selectedFilters)
|
||||
.filter(([, value]) => !!value.length)
|
||||
.every(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(el[getFieldByTitle(key)]);
|
||||
}
|
||||
return el[getFieldByTitle(key)] === value;
|
||||
}),
|
||||
);
|
||||
setTableData(newData);
|
||||
} else {
|
||||
setTableData(props.data as any[]);
|
||||
}
|
||||
setSelectedFiltersLength(selectedFiltersArray.flat().length);
|
||||
};
|
||||
|
||||
const constructFilters = (filterConfig: TableFilter[], dataValue: any[]) => {
|
||||
const extractColumnData = (column: string | keyof T) =>
|
||||
dataValue.map(el => ({ label: el[column], options: [] }));
|
||||
|
||||
return filterConfig.map(filter => ({
|
||||
type: filter.type,
|
||||
element:
|
||||
filter.type === 'checkbox-tree'
|
||||
? {
|
||||
label: filter.column,
|
||||
subCategories: extractColumnData(
|
||||
getFieldByTitle(filter.column) || '',
|
||||
),
|
||||
}
|
||||
: {
|
||||
placeholder: 'All results',
|
||||
label: filter.column,
|
||||
multiple: filter.type === 'multiple-select',
|
||||
items: dataValue.map(el => ({
|
||||
label: el[getFieldByTitle(filter.column) || ''],
|
||||
value: el[getFieldByTitle(filter.column) || ''],
|
||||
})),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<MTable<T>
|
||||
components={{
|
||||
Header: headerProps => (
|
||||
<MTableHeader classes={headerClasses} {...headerProps} />
|
||||
),
|
||||
Toolbar: toolbarProps => (
|
||||
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
|
||||
),
|
||||
}}
|
||||
options={{ ...defaultOptions, ...options }}
|
||||
columns={MTColumns}
|
||||
icons={tableIcons}
|
||||
title={
|
||||
<>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
{subtitle && (
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<div className={tableClasses.root}>
|
||||
{filtersOpen && filters?.length && (
|
||||
<Filters
|
||||
filters={constructFilters(filters, props.data as any[])}
|
||||
onChangeFilters={onChangeFilters}
|
||||
/>
|
||||
)}
|
||||
<MTable<T>
|
||||
components={{
|
||||
Header: headerProps => (
|
||||
<MTableHeader classes={headerClasses} {...headerProps} />
|
||||
),
|
||||
Toolbar: toolbarProps =>
|
||||
filters?.length ? (
|
||||
<div className={filtersClasses.root}>
|
||||
<div className={filtersClasses.root}>
|
||||
<IconButton
|
||||
onClick={() => toggleFilters(el => !el)}
|
||||
aria-label="filter list"
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
<Typography className={filtersClasses.title}>
|
||||
Filters ({selectedFiltersLength})
|
||||
</Typography>
|
||||
</div>
|
||||
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
|
||||
</div>
|
||||
) : (
|
||||
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
|
||||
),
|
||||
}}
|
||||
options={{ ...defaultOptions, ...options }}
|
||||
columns={MTColumns}
|
||||
icons={tableIcons}
|
||||
title={
|
||||
<>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
{subtitle && (
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
data={tableData}
|
||||
style={{ width: '100%' }}
|
||||
{...propsWithoutData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13076,6 +13076,11 @@ immer@1.10.0:
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
|
||||
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
|
||||
|
||||
immer@^7.0.9:
|
||||
version "7.0.9"
|
||||
resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e"
|
||||
integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==
|
||||
|
||||
immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
|
||||
|
||||
Reference in New Issue
Block a user