🐥
【React】フィルタリングできるテーブルフォームを実装する
つくったもの
useStateでテーブル全体のstateを管理するテーブルフォームを実装しようとしたのですが、テーブルが大きくなるとフォームの入力に対して再描画が追いつかず使い物になりませんでした。
対応として、stateの管理をuseStateからReact-Hook-Formに移行しました。
ついでにフィルタリング機能もつけました。UIコンポーネントはMaterial-UI(v4)を使用しています。
(下のgifは再生速度が遅いですが実物はもっとサクサク動きます)
コード
細かい実装内容は端折って、CSSの設定も割愛します。
Tableの実装
Table.tsx
import InputBase from '@material-ui/core/InputBase';
import FilterListIcon from '@material-ui/icons/FilterList';
import { Popover } from './Popover';
import type { CheckBoxItem } from './CheckBoxItem';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
type FormValues = {
tableItems: {
text: string;
col1: string;
col2: string;
}[]
};
const items = [
{key: 'key_1', label: 'A'},
{key: 'key_2', label: 'B'},
{key: 'key_3', label: 'AB'}
]
const initialCheckBoxItems = items.map((item) => ({
key: item.key,
label: item.label,
checked: true,
disabled: false,
onStateChange: () => {}
})),
export const Table = () => {
const { data: tableItems } = useItems();
const { control } = useForm<FormValues>(
defaultValues: { tableItems }
);
const { fields } = useFieldArray({
name: 'tableItems',
control
});
const [checkBoxItems, setCheckBoxItems] = useState<CheckBoxItem[]>(
initialCheckBoxItems
);
const checkedItems = checkBoxItems
.filter((item) => item.checked)
.map((item) => item.label);
const popoverRef = useRef(null);
const [anchorEl, setAnchorEl] = useState(null);
return (
<>
<Popover
initialItems={initialCheckBoxItems}
items={checkBoxItems}
setItems={setCheckBoxItems}
searchPlaceholder={'Search'}
checkBoxListLabel={'All items'}
onPopoverClose={() => {
setAnchorEl(null);
}}
anchorEl={anchorEl}
/>
<Table stickyHeader>
<TableHead>
<TableRow>
<HeaderCell text={'TextInput'} />
<HeaderCell text={'Column_1'} />
<HeaderCell
ref={popoverRef}
text={'Column_2'}
badgeCount={checkedItems.length}
onBadgeClick={() => {}}
icon={
<FilterListIcon
onClick={() => {
setAnchorEl(popoverRef.current);
}}
/>
}
/>
</TableRow>
</TableHead>
<TableBody>
{fields.map((field, index) => (
<>
{checkedItems.includes(field.col2) && (
<TableRow
hover
key={field.id}
>
<Controller
name={`field.${index}.text`}
control={control}
render={({ field }) => {
return (
<InputBase
placeholder={'placeholder'}
value={field.value}
onChange={field.onChange}
/>
);
}}
/>
<TextCell text={field.col1} />
<TextCell text={fiels.col2} />
</TableRow>
)}
</>
))}
</TableBody>
</Table>
</>
);
};
Popoverの実装
Popover.tsx
import Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import Divider from '@material-ui/core/Divider';
import InputBase from '@material-ui/core/InputBase';
import MuiPopover from '@material-ui/core/Popover';
import { CheckBoxList } from './CheckBoxList';
import type { CheckBoxItem } from './CheckBoxItem';
type Props = {
searchPlaceholder: string;
checkBoxListLabel: string;
initialItems: CheckBoxItem[];
items: CheckBoxItem[];
setItems: React.Dispatch<React.SetStateAction<CheckBoxItem[]>>;
onPopoverClose: () => void;
anchorEl: Element | null;
};
const filterItems = (
items: CheckBoxItem[],
filterText: string
): CheckBoxItem[] => {
if (filterText == '') return items;
return items.filter((item) => item.label.includes(filterText));
};
export const Popover = ({
searchPlaceholder,
checkBoxListLabel,
initialItems,
items,
setItems,
onPopoverClose,
anchorEl
}: Props) => {
const [text, setText] = useState('');
return (
<MuiPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onPopoverClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<Grid>
<Paper className={classes.searchText}>
<InputBase
placeholder={searchPlaceholder}
text={text}
onChange={(e) => {
setText(e.target.value);
setItems(filterItems(initialItems, e.target.value));
}}
/>
</Paper>
<Divider/>
<Paper>
<CheckBoxList
label={checkBoxListLabel}
checkBoxItems={checkBoxItems}
setCheckBoxItems={setCheckBoxItems}
/>
</Paper>
</Grid>
</MuiPopover>
);
};
CheckBoxListの実装
CheckBoxList/container.tsx
import Presenter, { CheckBoxItem as Item, CheckedState } from './presenter';
export type CheckBoxItem = Item;
type Props = {
label: string;
checkBoxItems: CheckBoxItem[];
setCheckBoxItems: React.Dispatch<React.SetStateAction<CheckBoxItem[]>>;
};
const judgeCheckBoxListState = (items: CheckBoxItem[]): CheckedState => {
const allChecked = items.every((item) => item.checked && !item.disabled);
const allUnchecked = items.every((item) => !item.checked || item.disabled);
if (allChecked) return 'checked';
if (allUnchecked) return 'unchecked';
return 'indeterminate';
};
const judgeCheckBoxListStateFromIndeterminate = (
items: CheckBoxItem[]
): 'checked' | 'unchecked' => {
if (items.filter((item) => !item.disabled).every((item) => item.checked))
return 'unchecked';
return 'checked';
};
export const CheckBoxList = ({
label,
checkBoxItems,
setCheckBoxItems
}: Props) => {
const [checkedState, setCheckedState] = useState<CheckedState>(
judgeCheckBoxListState(checkBoxItems)
);
useEffect(() => {
setCheckedState(judgeCheckBoxListState(checkBoxItems));
}, [checkBoxItems]);
const checkAllCheckBoxes = useCallback(
(checked: boolean) => {
setCheckBoxItems((currentItems) =>
currentItems.map((item) => ({ ...item, checked }))
);
checkBoxItems
.filter((item) => !item.disabled && item.checked === !checked)
.forEach((item) => item.onStateChange(item.checked, item.key));
},
[setCheckBoxItems, checkBoxItems]
);
const onListValueChange = useCallback(() => {
if (checkedState === 'checked') {
checkAllCheckBoxes(false);
return;
}
if (checkedState === 'unchecked') {
checkAllCheckBoxes(true);
return;
}
if (judgeCheckBoxListStateFromIndeterminate(checkBoxItems) === 'checked') {
checkAllCheckBoxes(true);
return;
}
if (
judgeCheckBoxListStateFromIndeterminate(checkBoxItems) === 'unchecked'
) {
checkAllCheckBoxes(false);
return;
}
}, [checkedState, checkBoxItems, checkAllCheckBoxes]);
const onValueChange = useCallback(
(index: number, checked: boolean) => {
setCheckBoxItems((currentItems) => {
const copiedItems = [...currentItems];
copiedItems[index].checked = !checked;
return copiedItems;
});
},
[setCheckBoxItems]
);
return (
<Presenter
label={label}
checkedState={checkedState}
items={checkBoxItems}
onListValueChange={onListValueChange}
onValueChange={onValueChange}
isDisabled={checkBoxItems.length === 0}
/>
);
};
CheckBoxList/presenter.tsx
import CheckBox from './CheckBox';
import Box from '@material-ui/core/Box';
export type CheckBoxItem = {
key: string;
label: string;
checked: boolean;
disabled?: boolean;
onStateChange: (checked: boolean, key: string) => void;
};
export type CheckedState = 'checked' | 'indeterminate' | 'unchecked';
type Props = {
label: string;
checkedState: CheckedState;
items: CheckBoxItem[];
onListValueChange: () => void;
onValueChange: (index: number, checked: boolean) => void;
isDisabled: boolean;
};
const CheckBoxList = ({
label,
checkedState,
items,
onListValueChange,
onValueChange,
isDisabled
}: Props) => {
return (
<>
<CheckBox
disabled={isDisabled}
checked={checkedState === 'checked'}
onValueChange={() => onListValueChange()}
label={label}
indeterminate={checkedState === 'indeterminate'}
/>
<Box>
{items.map((item, index) => (
<CheckBox
key={`${item.key}${index}`}
checked={item.checked}
onValueChange={(checked) => {
onValueChange(index, checked);
item.onStateChange(checked, item.key);
}}
label={item.label}
disabled={item.disabled}
/>
))}
</Box>
</>
);
};
export default CheckBoxList;
CheckBox.tsx
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox as MuiCheckBox from '@material-ui/core/Checkbox';
import Box from '@material-ui/core/Box';
type Props = {
label: string;
checked: boolean;
onValueChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
};
export const CheckBox: React.FC<Props> = ({
label,
checked,
onValueChange,
disabled = false,
indeterminate = false
}) => {
return (
<Box>
<FormControlLabel
control={
<MuiCheckBox
checked={disabled ? false : checked}
onClick={() => onValueChange(checked)}
disableRipple
disabled={disabled}
indeterminate={disabled ? false : indeterminate}
/>
}
label={label}
/>
</Box>
);
};
Discussion