본문 바로가기
개발/React

React Material - 파일 브라우저에 검색 기능 추가하기 (Add Search Files with Autocomplete at File Browser)

by 피로물든딸기 2023. 6. 19.
반응형

리액트 전체 링크

 

참고

Mui Tree View로 파일, 폴더 뷰 만들기

Mui Tree View로 파일, 폴더 뷰 만들기 (with Node JS)

- Mui 파일, 폴더 뷰 확장 / 선택하기

- 파일 브라우저에서 폴더 이벤트 추가하기

- Auto Complete로 목록 관리하기

- 파일 브라우저에 검색 기능 추가하기

 

현재까지 만든 파일 브라우저에 Material UI의 Autocomplete으로 파일 검색 기능을 구현해보자.

 

참고로 더 많은 파일이 잘 검색되는 지 확인하기 위해 여러 파일을 더 추가하였다.

globfiles.zip
0.01MB


트리 뷰 확장 기능 추가하기

 

먼저 트리 뷰의 확장 기능을 추가해보자.

검색이 되면 트리 뷰도 펼쳐지면서 해당 파일이나 폴더가 선택되어야 하기 때문이다.

 

Mui 파일, 폴더 뷰 확장 / 선택하기를 참고하여 아래와 같이 코드를 추가한다.

nodeId를 알 수 있도록 useState로 상태를 관리한다.

  const [id, setId] = useState(0);

  let nodeId = 0;
  const appendChild = (arr, info, path, tmpIdMap) => {
    if (arr.child === undefined) arr.child = [];
    if (arr.child.findIndex((item) => item.label === info) === -1) {
      arr.child.push({ label: info, nodeId });
      tmpIdMap[path] = nodeId.toString();
      nodeId++;
    }
  };

  const makeDirectories = (directories) => {
    
    ...
    
    setId(nodeId);
  };

 

useState로 nodeId 중 가장 큰 번호를 알기 때문에 setExpanded에 적용할 수 있다.

  const handleExpandClick = () => {
    let fullExpanded = [];
    for (let i = 0; i <= id; i++) fullExpanded.push(i.toString());

    setExpanded((oldExpanded) =>
      oldExpanded.length === 0 ? fullExpanded : []
    );
  };

 

전체 코드는 다음과 같다.

import React, { useEffect, useState } from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";

import TreeView from "@mui/lab/TreeView";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import TreeItem from "@mui/lab/TreeItem";
import FileUI from "./FileUI";

const FileBrowser = () => {
  const [treeInfo, setTreeInfo] = useState({});
  const [fileUi, setFileUI] = useState([]);

  const [idMap, setIdMap] = useState({});
  const [expanded, setExpanded] = useState([]);
  const [selected, setSelected] = useState([]);

  const [id, setId] = useState(0);

  let nodeId = 0;
  const appendChild = (arr, info, path, tmpIdMap) => {
    if (arr.child === undefined) arr.child = [];
    if (arr.child.findIndex((item) => item.label === info) === -1) {
      arr.child.push({ label: info, nodeId });
      tmpIdMap[path] = nodeId.toString();
      nodeId++;
    }
  };

  const makeDirectories = (directories) => {
    let tmpTreeInfo = {};
    let tmpIdMap = {};

    let dir = ["D:", "D:/github", ...directories];

    for (let d of dir) {
      let split = d.split("/");
      let len = split.length;
      let current = tmpTreeInfo;

      for (let i = 0; i < len; i++) {
        appendChild(current, split[i], d, tmpIdMap);
        current = current.child.find((item) => item.label === split[i]);
      }
    }

    setIdMap(tmpIdMap);
    setTreeInfo(tmpTreeInfo);
    setId(nodeId);
  };

  const getFiles = () => {
    // setTreeInfo(localData);
    // return;
    let server = `http://192.168.55.120:3002`;
    let path = `D:\\github\\globfiles\\**`;

    fetch(`${server}/useGlob?path=${path}`)
      .then((res) => res.json())
      .then((data) => makeDirectories(data.findPath));
  };

  const isFile = (path) => {
    let spt = path.split("/");
    return spt[spt.length - 1].includes(".");
  };

  const sortFileUI = (path) => {
    let dir = path.filter((val) => isFile(val) === false);
    let files = path.filter((val) => isFile(val));

    dir.sort();
    files.sort();

    setFileUI([...dir, ...files]);
  };

  const getFilesForFileBrowser = (path) => {
    let server = `http://192.168.55.120:3002`;

    /* /D:/... 앞의 / 삭제 */
    path = path.substring(1, path.length);

    fetch(`${server}/useGlob?path=${path}/*`)
      .then((res) => res.json())
      .then((data) => sortFileUI(data.findPath));
  };

  const makeTreeItem = (info, parent) => {
    if (info.child === undefined) return;

    return info.child.map((item, idx) => (
      <TreeItem
        key={idx}
        nodeId={item.nodeId.toString()}
        label={item.label}
        onClick={() => getFilesForFileBrowser(`${parent}/${item.label}`)}
      >
        {makeTreeItem(item, `${parent}/${item.label}`)}
      </TreeItem>
    ));
  };

  useEffect(() => {
    getFiles();
  }, []);

  const handleToggle = (event, ids) => {
    setExpanded(ids);
  };

  const handleSelect = (event, ids) => {
    setSelected(ids);
  };

  const handleExpandClick = () => {
    let fullExpanded = [];
    for (let i = 0; i <= id; i++) fullExpanded.push(i.toString());

    setExpanded((oldExpanded) =>
      oldExpanded.length === 0 ? fullExpanded : []
    );
  };

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: "25% 1% auto",
        gridGap: "25px",
        width: "100%",
      }}
    >
      {/* <button onClick={getFiles}>test</button> */}
      <div>
        <a
          href="https://bloodstrawberry.tistory.com/1175"
          target="_blank"
          rel="noreferrer"
        >
          Node Server 구현 필요
        </a>
        <Box sx={{ mb: 1 }}>
          <Button onClick={handleExpandClick}>
            {expanded.length === 0 ? "Expand all" : "Collapse all"}
          </Button>
        </Box>
        <TreeView
          expanded={expanded}
          onNodeToggle={handleToggle}
          selected={selected}
          onNodeSelect={handleSelect}
          aria-label="file system navigator"
          defaultCollapseIcon={<ExpandMoreIcon />}
          defaultExpandIcon={<ChevronRightIcon />}
          sx={{ height: 500, overflowX: "hidden" }}
          //sx={{ height: 240, flexGrow: 1, maxWidth: 400, overflowY: "auto" }}
        >
          {makeTreeItem(treeInfo, "")}
        </TreeView>
      </div>
      <div style={{ borderRight: "2px solid black" }} />
      <div>
        {fileUi.map((f, idx) => (
          <FileUI
            key={idx}
            pathInfo={f}
            idMap={idMap}
            tvfunc={{ expanded, setExpanded, setSelected, sortFileUI }}
          />
        ))}
      </div>
    </div>
  );
};

export default FileBrowser;

 

정상적으로 확장이 잘 되는 것을 알 수 있다.


검색 기능 추가하기

 

Autocomplete를 참고하여 아래의 코드를 추가한다.

import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";

 

Autocomplete value 값과 현재 받아온 파일/폴더 목록을 관리할 dirList를 useState로 선언한다.

  const [value, setValue] = useState(null);
  const [dirList, setDirList] = useState([]);

 

makeDirectories에서 directories를 그대로 저장하면 된다.

  const makeDirectories = (directories) => {
    ...
    
    setDirList(directories);
    
    setIdMap(tmpIdMap);
    setTreeInfo(tmpTreeInfo);
    setId(nodeId);
  };

 

그리고 검색 목록으로 사용할 options로 dirList를 지정한다.

  const defaultProps = {
    options: dirList,
    getOptionLabel: (option) => option,
  };

 

Autocomplete 설정은 다음과 같다.

    <div>
      <Stack spacing={1} sx={{ width: "80%", marginLeft: 5, paddingBottom: 3 }}>
        <Autocomplete
          {...defaultProps}
          id="auto-complete"
          autoComplete
          includeInputInList
          value={value}
          onChange={(event, newValue) => {
            setValue(newValue);
          }}
          renderInput={(params) => (
            <TextField {...params} label="Search Files" variant="standard" />
          )}
        />
      </Stack>

 

이제 node로 가져온 목록이 보이게 된다.

 

전체 코드는 다음과 같다.

import React, { useEffect, useState } from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";

import TreeView from "@mui/lab/TreeView";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import TreeItem from "@mui/lab/TreeItem";
import FileUI from "./FileUI";

import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";

const FileBrowser = () => {
  const [treeInfo, setTreeInfo] = useState({});
  const [fileUi, setFileUI] = useState([]);

  const [idMap, setIdMap] = useState({});
  const [expanded, setExpanded] = useState([]);
  const [selected, setSelected] = useState([]);

  const [id, setId] = useState(0);

  const [value, setValue] = useState(null);
  const [dirList, setDirList] = useState([]);

  let nodeId = 0;
  const appendChild = (arr, info, path, tmpIdMap) => {
    if (arr.child === undefined) arr.child = [];
    if (arr.child.findIndex((item) => item.label === info) === -1) {
      arr.child.push({ label: info, nodeId });
      tmpIdMap[path] = nodeId.toString();
      nodeId++;
    }
  };

  const makeDirectories = (directories) => {
    let tmpTreeInfo = {};
    let tmpIdMap = {};

    let dir = ["D:", "D:/github", ...directories];

    for (let d of dir) {
      let split = d.split("/");
      let len = split.length;
      let current = tmpTreeInfo;

      for (let i = 0; i < len; i++) {
        appendChild(current, split[i], d, tmpIdMap);
        current = current.child.find((item) => item.label === split[i]);
      }
    }

    setDirList(directories);
    setIdMap(tmpIdMap);
    setTreeInfo(tmpTreeInfo);
    setId(nodeId);
  };

  const getFiles = () => {
    // setTreeInfo(localData);
    // return;
    let server = `http://192.168.55.120:3002`;
    let path = `D:\\github\\globfiles\\**`;

    fetch(`${server}/useGlob?path=${path}`)
      .then((res) => res.json())
      .then((data) => makeDirectories(data.findPath));
  };

  const isFile = (path) => {
    let spt = path.split("/");
    return spt[spt.length - 1].includes(".");
  };

  const sortFileUI = (path) => {
    let dir = path.filter((val) => isFile(val) === false);
    let files = path.filter((val) => isFile(val));

    dir.sort();
    files.sort();

    setFileUI([...dir, ...files]);
  };

  const getFilesForFileBrowser = (path) => {
    let server = `http://192.168.55.120:3002`;

    /* /D:/... 앞의 / 삭제 */
    path = path.substring(1, path.length);

    fetch(`${server}/useGlob?path=${path}/*`)
      .then((res) => res.json())
      .then((data) => sortFileUI(data.findPath));
  };

  const makeTreeItem = (info, parent) => {
    if (info.child === undefined) return;

    return info.child.map((item, idx) => (
      <TreeItem
        key={idx}
        nodeId={item.nodeId.toString()}
        label={item.label}
        onClick={() => getFilesForFileBrowser(`${parent}/${item.label}`)}
      >
        {makeTreeItem(item, `${parent}/${item.label}`)}
      </TreeItem>
    ));
  };

  useEffect(() => {
    getFiles();
  }, []);

  const handleToggle = (event, ids) => {
    setExpanded(ids);
  };

  const handleSelect = (event, ids) => {
    setSelected(ids);
  };

  const handleExpandClick = () => {
    let fullExpanded = [];
    for (let i = 0; i <= id; i++) fullExpanded.push(i.toString());

    setExpanded((oldExpanded) =>
      oldExpanded.length === 0 ? fullExpanded : []
    );
  };

  const defaultProps = {
    options: dirList,
    getOptionLabel: (option) => option,
  };

  return (
    <div>
      <Stack spacing={1} sx={{ width: "80%", marginLeft: 5, paddingBottom: 3 }}>
        <Autocomplete
          {...defaultProps}
          id="auto-complete"
          autoComplete
          includeInputInList
          value={value}
          onChange={(event, newValue) => {
            setValue(newValue);
          }}
          renderInput={(params) => (
            <TextField {...params} label="Search Files" variant="standard" />
          )}
        />
      </Stack>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "25% 1% auto",
          gridGap: "25px",
          width: "100%",
        }}
      >
        {/* <button onClick={getFiles}>test</button> */}
        <div>
          {/* <a
            href="https://bloodstrawberry.tistory.com/1175"
            target="_blank"
            rel="noreferrer"
          >
            Node Server 구현 필요
          </a> */}
          <Box sx={{ mb: 1 }}>
            <Button onClick={handleExpandClick}>
              {expanded.length === 0 ? "Expand all" : "Collapse all"}
            </Button>
          </Box>
          <TreeView
            expanded={expanded}
            onNodeToggle={handleToggle}
            selected={selected}
            onNodeSelect={handleSelect}
            aria-label="file system navigator"
            defaultCollapseIcon={<ExpandMoreIcon />}
            defaultExpandIcon={<ChevronRightIcon />}
            sx={{ height: 500, overflowX: "hidden" }}
            //sx={{ height: 240, flexGrow: 1, maxWidth: 400, overflowY: "auto" }}
          >
            {makeTreeItem(treeInfo, "")}
          </TreeView>
        </div>
        <div style={{ borderRight: "2px solid black" }} />
        <div>
          {fileUi.map((f, idx) => (
            <FileUI
              key={idx}
              pathInfo={f}
              idMap={idMap}
              tvfunc={{ expanded, setExpanded, setSelected, sortFileUI }}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

export default FileBrowser;

파일 브라우저 기능과 연동하기

 

Autocomplete로 목록을 선택해도 아무 변화가 없다.

목록을 선택하면 tree view가 확장 및 선택되도록 하고, 폴더를 선택하면 파일 브라우저도 변경해보자.

 

브라우저를 만드는 함수는 이미 있으므로 onChange에 newValue 값을 넣어주기만 하면 된다.

  <Autocomplete
    ...
    onChange={(event, newValue) => {
      setValue(newValue);
      getFilesForFileBrowser(`/${newValue}`);
    }}

  />

 

이제 폴더를 선택하면 해당 폴더가 가지고 있는 목록이 보인다.

 

트리 뷰의 확장 기능은 FileUI.js에 이미 구현되어 있다.

const makeExpandedView = (pathInfo, idMap, tvfunc) => {
  let tmp = [...tvfunc.expanded];
  let spt = pathInfo.split("/");
  let tmpPath = "D:";
  for(let i = 1; i < spt.length; i++) {
    tmpPath += `/${spt[i]}`;

    console.log(tmpPath);
    if(idMap[tmpPath] === undefined) continue;
    if(tmp.includes(idMap[tmpPath])) continue;

    tmp.push(idMap[tmpPath]);
  }

  tvfunc.setExpanded([...tmp]);
}

 

따로 리팩토링은 하지 않고, FileBrowser.js에 아래와 같이 하나 더 만든다.

setSelected를 이용해 선택 또한 추가하였다.

  const makeExpandedView = (pathInfo) => {
    let tmp = ["0", "1", ...expanded];
    let spt = pathInfo.split("/");
    let tmpPath = "D:";
    for(let i = 1; i < spt.length; i++) {
      tmpPath += `/${spt[i]}`;
  
      if(idMap[tmpPath] === undefined) continue;
      if(tmp.includes(idMap[tmpPath])) continue;
  
      tmp.push(idMap[tmpPath]);
    }
  
    setSelected(idMap[pathInfo]);
    setExpanded([...tmp]);
  }

 

그리고 onChange에 makeExpandedView를 추가한다.

  onChange={(event, newValue) => {
    setValue(newValue);
    getFilesForFileBrowser(`/${newValue}`);
    makeExpandedView(newValue);
  }}

 

이제 검색한 결과가 트리 뷰와 잘 연동되는 것을 알 수 있다.

 

전체 코드는 다음과 같다.

 

FileBrowser.js

import React, { useEffect, useState } from "react";

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";

import TreeView from "@mui/lab/TreeView";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import TreeItem from "@mui/lab/TreeItem";
import FileUI from "./FileUI";

import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";

const FileBrowser = () => {
  const [treeInfo, setTreeInfo] = useState({});
  const [fileUi, setFileUI] = useState([]);

  const [idMap, setIdMap] = useState({});
  const [expanded, setExpanded] = useState([]);
  const [selected, setSelected] = useState([]);

  const [id, setId] = useState(0);

  const [value, setValue] = useState(null);
  const [dirList, setDirList] = useState([]);

  let nodeId = 0;
  const appendChild = (arr, info, path, tmpIdMap) => {
    if (arr.child === undefined) arr.child = [];
    if (arr.child.findIndex((item) => item.label === info) === -1) {
      arr.child.push({ label: info, nodeId });
      tmpIdMap[path] = nodeId.toString();
      nodeId++;
    }
  };

  const makeDirectories = (directories) => {
    let tmpTreeInfo = {};
    let tmpIdMap = {};

    let dir = ["D:", "D:/github", ...directories];

    for (let d of dir) {
      let split = d.split("/");
      let len = split.length;
      let current = tmpTreeInfo;

      for (let i = 0; i < len; i++) {
        appendChild(current, split[i], d, tmpIdMap);
        current = current.child.find((item) => item.label === split[i]);
      }
    }

    setDirList(directories);
    setIdMap(tmpIdMap);
    setTreeInfo(tmpTreeInfo);
    setId(nodeId);
  };

  const getFiles = () => {
    // setTreeInfo(localData);
    // return;
    let server = `http://192.168.55.120:3002`;
    let path = `D:\\github\\globfiles\\**`;

    fetch(`${server}/useGlob?path=${path}`)
      .then((res) => res.json())
      .then((data) => makeDirectories(data.findPath));
  };

  const isFile = (path) => {
    let spt = path.split("/");
    return spt[spt.length - 1].includes(".");
  };

  const sortFileUI = (path) => {
    let dir = path.filter((val) => isFile(val) === false);
    let files = path.filter((val) => isFile(val));

    dir.sort();
    files.sort();

    setFileUI([...dir, ...files]);
  };

  const getFilesForFileBrowser = (path) => {
    let server = `http://192.168.55.120:3002`;

    /* /D:/... 앞의 / 삭제 */
    path = path.substring(1, path.length);

    fetch(`${server}/useGlob?path=${path}/*`)
      .then((res) => res.json())
      .then((data) => sortFileUI(data.findPath));
  };

  const makeTreeItem = (info, parent) => {
    if (info.child === undefined) return;

    return info.child.map((item, idx) => (
      <TreeItem
        key={idx}
        nodeId={item.nodeId.toString()}
        label={item.label}
        onClick={() => getFilesForFileBrowser(`${parent}/${item.label}`)}
      >
        {makeTreeItem(item, `${parent}/${item.label}`)}
      </TreeItem>
    ));
  };

  useEffect(() => {
    getFiles();
  }, []);

  const handleToggle = (event, ids) => {
    setExpanded(ids);
  };

  const handleSelect = (event, ids) => {
    setSelected(ids);
  };

  const handleExpandClick = () => {
    let fullExpanded = [];
    for (let i = 0; i <= id; i++) fullExpanded.push(i.toString());

    setExpanded((oldExpanded) =>
      oldExpanded.length === 0 ? fullExpanded : []
    );
  };

  const makeExpandedView = (pathInfo) => {
    let tmp = ["0", "1", ...expanded];
    let spt = pathInfo.split("/");
    let tmpPath = "D:";
    for(let i = 1; i < spt.length; i++) {
      tmpPath += `/${spt[i]}`;
  
      if(idMap[tmpPath] === undefined) continue;
      if(tmp.includes(idMap[tmpPath])) continue;
  
      tmp.push(idMap[tmpPath]);
    }
  
    setSelected(idMap[pathInfo]);
    setExpanded([...tmp]);
  } 

  const defaultProps = {
    options: dirList,
    getOptionLabel: (option) => option,
  };

  return (
    <div>
      <Stack spacing={1} sx={{ width: "80%", marginLeft: 5, paddingBottom: 3 }}>
        <Autocomplete
          {...defaultProps}
          id="auto-complete"
          autoComplete
          includeInputInList
          value={value}
          onChange={(event, newValue) => {
            setValue(newValue);
            getFilesForFileBrowser(`/${newValue}`);
            makeExpandedView(newValue);
          }}
          renderInput={(params) => (
            <TextField {...params} label="Search Files" variant="standard" />
          )}
        />
      </Stack>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "25% 1% auto",
          gridGap: "25px",
          width: "100%",
        }}
      >
        {/* <button onClick={getFiles}>test</button> */}
        <div>
          {/* <a
            href="https://bloodstrawberry.tistory.com/1175"
            target="_blank"
            rel="noreferrer"
          >
            Node Server 구현 필요
          </a> */}
          <Box sx={{ mb: 1 }}>
            <Button onClick={handleExpandClick}>
              {expanded.length === 0 ? "Expand all" : "Collapse all"}
            </Button>
          </Box>
          <TreeView
            expanded={expanded}
            onNodeToggle={handleToggle}
            selected={selected}
            onNodeSelect={handleSelect}
            aria-label="file system navigator"
            defaultCollapseIcon={<ExpandMoreIcon />}
            defaultExpandIcon={<ChevronRightIcon />}
            sx={{ height: 500, overflowX: "hidden" }}
            //sx={{ height: 240, flexGrow: 1, maxWidth: 400, overflowY: "auto" }}
          >
            {makeTreeItem(treeInfo, "")}
          </TreeView>
        </div>
        <div style={{ borderRight: "2px solid black" }} />
        <div>
          {fileUi.map((f, idx) => (
            <FileUI
              key={idx}
              pathInfo={f}
              idMap={idMap}
              tvfunc={{ expanded, setExpanded, setSelected, sortFileUI }}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

export default FileBrowser;
반응형

댓글