背景
現在業務でNuxtからReactへのリプレイスを担当しています。
NuxtではElement-UIをコンポーネントライブラリに採用していて、MUIで書き換えています。
Element-UIのCascaderをMUIで再現してみます。
バックエンドから受け取るデータ
以下のようなオブジェクトが配列になっているものです。
{
id: 101
name: "さいたま市"
prefectures: {id: 11, name: "埼玉県"}
}
Element-UI の Cascader
業務ではMultiple SelectionのCascaderを使用しています。
ElementUIのCascader用に変換したデータ
[
{
id: "11",
name: "埼玉県",
children
:
[
{id: '101', name: 'さいたま市'},
{id: '149', name: 'さいたま市西区'},
{id: '150', name: 'さいたま市北区'},
{id: '151', name: 'さいたま市大宮区'},
{id: '152', name: 'さいたま市中央区'},
{id: '153', name: 'さいたま市桜区'},
]
},
{
id: "13",
name: "東京都",
children
:
[
{id: '1', name: '千代田区'},
{id: '2', name: '中央区'},
{id: '3', name: '港区'},
{id: '4', name: '新宿区'},
{id: '5', name: '文京区'},
{id: '6', name: '台東区'},
{id: '7', name: '墨田区'},
{id: '8', name: '江東区'},
{id: '9', name: '品川区'},
{id: '10', name: '目黒区'},
{id: '11', name: '大田区'},
{id: '12', name: '世田谷区'},
{id: '13', name: '渋谷区'},
}
]
MUI
MUIにはCascaderがありませんがコンポーネントを組み合わせことで作れます。
今回はTreeView,TreeItem,CheckBox,Select,Chipを組み合わせて作成します。
データ
データはElementUIのCascader用に変換したデータをそのまま使います。
TreeItemのnodeIdプロパティがこのデータですと問題があります。
渋谷区と東京都のid、13が重複しmaximum call stack size exceeded errorが起きます。
idの重複が起きないデータに置き換えてとりあえず実装を進めます。
ソース
実現したいものができました!
cssがぐちゃぐちゃなのでそこはまた修正しておきます。
RecursiveTreeView.tsx
import { FC, useState } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { TreeItem, TreeView } from '@mui/x-tree-view';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import { RenderTreeType } from '@/types/RenderTreeType';
type RecursiveTreeViewProps = {
renderTreeList: RenderTreeType[];
};
const prefectureIdList = ['11', '13'];
export const RecursiveTreeView: FC<RecursiveTreeViewProps> = ({
renderTreeList,
}) => {
const [selected, setSelected] = useState<string[]>([]);
// idから再起的にnodeを問い合わせて取得して返す
const getNodeById = (nodes: RenderTreeType, id: string) => {
if (nodes.id === id) {
return nodes;
} else if (Array.isArray(nodes.children)) {
let result = null;
nodes.children.forEach((node) => {
if (getNodeById(node, id)) {
result = getNodeById(node, id);
}
});
return result;
}
return null;
};
// idの値をidにもつ自身と子のidを再起的に取り出した配列を返す
const getChildById = (node: RenderTreeType, id: string) => {
let array: string[] = [];
// 自身と子のidを再起的に取り出した配列を返す
const getAllChild = (nodes: RenderTreeType | null) => {
if (nodes === null) return [];
array.push(nodes.id);
if (Array.isArray(nodes.children)) {
const childrenIdList = nodes.children.flatMap((child) =>
getAllChild(child),
);
const uniqueChildrenIdList = [...new Set(childrenIdList)];
array.push(...uniqueChildrenIdList);
}
return array;
};
return getAllChild(getNodeById(node, id));
};
//checkされたidのリストの更新をする
const getOnChange = (checked: boolean, nodes: RenderTreeType) => {
const allNode: string[] = renderTreeList.flatMap((renderTreeProp) =>
getChildById(renderTreeProp, nodes.id),
);
const newSelectedList = checked
? [...selected, ...allNode]
: selected.filter((value) => !allNode.includes(value));
const uniqueNewSelectedList = [...new Set(newSelectedList)];
setSelected(uniqueNewSelectedList);
};
const renderTree = (nodes: RenderTreeType) => (
<TreeItem
key={nodes.id}
nodeId={nodes.id}
label={
<FormControlLabel
control={
<Checkbox
checked={selected.includes(nodes.id)}
onChange={(event) =>
getOnChange(event.currentTarget.checked, nodes)
}
onClick={(e) => e.stopPropagation()}
/>
}
label={<>{nodes.name}</>}
key={nodes.id}
/>
}
>
{Array.isArray(nodes.children)
? nodes.children.map((node) => renderTree(node))
: null}
</TreeItem>
);
const getNodeIdLoop = (id: string): RenderTreeType | null => {
let node: RenderTreeType | null = null;
renderTreeList.forEach((renderTree) => {
if (!node) node = getNodeById(renderTree, id);
});
return node;
};
const handleDelete = (deletedId: string) => {
setSelected(selected.filter((id) => id !== deletedId));
};
return (
<Select
sx={{ fontSize: '0.5rem' }}
value={selected.filter((elm) => !prefectureIdList.includes(elm))}
multiple
fullWidth
renderValue={(selected) => {
const selectedValues = selected.sort((a, b) => +a - +b);
const value = selectedValues[0];
const value2 = selectedValues[1];
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{value && (
<Chip
key={value}
label={getNodeIdLoop(value)?.name}
onDelete={() => handleDelete(value)}
onMouseDown={(event) => {
event.stopPropagation();
}}
style={{
margin: '2px',
height: 'auto',
fontSize: '0.5rem',
}}
/>
)}
{value2 && (
<Chip
key={value2}
label={getNodeIdLoop(value2)?.name}
onDelete={() => handleDelete(value)}
onMouseDown={(event) => {
event.stopPropagation();
}}
style={{
margin: '2px',
height: 'auto',
fontSize: '0.5rem',
}}
/>
)}
{selected.length > 2 && (
<Chip
label={`+ ${selected.length - 2}`}
style={{
margin: '2px',
height: 'auto',
fontSize: '0.5rem',
}}
/>
)}
</div>
);
}}
>
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
>
{renderTreeList.map((renderTreeProp) => renderTree(renderTreeProp))}
</TreeView>
</Select>
);
};
RecursiveTreeViewの呼び出し元
<RecursiveTreeView renderTreeList={data} />
最後に
- まずはAntdを選んでいればMUIで頑張って実装せずに済んだのでライブラリ選定を今後はしっかりしていきたいです
- コンポーネントの組み合わせでElementUIのCascaderを再現できました