pagination.md

윤뿔소·2024년 1월 22일
0
post-thumbnail

Pagination

페이지네이션 로직에 관한 문서.
뒤로 가기 및 새로고침 등 웹에서 어떤 변화가 생겨도 페이지 상태가 그대로 유지되기 위해 현재 페이지, 페이지 당 데이터 개수 상태들을 가져와 URL의 Parameters로 관리하는 구조로 만듦.

useChangeParamsPage 커스텀 훅

  • 페이지네이션 URL 파라미터를 변경하는 데 사용되는 훅.
  • Next.js에서 제공하는 라우터(useRouter), 현재 경로(usePathname), 및 URL 파라미터(useSearchParams)를 사용해 URL을 가져오거나 수정.
  • updateQueryString 함수를 통해 URL 파라미터를 갱신하고, 페이지 및 항목 수를 처리하는 여러 함수들이 정의.
    • handlePagination: 기본값이 1인 currentPage를 변경하는 함수.
    • handlePerPage: 기본값이 10인 itemsPerPage를 변경하는 함수.
  • 최종적으로 변경된 값을 반환.
// 실사용 예시는 pagination 컴포넌트에 있음, 드롭다운을 선택해 필터를 거는 로직이나 searchText 검색하는 로직 등 page=1로 초기화 돼야하는 부분에는 초기화 진행하기.
import { usePathname, useRouter, useSearchParams } from 'next/navigation';

/**
 * 이 훅은 페이지네이션 URL 파라미터를 변경하는데 사용.
 * 기본적으로 perPage는 10, page는 1로 설정.
 */
const useChangeParamsPage = () => {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  // Next.JS App Router Updating searchParams example 참고
  // https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams
  const updateQueryString = (name: string, value: string) => {
    // 가지고 있는 searchParams를 다시 URL 객체로 초기화
    const params = new URLSearchParams(searchParams);
    // set : 있는 name을 value로 갱신(append는 추가가 되므로 사용 X)
    params.set(name, value);
    return params.toString();
  };

  // 기본값 설정
  const itemsPerPage = searchParams.get('perPage') || '10';
  const currentPage = searchParams.get('page') || '1';

  /**
   * 페이지 번호를 변경하고 해당 페이지로 이동하는 함수
   * @param {string} pageNumber - 이동할 페이지 번호
   */
  const handlePagination = (pageNumber: string) => {
    // pageNumber에 숫자가 들어오지 않을 시 예외처리
    if (isNaN(parseInt(pageNumber))) {
      router.push(pathname + '?' + updateQueryString('page', '1'));
      return;
    }
    router.push(pathname + '?' + updateQueryString('page', pageNumber));
  };

  /**
   * 한 페이지에 표시될 아이템 개수를 변경하고 해당 페이지로 이동하는 함수
   * @param {'10' | '30' | '50'} perPage - 변경할 페이지당 아이템 개수
   */
  const handlePerPage = (perPage: '10' | '30' | '50') => {
    handlePagination('1');
    // perPage에 숫자가 들어오지 않을 시 예외처리
    if (isNaN(parseInt(perPage))) {
      router.push(pathname + '?' + updateQueryString('perPage', '10'));
      return;
    }
    router.push(pathname + '?' + updateQueryString('perPage', perPage));
  };

  // 반환
  return { itemsPerPage, currentPage, handlePagination, handlePerPage };
};

export default useChangeParamsPage;

PaginationComponent 컴포넌트

  • 페이지를 나타내는 컴포넌트로, 페이지 변경 요청 및 표시에 필요한 로직이 구현되어 있음.
  • handlePrevClickhandleNextClick 함수는 이전/다음 페이지로 이동하게 하는 기능.
  • 현재 페이지와 전체 페이지 수를 이용하여 페이지 번호를 생성, 블록 계산 등의 로직이 있음.
    1. blockStart 계산: 현재 페이지를 5로 나누어 현재 블록의 첫 페이지를 계산.
    2. handlePrevClick 함수: 이전 버튼 클릭 시, 현재 블록의 이전 블록으로 이동하며 5 이상인 경우 끝 페이지, 5 이하인 경우 1페이지로 이동.
    3. handleNextClick 함수: 다음 버튼 클릭 시, 현재 블록의 다음 블록으로 이동하고 (전체 페이지 수 - 5)보다 작으면 다음 5의 배수 페이지, 그렇지 않으면 마지막 페이지로 이동.
  • 클릭 이벤트에 따라 페이지 변경 콜백(onPageChange)을 호출. 여기에 handlePagination이 들어감.
// Next.JS App Router Updating searchParams example 참고
// https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams
interface PaginationComponentProps {
  // 페이지 수정 함수
  onPageChange: (pageNumber: string) => void;
  // 총 페이지
  totalItems: number | undefined;
  // 페이지 당 보일 데이터 개수(perPage)
  itemsPerPage: string;
  // 현재 페이지(start)
  currentPage: string;
}

/**
 * PaginationComponent는 페이지를 나타내는 컴포넌트
 * @param onPageChange - 페이지 변경 시 호출되는 콜백 함수
 * @param totalItems - 전체 항목 수를 나타내며, 기본값은 0
 * @param itemsPerPage - 페이지당 항목 수를 나타냄
 * @param currentPage - 현재 선택된 페이지의 번호
 */
export default function PaginationComponent({
  onPageChange,
  totalItems = 0,
  itemsPerPage,
  currentPage,
}: PaginationComponentProps) {
  // 전체 페이지 수 계산
  const pageCount = Math.ceil(totalItems / parseInt(itemsPerPage));
  // 페이지 번호 배열 생성
  const pages = [...Array(pageCount)]?.map((_, i) => i + 1);

  // 블록은 각 번호 Section을 뜻함. 총 페이지가 19페이지라면 1,2,3,4,5가 한 블럭 ~ 16,17,18,19가 한 블럭.
  // 블록은 5 페이지 단위로 구성되며, 현재 페이지가 속한 블록의 첫 페이지와 마지막 페이지를 기준으로 이전과 다음 페이지를 계산함.

  // 블록의 첫 번째 페이지 계산
  const blockStart = Math.floor((parseInt(currentPage) - 1) / 5) * 5;

  /**
   * 이전 버튼 클릭 시 호출되는 핸들러 함수. 이전 버튼을 누를 시 새 블록 지점으로 가면서 그 블록의 제일 첫번째 페이지로 이동.
   * 현재 페이지가 5 이상인 경우 이전 블록의 끝 페이지로 이동.
   * 현재 페이지가 5 이하인 경우 1페이지로 이동.
   */
  const handlePrevClick = () => {
    const prevPage = Math.max(1, blockStart);
    onPageChange(`${prevPage}`);
  };

  /**
   * 다음 버튼 클릭 시 호출되는 핸들러 함수. 다음 버튼을 누를 시 새 블록 지점으로 가면서 그 블록의 제일 마지막 페이지로 이동.
   * 현재 페이지가 (전체 페이지 수 - 5)보다 작은 경우 다음 5의 배수 페이지로 이동.
   * 그렇지 않으면 마지막 페이지로 이동.
   */
  const handleNextClick = () => {
    const nextPage = Math.min(blockStart + 6, pageCount);
    onPageChange(`${nextPage}`);
  };

  return (
    <nav className='flex items-center justify-center' aria-label='Pagination'>
      <button
        onClick={handlePrevClick}
        className='bg-gray-outline mr-1 h-[25px] w-10 rounded text-xs font-medium transition-all hover:bg-opacity-70'
      >
        이전
      </button>
      {pages
        ?.slice((Math.ceil(parseInt(currentPage) / 5) - 1) * 5, Math.ceil(parseInt(currentPage) / 5) * 5)
        .map((pageNumber) => (
          <span
            key={pageNumber}
            className={`mr-1 h-[1.5625rem] w-[1.5625rem] cursor-pointer rounded text-xs font-medium ${
              `${pageNumber}` === currentPage
                ? 'bg-emerald-5 cursor-default text-white'
                : 'bg-gray-outline transition-all hover:bg-opacity-70'
            } flex items-center justify-center`}
            onClick={() => onPageChange(`${pageNumber}`)}
          >
            {pageNumber}
          </span>
        ))}
      <button
        onClick={handleNextClick}
        className='bg-gray-outline h-[25px] w-10 rounded text-xs font-medium transition-all hover:bg-opacity-70'
      >
        다음
      </button>
    </nav>
  );
}

실사용 예시 코드

BookManage 컴포넌트

  • 서버에서 페이징된 데이터를 가져와 표시하는 컴포넌트.
  • fetchAuth 함수 및 useQuery 훅을 사용해 GET 요청, QueryString을 보내는 등의 서버와의 통신을 처리.
  • useChangeParamsPage 훅을 사용해 페이지 및 항목 수 변수인 currentPage, itemsPerPage 처리.
  • PaginationComponent를 렌더링하고, 페이지 변경에 따라 데이터를 다시 가져오도록 함(리액트쿼리의 refetchTxnBook).
/// BookManage 컴포넌트
export default function BookManageContainer() {
  const searchParams = useSearchParams();
  const vendorId = searchParams.get('vendorId');
  const {
    isLoading,
    isError,
    data,
    refetch: refetchTxnBook,
  } = useQuery<TxnBookResponse>({
    queryKey: ['txnBookList'],
    queryFn: () =>
      fetchAuth({
        url: '/customers/txnbooks',
        options: {
          queryParams: {
            start: currentPage,
            perPage: itemsPerPage,
            startDate: startDateFormat as string,
            endDate: endDateFormat as string,
            [txnBookCateKey]: txnBookCateValue,
            vendorId,
            qs: searchedText,
          },
        },
      }),
  });
  const txnBookData = data?.payload?.items ?? [];

  // Pagination
  const totalItems = data?.payload?.pgn?.total ?? 0;
  const { itemsPerPage, currentPage, handlePagination, handlePerPage } = useChangeParamsPage();

  // Reset Button 핸들러
  const handleResetButton = () => {
    handlePagination('1');
    handlePerPage('10');
    setSelectPerPage('10건 씩 보기');
    setStartDate(null);
    setEndDate(null);
    setSelectCate('');
    setTxnBookCateValue(undefined);
    setSearchedText('');
    setSearchTextInput('');
  };

  // itemPerPage 드롭다운 상태 및 핸들러
  const [selectedPerPage, setSelectPerPage] = useState('10건 씩 보기');
  const handleSelectPerPage = (selected: string) => {
    if (selected === '10건 씩 보기') {
      setSelectPerPage('10건 씩 보기');
      handlePerPage('10');
    }
    if (selected === '30건 씩 보기') {
      setSelectPerPage('30건 씩 보기');
      handlePerPage('30');
    }
    if (selected === '50건 씩 보기') {
      setSelectPerPage('50건 씩 보기');
      handlePerPage('50');
    }
    handlePagination('1');
  };
...
  // refetch
  useEffect(() => {
    refetchTxnBook();
  }, [refetchTxnBook, itemsPerPage, currentPage, startDate, endDate, selectedCate, vendorId, searchedText]);
...

페이지 리스트 푸터

  • 전체 아이템 수(totalItems), 현재 페이지 범위 등을 표시하는 부분.
  • PaginationComponent를 렌더링하여 페이지 확인 onPageChangehandlePagination을 넣어 이동 요청을 가능케 함.
...
        {/* 표 푸터 */}
        <div className='static bottom-0 flex h-[2.81rem] w-full items-center justify-between bg-white pl-12 pr-4'>
          <div className='text-xs font-medium text-emerald-gray-2'>
            {txnBookData
              ? `${totalItems || 0}개 중 ${parseInt(itemsPerPage) * (parseInt(currentPage) - 1) + 1} - ${
                  parseInt(itemsPerPage) * (parseInt(currentPage) - 1) + txnBookData?.length
                }`
              : '0개 중 0 - 0'}
          </div>
          <div>
            <PaginationComponent
              onPageChange={handlePagination}
              totalItems={totalItems}
              itemsPerPage={itemsPerPage}
              currentPage={currentPage}
            />
          </div>
        </div>

페이지네이션 초기화 필요 로직

필터 상태 등 전체 데이터 수가 변경되거나 페이지를 초기화하면 좋을 로직에 handlePagination('1')를 작성해 초기화가 되도록 함.
그 아래는 초기화가 필요한 상태들.

  1. Reset 핸들러 함수인 handleResetButton 함수
  2. itemPerPage가 변경될 때 페이지 초기화
  3. 검색 로직 중 검색 쿼리인 qs에 제출할 때(handleSearch 함수) 초기화
  4. 모든 드롭다운 필터가 변경될 때(handleSelected~ 함수) 초기화
    • 약사: 주문상태, 반품상태, 예치금 등
    • 제약사: 주문상태, 반품상태, 지부, 직책, 영업사원, 약국 등

예시 코드

// 1. Reset 핸들러 함수인 `handleResetButton` 함수
const handleResetButton = () => {
  handlePagination('1');
  handlePerPage('10');
  setSelectPerPage('10건 씩 보기');
  setStartDate(null);
  setEndDate(null);
  setSelectCate('');
  setTxnBookCateValue(undefined);
  setSearchedText('');
  setSearchTextInput('');
};
// 2. `itemPerPage`가 변경될 때 페이지 초기화
const [selectedPerPage, setSelectPerPage] = useState('10건 씩 보기');
const handleSelectPerPage = (selected: string) => {
  if (selected === '10건 씩 보기') {
    setSelectPerPage('10건 씩 보기');
    handlePerPage('10');
  }
  if (selected === '30건 씩 보기') {
    setSelectPerPage('30건 씩 보기');
    handlePerPage('30');
  }
  if (selected === '50건 씩 보기') {
    setSelectPerPage('50건 씩 보기');
    handlePerPage('50');
  }
  handlePagination('1');
};
// 3. 검색 로직 중 검색 쿼리인 `qs`에 제출할 때(`handleSearch` 함수) 초기화
const [searchTextInput, setSearchTextInput] = useState('');
const [searchedText, setSearchedText] = useState('');
const handleSearchInput = (e: ChangeEvent<HTMLInputElement>) => {
  setSearchTextInput(e.target.value);
  if (e.target.value === '') {
    setSearchedText('');
  }
};
const handleSearch = () => {
  // 검색어에서 공백을 +로 치환하는 로직
  const sanitizedSearchText = searchTextInput.split(' ').join('+');
  setSearchedText(sanitizedSearchText);
  // 페이지네이션 초기화
  handlePagination('1');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') {
    handleSearch();
  }
};
// 4. 모든 드롭다운 필터가 변경될 때(`handleSelected~` 함수) 초기화
const handleSelectedBranch = (selected: string) => {
  setSelectedBranch(selected);
  const selectedBranchId = orgBranchListData?.find((branchItem) => branchItem?.name === selected)?.branchId || '';
  handlePagination('1');
  setBranchId(selectedBranchId);
};
const [selectedPosition, setSelectedPosition] = useState<string>('');
const positionNameArr = orgPositionListData?.map((positionItem) => {
  return positionItem?.name;
});
const [positionId, setPositionId] = useState<string>('');
const handleSelectedPosition = (selected: string) => {
  setSelectedPosition(selected);
  const selectedPositionId =
    orgPositionListData?.find((positionItem) => positionItem?.name === selected)?.positionId || '';
  handlePagination('1');
  setPositionId(selectedPositionId);
};
profile
코뿔소처럼 저돌적으로

0개의 댓글