[React] 카테고리 필터 구현하기

emit·2023년 8월 22일
0

📑 카테고리 필터 UI 구현하기

현재 진행중인 프로젝트에서 메인 컨텐츠 페이지를 담당하고 있는데, 카테고리 필터 기능이 필요했다.
기본적인 카테고리 필터 기능이 아니라, 계층형(트리형) 구조 형식으로 기능을 구현하는 게 요구 사항에 있었다.
예를 들어, 부모를 체크하면 자식 카테고리는 전체 체크, 자식 카테고리를 전체 체크하면 부모 카테고리가 체크되는 형식이다.

처음에는 라이브러리를 찾아보다가 딱 알맞은 라이브러리가 존재하지 않아서 직접 구현해보기로 했다.

🗂 개발환경

React + Typescript
Mui + Emotion

실제 프로덕트는 Next.js로 되어 있지만 Codesandbox에선 react + typescript로 진행했다.
css스타일링은 원래부터 CSS in JS를 선호하고, Mui의 checkbox컴포넌트가 간편해서 사용했다.

👀 구현 시작

카테고리 type과 data

export type Category = {
  id: number;
  name: string;
  children: Category[] | [];
  collapsed?: boolean; // 숨김/보임 상태
};


export const categories = [
  {
    id: 1,
    name: "데이터",
    children: [
      { id: 2, name: "게임데이터", children: [] },
      { id: 3, name: "로드데이터", children: [] },
      { id: 4, name: "외부데이터", children: [] }
    ],
    collapsed: true
  },
  {
    id: 5,
    name: "개선사항",
    children: [
      { id: 6, name: "복지", children: [] },
      {
        id: 7,
        name: "연봉",
        children: [
          { id: 8, name: "월급", children: [] },
          {
            id: 9,
            name: "주급",
            children: [{ id: 10, name: "일당", children: [] }]
          }
        ]
      },
      { id: 11, name: "식대", children: [] },
      { id: 12, name: "도서비", children: [] },
      { id: 13, name: "와우", children: [] }
    ],
    collapsed: true
  },
  ...,
];

카테고리 데이터는 부모 자식 관계이고, children에 배열로 담아 자식을 구성하고 있다.

Filter Item 컴포넌트

카테고리 필터 아이템 컴포넌트를 구현한다.

import styled from "@emotion/styled";
import { Box, Checkbox, FormControlLabel } from "@mui/material";
import { Category } from "../data/categories";

const LabelName = styled("span")`
  font-size: 12px;
  line-height: 17px;
  color: #5d5d62;
  margin-left: 8px;
  user-select: none;
`;

type Props = {
  category: Category;
  depth: number;
  handleClick: (category: Category, parentCategoryList: Category[]) => void;
  selectedCategoryIds: number[];
  parentCategoryList: Category[];
};

const SearchFilterItem = ({
  category,
  depth,
  handleClick,
  selectedCategoryIds,
  parentCategoryList
}: Props) => {
  return (
    <Box
      sx={{
        display: "flex",
        flexDirection: "column",
        marginLeft: `${30 + depth * 12}px`, // depth가 깊어질 때마다 12px 오른쪽으로
        gap: "10px"
      }}
    >
      <FormControlLabel
        label={<LabelName>{category.name}</LabelName>}
        control={
          <Checkbox
            sx={{
              width: 13,
              height: 13,
              "& .MuiSvgIcon-root": { fontSize: 18 }
            }}
            onChange={() => handleClick(category, parentCategoryList)}
            checked={selectedCategoryIds.includes(category.id)}
          />
        }
      />
    </Box>
  );
};

export default SearchFilterItem;

APP 컴포넌트

1. 재귀 형식으로 카테고리의 자식id 배열을 리턴하는 함수

// App.tsx
const getAllChildIds: (category: Category) => number[] = (
  category: Category
) => {
  let childIds: number[] = [];
  for (const child of category.children) {
    childIds.push(child.id);
    childIds = childIds.concat(getAllChildIds(child)); // 재귀
  }
  return childIds;
};

2. 코어 기능이자, click함수(onChange)이다. 현재 박스를 체크할 때마다 부모와 자식을 선회해 찾아서 있는지 없는지 확인하고 기능을 진행한다.

// App.tsx
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);

// ...
// getAllChildIds

const toggleCategory = (
  category: Category,
  parentCategoryList: Category[]
) => {
  const childIds = getAllChildIds(category); // 자식들의 id를 가져온다.

  if (selectedCategoryIds.includes(category.id)) {
    let tempIds = selectedCategoryIds.filter((id) => id !== category.id);
    for (const parentCategory of parentCategoryList) {
      // 부모가 체크되어 있고, 자식 체크박스를 풀었을 때 부모 체크 해제, 조부모 체크 해제
      if (parentCategory && selectedCategoryIds.includes(parentCategory.id)) {
        tempIds = tempIds.filter((id) => id !== parentCategory.id);
      }
    }
    setSelectedCategoryIds(tempIds.filter((id) => !childIds.includes(id)));
  } else {
    const tempIds = [...selectedCategoryIds, ...childIds, category.id];
    parentCategoryList = parentCategoryList.reverse();
    for (const parentCategory of parentCategoryList) {
      // 자식을 모두 체크했을 때 부모가 체크되게 변경
      const parentChildrenIds = parentCategory?.children.map(
      	(item) => item.id
      );
      if (
        parentCategory &&
        parentChildrenIds?.every((item) => tempIds.includes(item))
      ) {
        tempIds.push(parentCategory.id);
      }
    }
    setSelectedCategoryIds(tempIds);
  }
};

기본적인 check 기능과 함께 추가적인 기능을 탑재했다.
parentCategoryList는 부모 배열입니다. renderCategories함수가 재귀로 호출되서 결과적으로 [조부모, 부모, ...] 이러한 값을 받아옵니다.
이 배열을 reverse 메서드를 사용해 돌린 뒤 for of 문을 사용해 가까운 부모들부터 하나씩 불러옵니다.
map 메서드를 사용해 자식들의 id 배열 형태로 변경한 뒤 부모의 자식들 id가 현재 state에 있는지 확인합니다.
모두 있으면 부모의 id를 현재 state에 추가하는 형태로 위 과정을 반복합니다.

3. 재귀 형식의 렌더링 함수이다.

// App.tsx
function App() {
  
  // ...
  // getAllChildIds
  // toggleCategory
  
  const renderCategories: any = (
      categoryList: Category[],
      depth = 0,
      parentCategory: Category[] = []
    ) => {
      return categoryList.map((category) => (
        <React.Fragment key={category.id}>
          <SearchFilterItem
            depth={depth}
            category={category}
            handleClick={() => toggleCategory(category, parentCategory)}
            parentCategoryList={parentCategory}
            selectedCategoryIds={selectedCategoryIds}
          />
          {category.children.length > 0 &&
            renderCategories(category.children, depth + 1, [
              ...parentCategory,
              category
            ])}
        </React.Fragment>
      ));
  };

  return (
    <Container>
      <CategoryWrapper>
        {renderCategories(categoriesState, 0, [])}
      </CategoryWrapper>
    </Container>
  );
}

renderCategories를 재호출할 때 현재 카테고리 객체를 배열에다 추가해서 보냅니다.

완성된 결과물

전체 코드는 코드 샌드박스를 클릭해서 볼 수 있다.

끝내면서

요구 사항에서 봤을 때는 쉬워보였는데 막상 구현해보니까 어려웠다.
아마 재귀형식으로 구현해서 그런 것 같다.
더 쉬운 방식이나 좋은 코드가 있을 것 같지만 현재는 이러한 코드로 진행했다.
그리고 나중에도 비슷한 필터 기능이 있을 때 사용할 것 같다.
추가적으로 toggleCategory 함수가 약간 복잡한 것 같아서 함수 리팩토링을 진행하면 좋을 것 같다.

profile
간단한 공부 기록들 https://github.com/ohjooyeong

0개의 댓글