Element-UIのCascaderをMUIで再現する

# 背景
現在業務でNuxtからReactへのリプレイスを担当しています。
NuxtではElement-UIをコンポーネントライブラリに採用していて、MUIで書き換えています。
Element-UIのCascaderをMUIで再現してみます。

# バックエンドから受け取るデータ
以下のようなオブジェクトが配列になっているものです。
```ts
{
    id: 101
    name: "さいたま市"
    prefectures: {id: 11, name: "埼玉県"}
}
```

# Element-UI の Cascader

https://element.eleme.io/#/en-US/component/cascader

業務では[Multiple Selection](https://element.eleme.io/#/en-US/component/cascader#multiple-selection)のCascaderを使用しています。

## ElementUIのCascader用に変換したデータ
```ts
[
    {
        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がありませんがコンポーネントを組み合わせことで作れます。

https://github.com/mui/mui-x/issues/6019#issuecomment-1236151651

今回はTreeView,TreeItem,CheckBox,Select,Chipを組み合わせて作成します。

## データ
データはElementUIのCascader用に変換したデータをそのまま使います。

TreeItemのnodeIdプロパティがこのデータですと問題があります。
渋谷区と東京都のid、13が重複しmaximum call stack size exceeded errorが起きます。

https://github.com/mui/material-ui/issues/21144

idの重複が起きないデータに置き換えてとりあえず実装を進めます。

## ソース

実現したいものができました！
cssがぐちゃぐちゃなのでそこはまた修正しておきます。

https://gyazo.com/dd8305546aeb79e8a9d3926c09fef955


```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>
  );
};
```

```ts:RecursiveTreeViewの呼び出し元
<RecursiveTreeView renderTreeList={data} />
```

# 最後に
- まずはAntdを選んでいればMUIで頑張って実装せずに済んだのでライブラリ選定を今後はしっかりしていきたいです
- コンポーネントの組み合わせでElementUIのCascaderを再現できました