블로그 포스트 필터 기능 구현

Nextjs

  • filter
  • post

2023-04-28 23:31

포스트의 필터 기능을 통해 조금 더 편리하게 원하는 게시글을 찾고 싶어서 구현하게 되었다.

우선, Header 타이틀 옆에 Filter Icon을 삽입하였다. Icon은 https://heroicons.com/ 이 곳에서 가져왔다.

먼저 카테고리 리스트를 가져오자.

포스트들을 담고 있는 페이지의 경로는 /blog/posts 이기 때문에,

전체 데이터 받아오기

pages/blog/posts/index.js

전체 데이터를 받아온다.

export async function getStaticProps() {
  const options = {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Notion-Version": "2022-06-28",
      "Content-Type": "application/json",
      Authorization: `Bearer ${TOKEN}`,
    },
    body: JSON.stringify({
      sorts: [
        {
          property: "createdDate",
          direction: "descending",
        },
      ],

      page_size: 100,
    }),
  };

  const res = await fetch(`https://api.notion.com/v1/databases/${POST_DATABASE_ID}/query`, options);

  const data = await res.json();

  const allPosts = data.results;

  const postsPerPage = 6;

  const numPages = Math.ceil(allPosts.length / postsPerPage);

  const posts = allPosts.slice(0, postsPerPage);

  return {
    props: { allPosts, posts, numPages },
  };
}
javascript
  • allPosts 전체 데이터
  • posts 는 첫 번째 페이지의 데이터
  • numPages 페이지네이션 구현을 위한 페이지의 갯수

Redux로 전역 상태변수 관리

slices/FilterSlice.js

선택한 카테고리와 선택한 태그 그리고 필터 모달의 오픈 여부를 전역 변수로 관리했다.

const { createSlice } = require("@reduxjs/toolkit");

const initialFilterSlice = {
  selectedCategory: "",
  selectedTag: "",
  filterOpen: false,
}

const FilterSlice = createSlice({
  name: 'FilterSlice',
  initialState: initialFilterSlice,
  reducers: {
    choiceCategory: (state, action) => {
      const categoryValue = action.payload;
      const tagValue = state.selectedTag;
      const openValue = state.filterOpen
      return {
        selectedCategory: categoryValue,
        selectedTag: tagValue,
        filterOpen: openValue
      }
    },
    choiceTag: (state, action) => {
      const categoryValue = state.selectedCategory;
      const tagValue = action.payload ? [...state.selectedTag, action.payload].filter((v) => v !== undefined) : "";
      const openValue = state.filterOpen
      return {
        selectedCategory: categoryValue,
        selectedTag: tagValue,
        filterOpen: openValue
      }
    },
    open: (state, action) => {
      const categoryValue = state.selectedCategory;
      const tagValue = state.selectedTag;
      const openValue = true
      return {
        selectedCategory: categoryValue,
        selectedTag: tagValue,
        filterOpen: openValue
      }
    },
    close: (state, action) => {
      const categoryValue = state.selectedCategory;
      const tagValue = state.selectedTag;
      const openValue = false
      return {
        selectedCategory: categoryValue,
        selectedTag: tagValue,
        filterOpen: openValue
      }
    }
  }
})

export const { choiceCategory, choiceTag, open, close } = FilterSlice.actions;
export default FilterSlice.reducer;
javascript

필터 컴포넌트 열고 닫기 emotion, redux

헤더 옆의 FilterIcon을 클릭해서 필터 모달을 열어보자

const Filter = ({ posts }) => {
	const { filterOpen } = useSelector((state) => state.FilterSlice);
  return (
      <Base filter={filterOpen}>
...
      </Base>
  );
};

const Base = styled.div`
  ${({ filter }) =>
    filter
      ? css`
          opacity: 1;
          pointer-events: all;
        `
      : css`
          opacity: 0;
          pointer-events: none;
        `}
`;
javascript

다른 코드들은 제외하고 필터 모달을 여는 코드만 남겨놓았다.

useSelector을 이용해 FilterSlice에서 filterOpen 값을 가져온다.

emotion 스타일 컴포넌트로 Base라는 컴포넌트를 정의했는데 filter값에 따라 다른 스타일을 적용해 열고 닫기 기능을 구현했다.

이제 헤더의 필터 아이콘을 클릭하는 코드를 구현하자.

const handleFilter = () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
    filterOpen ? dispatch(close()) : dispatch(open());
  };
javascript

필터 아이콘을 클릭하면 handleFilter 함수가 실행된다.

상단에 고정되어 있는 필터 아이콘을 밑에서 누르게 되면 위로 올리는 scrollTo 와 dispatch가 있다.

filterOpen 값에 따라 close 또는 open이 실행되게 하였다.

이제 클릭하면 필터 컴포넌트가 열린다!

Portal, overflow:hidden

필터 컴포넌트가 부모 요소나 기타 요소에 영향을 받지 않게 하기 위해서 Portal로 감싸주었다.

components/blog.Filter.js

const Filter = ({ posts }) => {
  const category = posts?.map((v) => v.properties.category.select?.name).filter((v, i, arr) => arr.indexOf(v) === i);

  const { filterOpen } = useSelector((state) => state.FilterSlice);
  // filter true 인 경우 body 스크롤 방지
  useEffect(() => {
    if (typeof window !== "object") return;

    if (filterOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }
  });

  return (
    <Portal selector="#portal">
      <Base filter={filterOpen}>
      </Base>
    </Portal>
  );
};
javascript

components/common/Portal.js

import { useEffect, useState } from "react";
import ReactDOM from "react-dom";

const Portal = ({ children, selector }) => {
  const [element, setElement] = useState(null);
  useEffect(() => {
    setElement(document.querySelector(selector));
  }, [selector]);
  return element && children ? ReactDOM.createPortal(children, element) : null;
};

export default Portal;
javascript

필터 컴포넌트가 open 된 상태일 때, body의 스크롤을 방지하는 코드도 추가하였다.

useEffect(() => {
    if (typeof window !== "object") return;

    if (filterOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }
  });
javascript

CategoryList, TagList

CategoryList와 TagList 컴포넌트를 만들고 필터 컴포넌트에 넣고 전체 데이터를 넘겨준다.

const Filter = ({ posts }) => {
  const category = posts?.map((v) => v.properties.category.select?.name).filter((v, i, arr) => arr.indexOf(v) === i);

  const { filterOpen } = useSelector((state) => state.FilterSlice);
  // filter true 인 경우 body 스크롤 방지
  useEffect(() => {
    if (typeof window !== "object") return;

    if (filterOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }
  });

  return (
    <Portal selector="#portal">
      <Base filter={filterOpen}>
        <Wrapper>
          <CategoryList data={category} posts={posts} />
          <TagList posts={posts} />
        </Wrapper>
      </Base>
    </Portal>
  );
};
javascript

CategoryList

먼저, 카테고리 리스트를 살펴보면 아래와 같다.

const CategoryList = ({ data, posts }) => {
  const dispatch = useDispatch();
  const handleClickCategory = (category) => {
    dispatch(choiceCategory(category));
    dispatch(choiceTag());
  };

  const { selectedCategory } = useSelector((state) => state.FilterSlice);

  return (
    <Base>
      <CategoryItem selectedCategory={selectedCategory === undefined} onClick={() => handleClickCategory()}>{`전체 (${posts?.length})`}</CategoryItem>
      {data?.map((v) => {
        const length = posts?.filter((v1) => v1.properties.category.select?.name === v).length;
        return (
          <>
            <CategoryItem selectedCategory={selectedCategory === v} onClick={() => handleClickCategory(v)}>{`${v} (${length})`}</CategoryItem>
          </>
        );
      })}
    </Base>
  );
};
javascript

받아온 데이터에서 map 메소드를 이용해 전체 카테고리 아이템들을 CategoryItem이라는 스타일 컴포넌트에 담아서 렌더링한다.

아이템을 클릭하면 만들어둔 handleClickCategory 함수를 실행하게 했다.

const handleClickCategory = (category) => {
    dispatch(choiceCategory(category));
    dispatch(choiceTag());
  };
javascript

이 함수는 카테고리를 선택하고 선택된 태그를 삭제하는 dispatch 함수이다.

선택된 카테고리를 useSelector로 불러와 아래와 같은 형식으로 스타일을 주었다.

<CategoryItem selectedCategory={selectedCategory === v} />
javascript

TagList

TagList는 CategoryList보다 살짝 복잡하다.

처음 열렸을 땐 태그 전체가 보이겠지만, 카테고리를 선택하게 되면 그 카테고리에 해당하는 태그들만 보여줄 것이기 때문이다.

위 기능 구현을 위한 코드이다.

selectedCategory
    ? posts
        ?.filter((post) => post.properties.category.select.name === selectedCategory)
        .map((v) => v.properties.tags.multi_select.map((v1) => v1.name))
        .flat()
        .filter((v, i, arr) => arr.indexOf(v) === i)
    : posts
        ?.map((v) => v.properties.tags.multi_select.map((v1) => v1.name))
        .flat()
        .filter((v2, i, arr) => arr.indexOf(v2) === i);
javascript

선택된 카테고리가 존재하면 filter를 사용한 후에 map을 돌리고 그렇지 않다면 바로 map을 사용한다.

TagList 전체 코드

const TagList = ({ posts }) => {
  const router = useRouter();
  const dispatch = useDispatch();
  const handleChoiceTag = (tag) => {
    dispatch(choiceTag(tag));
  };


  const { selectedCategory, selectedTag } = useSelector((state) => state.FilterSlice);
  const tagData = selectedCategory
    ? posts
        ?.filter((post) => post.properties.category.select.name === selectedCategory)
        .map((v) => v.properties.tags.multi_select.map((v1) => v1.name))
        .flat()
        .filter((v, i, arr) => arr.indexOf(v) === i)
    : posts
        ?.map((v) => v.properties.tags.multi_select.map((v1) => v1.name))
        .flat()
        .filter((v2, i, arr) => arr.indexOf(v2) === i);

  return (
    <Base>
      <TagItem selectedTag={selectedTag.length === 0} onClick={() => handleChoiceTag()}>{`전체 (${
        selectedCategory ? posts?.filter((v) => v.properties.category.select.name === selectedCategory).length : posts?.length
      } ) `}</TagItem>
      {tagData?.map((v) => {
        const length = posts.map((post) => post.properties.tags.multi_select.map((tag) => tag.name)).filter((item) => item.includes(v) === true).length;
        return (
          <>
            <TagItem selectedTag={selectedTag.includes(v)} key={v.id} onClick={() => handleChoiceTag(v)}>{`${v} (${length})`}</TagItem>
          </>
        );
      })}
    </Base>
  );
};
javascript

이제 필터 컴포넌트를 열어 카테고리와 태그를 선택할 수 있고 선택된 요소들을

포스트 리스트 컴포넌트에서 useSelector로 받아와 해당하는 포스트들을 불러올 수 있다!

느낀 점

생각보다 많이 복잡했다. contextAPI로만 관리하고 있엇는데 필터 기능을 위해 Redux를 도입했다.

category는 하나의 요소지만 tag는 한 포스트의 여러 개가 있을 수 있고 필터에서 태그 선택도 복수 선택이 가능하게 구현했기 때문에 배열과 배열 사이의 공통된 요소가 존재하는지 확인하기 위해 잘 안쓰던 some 메소드도 사용했다.