데이터 테이블 만들기 (1)

Snoop So·2023년 8월 13일
0

오늘 만들어 볼 것은 데이터 테이블이다.

아주 흔하게 볼 수 있는 구글 머테리얼의 데이터 테이블이다. 기능 요건을 정리해보면 다음과 같다. 단, 여기에서 페이지네이션은 일단 고려하지 않는다. 이건 2탄에...

  1. 왼쪽의 체크박스를 클릭하면 해당 row가 선택되어야 한다.
  2. 헤더의 라벨을 클릭하면 정렬 기준이 해당 라벨을 기준으로 변경되어야 한다.
  3. 이 때, 정렬 아이콘이 아래를 가리키면 내림차순, 위를 가리키면 오름차순이 되어야 한다.

기본적인 html 테이블 구조는 다음과 같다.

<table>
  <tr>
    <th>Company</th>
    <th>Contact</th>
    <th>Country</th>
  </tr>
  <tr>
    <td>Alfreds Futterkiste</td>
    <td>Maria Anders</td>
    <td>Germany</td>
  </tr>
  <tr>
    <td>Centro comercial Moctezuma</td>
    <td>Francisco Chang</td>
    <td>Mexico</td>
  </tr>
</table>

여기에 하나하나 살을 붙여보자.

상태 설정하기

우선 상태를 설정해주어야 할 것이다. 조건을 확인해보면,

  1. 어떤 row가 체크박스가 활성화 되었는지
  2. 어떤 정렬 기준이 선택되었으며, 오름차순인지 내림차순인지.

와 같은 두가지 상태를 저장해야 한다.
그렇다면 다음과 같이 상태를 작성할 수 있겠다.

// 활성화된 체크박스 id list
[id1, id2, id3...]

// 필터 설정값
{
	selectedKey: 'company',
	direction: 'top' | 'bottom'
}

참고로 필터 설정값들을 각각의 상태가 아닌 object로 두게 된 이유는, 상태가 변경되는 시점이 항상 일치하기 때문이다.

이벤트 설정하기

이벤트는 다음과 같을 것이다.

  1. 테이블 헤더 클릭 이벤트: 클릭하면 필터 설정값이 변경된다.
  2. 체크 박스 클릭 이벤트 : 클릭하면 체크된 id 목록에 추가된다.
function handleCheckboxClick (e) {
	// 타겟의 id를 checkedIds 에 넣어주기
}

function handleHeaderClick (e) {
	// 타겟의 id를 가져와 selectedKey에 저장해주기
}

여기까지 작성하다보니 고민이 생겼다. 그런데 이걸 어떻게 정렬하지?
우선 데이터를 넣는 형식부터 다시 고민을 해봐야겠다.

데이터 형식을 어떻게 넣을까

[
	{company: 'Alfreds Futterkiste', contact: 'Maria Anders', country: 'Germany'},
  	{company: 'Centro comercial Moctezuma', contact: 'Francisco Chang', country: 'Mexico'}
]

위와 같이 된다고 했을때, Array.sort()를 key 값만 묶어서 할 수 있을까? 왠지 가능할 것 같...다!
마침 mdn에 활용할 수 있는 아주 좋은 로직이 나와있는 것을 확인할 수 있었다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/sort

var items = [
  { name: "Edward", value: 21 },
  { name: "Sharpe", value: 37 },
  { name: "And", value: 45 },
  { name: "The", value: -12 },
  { name: "Magnetic", value: 13 },
  { name: "Zeros", value: 37 },
];

// value 기준으로 정렬
items.sort(function (a, b) {
  if (a.value > b.value) {
    return 1;
  }
  if (a.value < b.value) {
    return -1;
  }
  // a must be equal to b
  return 0;
});

// name 기준으로 정렬
items.sort(function (a, b) {
  var nameA = a.name.toUpperCase(); // ignore upper and lowercase
  var nameB = b.name.toUpperCase(); // ignore upper and lowercase
  if (nameA < nameB) {
    return -1;
  }
  if (nameA > nameB) {
    return 1;
  }

  // 이름이 같을 경우
  return 0;
});

내가 딱 원하던 로직이다. 가져다 쓰도록 하자.
그나저나 string끼리 비교문에 넣을 수 있다니 정말 신비한 js의 세계... 다들 욕하지만 나는 이런 편의성이 또 js의 매력이라고 생각한다. 암튼.

여기에서 return 값이 의미하는 바가 좀 헷갈렸는데, 찾아보니 array를 순차적으로 비교 하여 결과적으로 음수가 나오는 경우 뒤로 보내거나 앞으로 보내거나 하는 처리를 하는 것이었다.

https://noirstar.tistory.com/359 < 요 블로그를 읽어보았다.

여기에서 조금 더 복잡해지는데, 그럼 문자와 숫자의 경우를 분리하여 sort 해야하는 상황이 되었다. 이 로직을 어떻게 처리하느냐에 또 관건이 될 것 같다.

아래와 같이 sortedData가 currentType에 따라서 변경될 수 있도록 작성하였다.

const currentType = useMemo(() => typeof data?.[0][options.selectedKey], [
    data,
    options.selectedKey
]);

const sortedData: T[] = useMemo(
  () =>
  currentType === "string"
  ? data?.sort(stringCompare)
  : currentType === "number"
  ? data?.sort(numCompare)
  : data,
  [options]
);

useMemo를 쓴 이유는, 상태가 두개인데 options 외의 다른 상태에 위 변수의 값이 변경될 필요가 없기 때문에 최적화 이슈로 사용했다.

이제 정렬 기준이 바뀌면 정렬이 바뀌도록 작업을 해야한다.

고민하다, 손쉽게 compare 함수가 options.direction에 따라 1 또는 -1을 반환하도록 작성했다. 또한, options이 변경될 때만 compare 변경되도록 useCallback을 적용하여 최적화했다.

const stringCompare = useCallback(
  function (a, b) {
    var nameA = a[options.selectedKey]?.toUpperCase(); // ignore upper and lowercase
    var nameB = b[options.selectedKey]?.toUpperCase(); // ignore upper and lowercase
    if (nameA < nameB) {
      return options.direction === "top"
        ? 1
      : options.direction === "bottom"
        ? -1
      : 0;
    }
    if (nameA > nameB) {
      return options.direction === "top"
        ? -1
      : options.direction === "bottom"
        ? 1
      : 0;
    }

    return 0;
  },
  [options]
);

위와 같은 과정을 통해 작성한 코드는 아래와 같다.

import { useState, useMemo, useCallback } from "react";
import { capitalize } from "./utils";

interface OptionType {
  selectedKey: string | null;
  direction: "top" | "bottom";
}

function Arrow({ direction }: { direction: OptionType["direction"] }) {
  if (direction === "top") return <>⬆︎</>;
  if (direction === "bottom") return <></>;
  return <></>;
}

export default function DataTable<T>({ data }: { data: T[] }) {
  const [checkedIds, setCheckedIds] = useState([]);
  const [options, setOptions] = useState<OptionType>({
    selectedKey: Object.keys(data[0])[0],
    direction: "bottom"
  });

  function handleHeaderClick(e) {
    const target = e.currentTarget;
    setOptions({
      selectedKey: target.id,
      direction: options.direction === "top" ? "bottom" : "top"
    });
  }

  const numCompare = useCallback(
    function (a, b) {
      if (a[options.selectedKey] > b[options.selectedKey]) {
        return options.direction === "top"
          ? 1
          : options.direction === "bottom"
          ? -1
          : 0;
      }
      if (a[options.selectedKey] < b[options.selectedKey]) {
        return options.direction === "top"
          ? -1
          : options.direction === "bottom"
          ? 1
          : 0;
      }
      return 0;
    },
    [options]
  );

  const stringCompare = useCallback(
    function (a, b) {
      var nameA = a[options.selectedKey]?.toUpperCase(); // ignore upper and lowercase
      var nameB = b[options.selectedKey]?.toUpperCase(); // ignore upper and lowercase
      if (nameA < nameB) {
        return options.direction === "top"
          ? 1
          : options.direction === "bottom"
          ? -1
          : 0;
      }
      if (nameA > nameB) {
        return options.direction === "top"
          ? -1
          : options.direction === "bottom"
          ? 1
          : 0;
      }

      return 0;
    },
    [options]
  );

  const currentType = useMemo(() => typeof data?.[0][options.selectedKey], [
    data,
    options.selectedKey
  ]);

  const sortedData: T[] = useMemo(
    () =>
      currentType === "string"
        ? data?.sort(stringCompare)
        : currentType === "number"
        ? data?.sort(numCompare)
        : data,
    [options]
  );

  return (
    <table>
      <tr>
        {sortedData &&
          Object.keys(sortedData[0]).map((key) => {
            return (
              <th id={key} onClick={handleHeaderClick}>
                {capitalize(key)}
                {options.selectedKey === key && (
                  <Arrow direction={options.direction} />
                )}
              </th>
            );
          })}
      </tr>
      {sortedData?.map((element) => (
        <>
          <tr>
            {Object.values(element).map((value: any) => {
              return <td>{value} </td>;
            })}
          </tr>
        </>
      ))}
    </table>
  );
}

깔끔하게 작동하는 것 같다!

결과물은 아래 링크에서 확인할 수 있다.
https://codesandbox.io/s/suspicious-khorana-w7ptpk?file=/DataTable.tsx

다음번에는
1. 체크박스 동작 시키기
2. 페이지네이션 적용
3. 테스트 코드 작성
4. 라이브러리 배포까지 해보자.

2탄에 이어서 계속!

0개의 댓글