Skip to content

Commit

Permalink
Merge pull request #299 from icflorescu:feature/filterable_columns
Browse files Browse the repository at this point in the history
Feature/filterable_columns
  • Loading branch information
icflorescu authored May 22, 2023
2 parents 1629191 + 626aa94 commit d7fb2c6
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 72 deletions.
135 changes: 92 additions & 43 deletions docs/examples/SearchingAndFilteringExample.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,122 @@
import { Box, Checkbox, Grid, TextInput } from '@mantine/core';
import { Box, Button, MultiSelect, Stack, TextInput } from '@mantine/core';
import { DatePicker, type DatesRangeValue } from '@mantine/dates';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { DataTable } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { employees } from '~/data';

const initialRecords = employees.slice(0, 100);

export default function SearchingAndFilteringExample() {
const [records, setRecords] = useState(initialRecords);

const departments = useMemo(() => {
const departments = new Set(employees.map((e) => e.department.name));
return [...departments];
}, []);

const [query, setQuery] = useState('');
const [veteransOnly, setVeteransOnly] = useState(false);
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
const [birthdaySearchRange, setBirthdaySearchRange] = useState<DatesRangeValue>();
const [debouncedQuery] = useDebouncedValue(query, 200);

useEffect(() => {
const now = dayjs();
setRecords(
initialRecords.filter(({ firstName, lastName, department, birthDate }) => {
if (veteransOnly && now.diff(birthDate, 'years') < 40) {
if (
debouncedQuery !== '' &&
!`${firstName} ${lastName}`.toLowerCase().includes(debouncedQuery.trim().toLowerCase())
) {
return false;
}

if (
debouncedQuery !== '' &&
!`${firstName} ${lastName} ${department.name} ${department.company.name}`
.toLowerCase()
.includes(debouncedQuery.trim().toLowerCase())
birthdaySearchRange &&
birthdaySearchRange[0] &&
birthdaySearchRange[1] &&
(dayjs(birthdaySearchRange[0]).isAfter(birthDate, 'day') ||
dayjs(birthdaySearchRange[1]).isBefore(birthDate, 'day'))
) {
return false;
}

if (selectedDepartments.length && !selectedDepartments.some((d) => d === department.name)) {
return false;
}
return true;
})
);
}, [debouncedQuery, veteransOnly]);
}, [debouncedQuery, birthdaySearchRange, selectedDepartments]);

return (
<>
<Grid align="center" mb="md">
<Grid.Col xs={8} sm={9}>
<TextInput
sx={{ flexBasis: '60%' }}
placeholder="Search employees..."
icon={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col xs={4} sm={3}>
<Checkbox
label="Over 40 years old"
checked={veteransOnly}
onChange={(e) => setVeteransOnly(e.currentTarget.checked)}
/>
</Grid.Col>
</Grid>
<Box sx={{ height: 300 }}>
<DataTable
withBorder
records={records}
columns={[
{ accessor: 'name', render: ({ firstName, lastName }) => `${firstName} ${lastName}` },
{ accessor: 'department.name' },
{ accessor: 'department.company.name' },
{ accessor: 'birthDate', render: ({ birthDate }) => dayjs(birthDate).format('MMM DD YYYY') },
{ accessor: 'age', render: ({ birthDate }) => dayjs().diff(birthDate, 'y') },
]}
/>
</Box>
</>
<Box sx={{ height: 300 }}>
<DataTable
withBorder
records={records}
columns={[
{
accessor: 'name',
render: ({ firstName, lastName }) => `${firstName} ${lastName}`,
filter: (
<TextInput
label="Employees"
description="Show employees whose names include the specified text"
placeholder="Search employees..."
icon={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== '',
},
{
accessor: 'department.name',
filter: (
<MultiSelect
label="Departments "
description="Show all employees working at the selected departments"
data={departments}
value={selectedDepartments}
placeholder="Search departments…"
onChange={setSelectedDepartments}
icon={<IconSearch size={16} />}
clearable
searchable
/>
),
filtering: selectedDepartments.length > 0,
},
{ accessor: 'department.company.name' },
{
accessor: 'birthDate',
render: ({ birthDate }) => dayjs(birthDate).format('MMM DD YYYY'),
filter: ({ close }) => (
<Stack>
<DatePicker
maxDate={new Date()}
type="range"
value={birthdaySearchRange}
onChange={setBirthdaySearchRange}
/>
<Button
disabled={!birthdaySearchRange}
color="red"
onClick={() => {
setBirthdaySearchRange(undefined);
close();
}}
>
Reset
</Button>
</Stack>
),
filtering: Boolean(birthdaySearchRange),
},
{ accessor: 'age', render: ({ birthDate }) => dayjs().diff(birthDate, 'y') },
]}
/>
</Box>
);
}
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@faker-js/faker": "^8.0.1",
"@formkit/auto-animate": "^1.0.0-beta.6",
"@mantine/core": "^6.0.11",
"@mantine/dates": "^6.0.11",
"@mantine/hooks": "^6.0.11",
"@mantine/modals": "^6.0.11",
"@mantine/next": "^6.0.11",
Expand Down
4 changes: 4 additions & 0 deletions package/DataTableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export default forwardRef(function DataTableHeader<T>(
titleClassName,
titleStyle,
titleSx,
filter,
filtering,
}) =>
hidden ? null : (
<DataTableHeaderCell<T>
Expand All @@ -90,6 +92,8 @@ export default forwardRef(function DataTableHeader<T>(
sortStatus={sortStatus}
sortIcons={sortIcons}
onSortStatusChange={onSortStatusChange}
filter={filter}
filtering={filtering}
/>
)
)}
Expand Down
77 changes: 48 additions & 29 deletions package/DataTableHeaderCell.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Box, Center, createStyles, Group, type MantineTheme, type Sx } from '@mantine/core';
import { IconArrowsVertical, IconArrowUp } from '@tabler/icons-react';
import { Box, Center, createStyles, Group, ActionIcon, Popover, Slider, type MantineTheme, type Sx } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconArrowsVertical, IconArrowUp, IconFilter } from '@tabler/icons-react';
import type { CSSProperties, ReactNode } from 'react';
import type { DataTableColumn, DataTableSortProps } from './types';
import { humanize, useMediaQueryStringOrFunction } from './utils';
import { type BaseSyntheticEvent } from 'react';

const useStyles = createStyles((theme) => ({
sortableColumnHeader: {
cursor: 'pointer',
transition: 'background .15s ease',
'&:hover': {
'&:hover:not(:has(button:hover))': {
background: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
},
},
Expand Down Expand Up @@ -48,7 +50,22 @@ type DataTableHeaderCellProps<T> = {
sortStatus: DataTableSortProps['sortStatus'];
sortIcons: DataTableSortProps['sortIcons'];
onSortStatusChange: DataTableSortProps['onSortStatusChange'];
} & Pick<DataTableColumn<T>, 'accessor' | 'sortable' | 'textAlignment' | 'width'>;
} & Pick<DataTableColumn<T>, 'accessor' | 'sortable' | 'textAlignment' | 'width' | 'filter' | 'filtering'>;

function Filter<T>({ children, isActive }: { children: DataTableColumn<T>['filter'], isActive: boolean }) {
const [isOpen, {close, toggle}] = useDisclosure(false);

return <Popover withArrow withinPortal shadow="md" opened={isOpen} onClose={close} trapFocus>
<Popover.Target>
<ActionIcon onClick={e => { e.preventDefault(); toggle(); }} variant={isActive ? 'default' : 'subtle'}>
<IconFilter size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
{typeof children === 'function' ? children({ close }) : children}
</Popover.Dropdown>
</Popover>
}

export default function DataTableHeaderCell<T>({
className,
Expand All @@ -63,14 +80,17 @@ export default function DataTableHeaderCell<T>({
width,
sortStatus,
onSortStatusChange,
filter,
filtering
}: DataTableHeaderCellProps<T>) {
const { cx, classes } = useStyles();
if (!useMediaQueryStringOrFunction(visibleMediaQuery)) return null;
const text = title ?? humanize(accessor);
const tooltip = typeof text === 'string' ? text : undefined;
const sortAction =
sortable && onSortStatusChange
? () => {
? (e?: BaseSyntheticEvent) => {
if (e?.defaultPrevented) { return; }
onSortStatusChange({
columnAccessor: accessor,
direction:
Expand Down Expand Up @@ -101,32 +121,31 @@ export default function DataTableHeaderCell<T>({
onClick={sortAction}
onKeyDown={(e) => e.key === 'Enter' && sortAction?.()}
>
{sortable || sortStatus?.columnAccessor === accessor ? (
<Group className={classes.sortableColumnHeaderGroup} position="apart" noWrap>
<Box className={cx(classes.columnHeaderText, classes.sortableColumnHeaderText)} title={tooltip}>
{text}
</Box>
{sortStatus?.columnAccessor === accessor ? (
<Center
className={cx(classes.sortableColumnHeaderIcon, {
[classes.sortableColumnHeaderIconRotated]: sortStatus.direction === 'desc',
})}
role="img"
aria-label={`Sorted ${sortStatus.direction === 'desc' ? 'descending' : 'ascending'}`}
>
{sortIcons?.sorted || <IconArrowUp size={14} />}
</Center>
) : (
<Center className={classes.sortableColumnHeaderUnsortedIcon} role="img" aria-label="Not sorted">
{sortIcons?.unsorted || <IconArrowsVertical size={14} />}
</Center>
)}
</Group>
) : (
<Box className={classes.columnHeaderText} title={tooltip}>
<Group className={classes.sortableColumnHeaderGroup} position="apart" noWrap>
<Box className={cx(classes.columnHeaderText, classes.sortableColumnHeaderText)} title={tooltip}>
{text}
</Box>
)}
{sortable || sortStatus?.columnAccessor === accessor ? (
<>
{sortStatus?.columnAccessor === accessor ? (
<Center
className={cx(classes.sortableColumnHeaderIcon, {
[classes.sortableColumnHeaderIconRotated]: sortStatus.direction === 'desc',
})}
role="img"
aria-label={`Sorted ${sortStatus.direction === 'desc' ? 'descending' : 'ascending'}`}
>
{sortIcons?.sorted || <IconArrowUp size={14} />}
</Center>
) : (
<Center className={classes.sortableColumnHeaderUnsortedIcon} role="img" aria-label="Not sorted">
{sortIcons?.unsorted || <IconArrowsVertical size={14} />}
</Center>
)}
</>
) : null}
{filter ? <Filter isActive={!!filtering}>{filter}</Filter> : null}
</Group>
</Box>
);
}
33 changes: 33 additions & 0 deletions package/types/DataTableColumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ export type DataTableColumn<T> = {
*/
sortable?: boolean;

/**
* Optional node which provides the user with filtering options.
* If present, a filter button will be added to the column's header. Upon clicking that button,
* a pop-over will be opened which shows the provided node.
*
* Alternatively a method returning a node can be provided. It is provided props with a `close`
* method which allows programatically closing the pop-over.
*
* ```tsx
* // …
* columns={[
* {
* accessor: 'name',
* filter: ({ close }) => {
* return <Stack>
* <Button onClick={() => { setFilter(undefined); close(); }}>Reset</Button>
* </Stack>
* },
* }
* ]}
* // …
* ```
*
* Note: this property only takes care of rendering the node which provides the filtering options.
* It is assumed that the actual filtering is performed somewhere in user-land.
*/
filter?: ReactNode | ((filterProps: { close: () => void }) => ReactNode);

/**
* If true, filter icon will be styled differently to indicate the filter is in effect.
*/
filtering?: boolean;

/**
* Desired column width
*/
Expand Down
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,13 @@
react-remove-scroll "^2.5.5"
react-textarea-autosize "8.3.4"

"@mantine/dates@^6.0.11":
version "6.0.11"
resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-6.0.11.tgz#768098e9af40686869c9b5a37f5a2e13553498ba"
integrity sha512-R0+fZQoTH8ioiAiVA7RFBsO6NL4MPz3d1lin2QCi/rj3ICp/+8X+AG4jN1Uy+xtWgfPB+hjp5RJASyUa0hNqtw==
dependencies:
"@mantine/utils" "6.0.11"

"@mantine/hooks@^6.0.11":
version "6.0.11"
resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.11.tgz#1f31cc04aaaf1c24509452a263984647a2b494cf"
Expand Down

0 comments on commit d7fb2c6

Please sign in to comment.