오늘 만들어 볼 것은 데이터 테이블이다.
아주 흔하게 볼 수 있는 구글 머테리얼의 데이터 테이블이다. 기능 요건을 정리해보면 다음과 같다. 단, 여기에서 페이지네이션은 일단 고려하지 않는다. 이건 2탄에...
기본적인 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>
여기에 하나하나 살을 붙여보자.
우선 상태를 설정해주어야 할 것이다. 조건을 확인해보면,
와 같은 두가지 상태를 저장해야 한다.
그렇다면 다음과 같이 상태를 작성할 수 있겠다.
// 활성화된 체크박스 id list
[id1, id2, id3...]
// 필터 설정값
{
selectedKey: 'company',
direction: 'top' | 'bottom'
}
참고로 필터 설정값들을 각각의 상태가 아닌 object로 두게 된 이유는, 상태가 변경되는 시점이 항상 일치하기 때문이다.
이벤트는 다음과 같을 것이다.
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탄에 이어서 계속!