Merge pull request #2476 from ayshiff/feature/filter-component

Feature: Filter component
This commit is contained in:
Marcus Eide
2020-10-08 13:25:04 +02:00
committed by GitHub
15 changed files with 1233 additions and 26 deletions
+1
View File
@@ -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>
);
};
+144 -26
View File
@@ -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>
);
}
+5
View File
@@ -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"