kokotable
v0.99.3
Published
헤드리스 테이블 코어 + MUI 렌더러 + 서버 모드 — 백엔드 QueryBuilder 와 같은 의미의 필터, 검색 상태, 확장 계약
Maintainers
Readme
koko-table
NewDewbellSite 의 테이블/QueryBuilder/PivotBuilder 를 material-react-table 없이
다시 세운 패키지. 클라이언트·서버 모드 모두 갖췄고(@koko-table/query), 렌더러는 @koko-table/mui 다.
두 배포가 한 저장소에서 나간다 — npm kokotable(프론트)과 koko-table-server(백엔드, 지금은 GitHub 태그에서 설치 · PyPI 는 보류).
계약이 바뀌면 한 커밋에서 양쪽이 같이 빨간불이 된다(npm run contract:verify).
packages/core kokotable 헤드리스 (MUI·MRT 무의존) — 내부 워크스페이스 이름 @koko-table/core
packages/mui kokotable/mui MUI 렌더러 · 애드온 · 덮어쓰기 계층
packages/query kokotable/query 서버 모드 (react-query)
contract/ kokotable/contract 백엔드 TableRequest JSON Schema — 두 쪽이 공유하는 유일한 것
server/ koko-table-server 백엔드 엔진 (파이썬) — TableRequest → SQLAlchemy → TableResponse
stories/ Storybook (vite)설치와 사용
배포 단위는 kokotable 하나다. 서브패스로 세 진입점을 낸다.
npm i kokotable
# peer: react, react-dom, @tanstack/react-table
# kokotable/mui 를 쓰면: @mui/material @mui/icons-material @emotion/react @emotion/styled
# kokotable/query 를 쓰면: @tanstack/react-query
# 행/열 가상화(kokotable/mui/virtual): @tanstack/react-virtual| import | 내용 | 구 이름(vendor 시절) |
|---|---|---|
| kokotable | 헤드리스 코어 — 검색 상태·요청 조립·서버 동형 필터·컬럼 정의·확장 계약·계약 스키마·클라이언트 조립 useClientTable | @koko-table/core |
| kokotable/mui | MUI 렌더러·레이아웃·애드온·KokoTableProvider·TableScreen | @koko-table/mui |
| kokotable/mui/virtual | 행·열 가상화 (VirtualTableContent, VirtualTableScreen) | @koko-table/mui/virtual |
| kokotable/query | 서버 모드 — 정의 조립 useTable(두 모드)·useServerTable, useServerTableData·useServerGrouping | @koko-table/query |
| kokotable/contract/table-request.schema.json | 백엔드 TableRequest JSON Schema | — |
import { defineTable, createColumnHelper, buildColumns } from "kokotable";
import { useTable } from "kokotable/query";
import { KokoTableProvider, TableScreen } from "kokotable/mui";
const h = createColumnHelper<Row>();
const def = defineTable<Row>({
key: "sales",
columns: buildColumns([h.ColumnIsText({ key: "name", header: "이름" }), h.ColumnIsNumber({ key: "amount", header: "금액" })]),
server: { queryFn: (req, { signal }) => api.post("/sales/query", req, { signal }), queryKey: ["sales"] },
features: { selection: true, expand: true, rowNumbers: true },
urlSync: true,
});
function SalesPage() {
const bundle = useTable(def); // 정의에 server 가 있으니 서버 조립. data 를 주면 클라이언트 조립
return (
<KokoTableProvider defaults={{ density: "compact" }}>
<TableScreen bundle={bundle} layout="side" />
</KokoTableProvider>
);
}서버 요청 계약(TableRequest)은 백엔드 pydantic 모델에서 뽑은 JSON Schema(kokotable/contract/table-request.schema.json) 와 1:1 이다. 백엔드(server/, 파이썬 koko-table-server)는 따로 배포되고 프론트는 그것을 직접 import 하지 않는다 — 두 쪽이 공유하는 것은 이 스키마뿐이고 CONTRACT_VERSION 이 같으면 호환된다. 같은 저장소에 두는 이유는 어긋남을 CI 가 잡게 하는 것이다: npm run contract:verify 가 프론트 → 스키마(ajv) → 백엔드 pydantic → SQLite 왕복을 한 번에 돌린다(docs/backend-contract.md).
ERP 에서 vendor alias 를 npm 의존으로 바꾸기
평가 레인은 빌드본을 front/vendor/koko-table/{core,mui,query}/ 에 복사하고 vite.config.ts·vitest.config.ts 의 resolve.alias 와 tsconfig.app.json 의 paths 로 @koko-table/* 를 그 경로에 붙였다. npm 으로 바꾸는 순서:
npm i kokotable(front 에서). peer 는 이미 있다(react·@mui·@emotion·@tanstack/react-table·react-query).- import 치환:
@koko-table/core→kokotable,@koko-table/mui→kokotable/mui,@koko-table/mui/virtual→kokotable/mui/virtual,@koko-table/query→kokotable/query. 이름만 바뀌고 export 는 같다. 또는 alias 로@koko-table/core: "kokotable"등을 붙여 두고 점진 치환. vite.config.ts·vitest.config.ts의 vendor alias 4줄과tsconfig.app.jsonpaths4줄 삭제,front/vendor/koko-table삭제.- React 인스턴스가 하나인지 확인(
npm ls react) — npm 의존이면 peer 로 front 의 react 를 쓴다.
컬럼 정의
0.95 → 0.97 로 올릴 때 바꿔야 하는 것은 docs/migration-0.97.md 에 모았다(빌더 메서드·meta 접근자·kokotable/legacy·기본 동작 변경).
렌더러가 앱 MUI 테마에서 무엇을 읽는지(다크 모드 포함)는 docs/theming.md 에 있다.
createColumnHelper<T>() 의 ColumnIs* 는 ColumnBuilder 메서드를 묶은 프리셋이고, new ColumnBuilder(key, header, dataType) 로 직접 시작해도 같다.
메서드 열셋(setFilter / setHeader · setAlign · setSizing · setVisibility · setSort · setGroup / setCell · setEdit · setAgg / 확장 setCustom · setProps)과
옛 이름(setDisplay · setEditable · setCellWrapper …)을 새 정의로 바꿔 주는 임시 변환기 kokotable/legacy(createLegacyColumnHelper · LegacyColumnBuilder · 옛 URL 포맷 legacyUrlFallback · 옛 MRT 컬럼 모양 toLegacyColumnDefs), 그리고
프리셋이 무엇을 묶었는지, 어느 프리셋이 소비 앱 조회기(commonCodeLookup · userLookup)에 기대는지는 docs/column-definition.md 에 정리했다.
meta 를 통째로 쓰는 setMeta 는 없다 — 프리셋 하나가 자기 meta 슬라이스와 읽기 접근자를 한 모듈에 갖는다(columns/presets/link.ts 의 ColumnIsLink · LinkMeta · linkOf 처럼).
읽는 쪽은 meta?.x 대신 그 접근자(headerMetaOf · cellDecorOf · editOf · linkOf · relationColumnsOf …)를 부른다.
화면은 세 구역, 계약은 넷
┌─ 필터 구역 ──────────── FilterSource ┐
├─ 콘텐츠 구역 ────────── ContentSource │ 각 구역은 테이블이 아니라
└─ 페이지네이션 구역 ──── PaginationSource ┘ 자기 계약에만 의존한다
정렬 ───────────────── SortSource 구역이 아니라 계약정렬은 테이블에서 헤더 안에 그려지지만 ContentSource 에 넣지 않았다. 두 축이
독립이기 때문이다 — 카드 그리드는 콘텐츠는 있는데 정렬 UI 가 없고,
"정렬: 금액↓" 단독 드롭다운은 정렬은 있는데 콘텐츠가 없다.
테이블 인스턴스는 이 계약들의 구현 하나일 뿐이라, 네 계약 모두
table 을 받을 수도 search/data 를 받을 수도 있다:
// 기존 화면 — 테이블 하나면 전부 붙는다
<FilterArea source={table} />
<TableContent table={table} content={contentSource(data)} sort={sortSource(search)} />
<PaginationArea source={table} />
// 카드 화면 — 테이블 인스턴스 없음
<FilterArea source={search} />
<CardContent source={contentSource(data)} renderCard={...} />
<PaginationArea source={paginationSource(search, data)} />검색 방식 — 버튼 / 자동
표 단위 searchMode 하나로 정한다. 컬럼마다 debounce 를 거는 방식이 아니다.
| 모드 | 동작 | 렌더러 |
|---|---|---|
| "button" (기본) | 조건은 draft 에만 쌓이고 apply()(검색 버튼·Enter)를 눌러야 조회 | SearchBar 에 검색 버튼 |
| "auto" | 필터·전역검색 값이 바뀌면 debounceMs 뒤 자동 조회 — debounce 는 코어 한 곳이 든다 | SearchBar autoApply 는 안내 문구만, GlobalFilterField 는 search.searchMode 를 읽어 타이머를 겹치지 않는다 |
useTableSearch({ columns, searchMode: "auto", debounceMs: 300 }); // 훅
defineTable({ …, search: { mode: "auto", debounceMs: 300 } }); // 정의 한 장 → TableScreen 이 버튼을 감춘다
configureTableDefaults({ searchMode: "auto", debounceMs: 300, pageSize: 50 }); // 앱 진입점에서 한 번 — 모든 표의 기본search.searchMode 로 현재 모드를 읽는다. 컬럼 단위 filter.applyOnChange 는 button 모드에서 특정 컬럼만 즉시 조회하게 두는 예외(auto 모드에서 false 를 주면 그 컬럼만 버튼을 기다린다)이며 보통 필요 없다.
3층 구조
| 층 | 훅 | 테이블 필요? |
|---|---|---|
| 1 | useTableSearch({ columns }) | ❌ |
| 2 | useTableData({ columns, request, data }) | ❌ |
| 3 | useDataTable({ columns, search, data }) | ✅ |
애드온: useGrouping, useUrlSync + readUrlSearchState, useSearchHistory.
피벗은 순수 함수 runClientPivot(rows, spec) / transposePivot(spec).
실행
npm install
npm test # 600 케이스
npm run typecheck # 패키지 빌드
npx tsc -p packages/core/checks # 설계 조건 타입 레벨 검증
npm run storybook # 개발 서버 (6006)
npm run storybook:build # 정적 덤프 → storybook-static/client / server 의미 일치
클라이언트 필터는 백엔드 QueryBuilder 와 같은 의미로 구현돼 있다.
분기 순서(filters/registry.ts)까지 _apply_filter_by_type 을 따른다 —
순서가 어긋나면 between 처럼 여러 타입이 지원하는 연산자에서 결과가 갈린다.
filter.variant 가 관계·선형·일정이면 meta.dataType 보다 먼저 판정한다
(ColumnIsRelation 컬럼은 dataType 이 text 여도 관계 필터를 탄다).
서버 SQL 의 3값 논리도 그대로 따른다 — notIn/notInArray/notEquals/notContains
는 NULL 셀을 통과시키지 않고, 관계 조건의 equals 는 PG 캐스팅처럼 숫자 셀과
문자열 값(2 와 "2")을 같게 본다. linearExact 는 수량을 == 로만 판정하고,
scheduleOverlaps 상한은 반열림(start < end+1d)이다.
요청 조립(TableSearch.request)에서 맞추는 것:
- 리스트 연산자(
inArray/notInArray/in/notIn/arrIncludes*/inArrayFilter)의 스칼라 값은[value]로 감싼다 — 서버ListFilter가 문자열을 글자 단위로 쪼개 enum 컬럼에서 500 을 내기 때문이다. - 값 없는 연산자(
empty/notEmpty/isEmpty/hasChild/hasNotChild)는 값이 비어도 항목을true로 싣는다 — 서버는value None인 필터를 통째로 건너뛴다. meta.sortExpandsTo가 있는 통합 컬럼의 정렬은 실제 필드들로 펼쳐 싣는다 (expandSorting). UI 상태는 통합 컬럼 id 를 유지한다.grouping이 있으면meta.enableAgg/meta.aggregations에서 파생한aggregation을 싣는다 (deriveAggregation).SearchState.aggregation을 명시하면 그것이 우선한다.- 정렬·페이지·그룹 변경은 적용된 params 위에서 그 필드만 갱신한다 — 미적용
draft(필터·연산자·전역검색)가 함께 나가지 않는다.
patchParams(partial)/patchDraft(partial)로 부분 상태를 병합할 수 있다.
pageSize 0 은 서버 LIMIT 0 과 같이 0건이다. "전체" 는 pagination 미지정 또는
PAGE_SIZE_ALL 로만 표현한다.
여전히 다를 수 있는 지점
| 항목 | 클라이언트 | 서버 | 맞추는 방법 |
|---|---|---|---|
| globalFilter 대상 | 선언된 컬럼 중 텍스트 계열(resolveDataKind === "text": text/enum/select/common_code). 숫자·날짜·boolean·관계·선형·일정 제외 | 모델의 varchar/text/enum 컬럼 전부 + FK 대상 테이블의 텍스트 컬럼 (GlobalFilterMixin._collect_auto_columns) | 화면에 선언하지 않은 컬럼이나 FK 대상 텍스트(supplier.name 등)를 검색해야 하면 useTableData({ globalFilterFields: ["name", "code", "supplier.name"] }) 로 경로를 명시한다. 서버도 columns 를 명시하면 같은 목록만 본다 |
| inPeriod 기준 시각 | 브라우저 now | 서버 시각(KST, datetime.now()) | 브라우저 시간대가 KST 가 아니면 경계가 어긋날 수 있다. useTableData({ now }) / runClientQuery(rows, columns, request, { now }) 로 기준 시각을 주입한다 |
| 날짜 경계 해석 실패 ("", "abc") | 경계 없음으로 통과 | 400 | 입력 단계에서 막는다 |
| 텍스트 greaterThan 계열 | contains 로 동작 | _apply_term 에 분기가 없어 ILIKE (같다) | 계약 구멍 — 텍스트 컬럼에 비교 연산자를 노출하지 않는다 |
| 문자열 정렬 순서 | 코드포인트 | DB collation | 한글·영문 혼합에서 다를 수 있다 |
덮어쓰기 계층 (customize)
배포된 패키지를 쓰는 화면(ERP 등)이 포크 없이 문구·컴포넌트·셀/필터 렌더러·메뉴·기본값을 바꾸는 통로.
우선순위는 항상 컬럼 단위 선언 → KokoTableProvider → 기본값 이다. 중첩 Provider 는 바깥 위에 병합된다.
import { KokoTableProvider } from "kokotable/mui";
<KokoTableProvider
labels={{ operators: { fuzzy: "Contains" }, ui: { search: "Search", empty: "No rows" }, aggregations: { sum: "Sum" } }}
components={{ EmptyState: BrandEmpty, HeaderCell: BrandHeaderCell, AggregateChip: PlainChip }}
cellRenderers={[{ id: "number", render: ({ renderedValue }) => <b>{renderedValue}</b> }, { id: "boolean", render: ({ value }) => <Chip label={value ? "ON" : "OFF"} /> }]}
filterInputs={{ text: (field) => <MyTextInput field={field} /> }}
defaults={{ density: "compact", pageSizeOptions: [10, 25, 50], skeletonRows: 3 }}
>
<App />
</KokoTableProvider>| 무엇을 | 컬럼 단위 (최우선) | Provider | 기본값 |
|---|---|---|---|
| 필터 입력 | filter.render (setFilter({ render })) | filterKinds[kind.id].editor → filterInputs[variant] | kind.editor(withEditor) → 내장 kind 의 기본 입력 (FilterInput) |
| 셀 값 | 컬럼 cell (코어 포맷터·setCell) 결과가 renderedValue 로 전달 | cellRenderers[dataType] → chip / link / placeholder | renderCellValue 의 Chip/Link/placeholder |
| 셀 정렬 | meta.align (setAlign) | defaults.cellAlign (표 옵션 TableContent cellAlign / features.cellAlign) | 표 전체 center — 헤더·본문·그룹 집계 셀이 같은 규칙, dataType 으로 갈라 놓지 않는다 |
| 헤더 내용 | meta.headerTooltip / meta.headerAlign | TableContent renderHeader({ header, defaultContent }) | columnDef.header |
| 헤더 정의 (슬롯·규칙) | meta.header = "compact" | defineHeader(…) | headers={[defineHeader({ when, slots, rules })]} | defaultHeader ("헤더 정의하기" 절) |
| 헤더 셀 props | — | TableContent headerCellProps(header, table) | — |
| 헤더 셀 컴포넌트 | — | components.HeaderCell | HeaderCell |
| 컬럼 메뉴 항목 | 헤더 rules 로 항목 제외 | menus.column: { add, remove, replace, order } → TableContent columnMenuItems({ defaultItems }) 가 마지막 | sort.* · pin.* · move.* · hide · group.* (onGroupingChange) · filter.open (onOpenFilter) · resize.reset (리사이즈) · copy.header |
| 컬럼 메뉴 열기 | — | 메뉴 버튼 · 헤더 우클릭 (같은 항목) | enableColumnMenu (Provider defaults.enableColumnMenu) |
| 사용자 설정 기억 | features.remember | useTableMemory({ key, table, search, include, store }) · createTableMemoryStore({ storage, name }) | tableMemoryStore (localStorage koko-table-memory, peer zustand) — 복원은 key 당 한 번, 같은 값이면 no-op (렌더마다 새 table·data 를 넘겨도 루프 없음, 0.97.5) |
| 폭 조절 스필 | — | contentProps.columnResizeSpill · 정의 features.columnResizeSpill — "right" 면 끄는 열의 오른쪽 열만 내준다 | defaults.columnResizeSpill ("none") |
| 컬럼 순서 드래그 | — | 헤더를 끌어 다른 헤더 좌/우에 놓기 (moveColumnTo) · 메뉴 move.* | enableColumnDrag (Provider defaults.enableColumnDrag, 정의 features.columnDrag) |
| 날짜 입력 | meta.dateFilterMode | filterInputs.date / DateField · DateRangeField(숫자 8자리 자동 포맷 · 달력 팝오버 · 빠른 기간 · 한쪽만 정한 열린 범위) | 브라우저 type="date" 를 쓰지 않는다 |
| 행 우클릭 메뉴 | — | menus.row: { add, remove, replace, order } → TableContent rowMenuItems({ row, group, defaultItems }) 가 마지막 | 말단 copy.row / 그룹 expand · collapse · expandToDepth (onExpandGroupToDepth 있을 때) |
| 그룹 줄 건수 (n) | — | TableContent groupCount="always" \| "multiple" \| "never" — "multiple" 은 한 건짜리 묶음의 (1) 을 뺀다(ERP R89). 아직 모르는 건수는 늘 그린다 | defaults.groupCount("always") |
| 그룹 모드 말단 줄의 묶인 열 | — | TableContent groupedLeafCells="show" \| "hide" — "hide" 면 묶인 열의 말단 셀을 비운다(칸은 남긴다, ERP R90). 묶이지 않은 열·그룹이 없을 때는 무관 | defaults.groupedLeafCells("show") |
| 페이지네이션 문구 | — | PaginationArea total="range" \| "count" · pageTooltip(페이지 번호에 행 범위) | defaults.paginationTotal("range") · defaults.paginationPageTooltip(false) |
| 확장 열 헤더 "모두 펼치기" | — | 좌클릭 토글 · 우클릭 메뉴 (항목 고정 — labels.ui.expandAll · expandPage · collapseAll 로 문구만) | 모두 펼치기 / 현재 페이지만 펼치기(페이지 둘 이상) / 모두 접기 |
| 그룹 행 / 상세 패널 행 | — | components.GroupRow(ctx) / components.DetailPanelRow(ctx) | TableContent 기본 배치 |
| 빈 상태 / 에러 / 로딩 | TableContent renderEmpty / renderError | components.EmptyState / ErrorFallback / LoadingSkeleton | 문구 + TableErrorFallback + Skeleton 행 |
| 편집 입력 / 집계 칩 | meta.edit | components.EditCell / components.AggregateChip | EditCell / AggregateChip |
| 문구 (다국어) | — | labels.operators / labels.ui / labels.aggregations(Short) | 한국어 (DEFAULT_LABELS) |
| 기본값 (UI) | 컴포넌트 prop | defaults.{density, cellAlign, pageSizeOptions, skeletonRows, groupRowLayout, groupedColumnMode, enableColumnMenu, enableColumnDrag, stickyHeader, maxHeight, debounceMs, paginationTotal, paginationPageTooltip, groupCount, groupedLeafCells} | DEFAULT_TABLE_DEFAULTS |
| 기본값 (코어) | 훅 옵션 · defineTable | configureTableDefaults({ searchMode, debounceMs, pageSize, groupBatchSize, enableColumnResizing, columnResizeMode, remember, editActivate, dateCloseOnSelect, getRowId }) — 앱 진입점에서 한 번 (packages/core/src/defaults.ts) | button · 300ms · 30 · 100 · 폭 조절 끔 · remember 없음 · 더블클릭 · 안 닫음 · 행 순번 |
useKokoTableConfig() 로 해석된 설정을 읽고, resolveTableProps(props, defaults) 로 자기 컴포넌트에도 같은 규칙을 적용할 수 있다.
모든 확장점은 { id, … } 항목의 레지스트리다(createRegistry · Registry.with({ add, remove, replace, order })) — 셀 렌더러·집계·export 포맷터도
cellRenderers={[…]} / aggregations / exportFormatters 로 받아 useKokoTableConfig().registries.{cells,aggregations,exportFormatters} 로 노출한다.
스토리 "커스터마이즈" · "확장/새 header 정의하기" · "확장/컨텍스트 메뉴 확장" 에 각 통로의 예가 있다.
헤더 정의하기
헤더 셀 안쪽은 헤더 정의가 그린다. 기본 헤더(defaultHeader)도 같은 API 다 — 다섯 슬롯 content · sortIndicator · resizeHandle · menuTrigger · pinIndicator 의 조합.
const amountHeader = defineHeader<SalesRow>({
id: "amount", when: (ctx) => ctx.column.id === "amount", // Provider headers[] 에서 어느 컬럼에 붙일지
slots: { sortIndicator: (ctx) => (ctx.sortState ? "▼" : null) }, // 일부 자리만 교체 (나머지는 기본)
rules: { canPin: () => false, canHide: (ctx) => ctx.column.id !== "idx" }, // 메뉴 항목 + 헤더 클릭 + column.getCan*() 모두 반영
// render: (ctx, slots) => <div>{ctx.defaultContent}{slots.menuTrigger(ctx)}</div> // 셀 내용 통째 교체 (<th> 는 유지)
});
<KokoTableProvider headers={[amountHeader]} /> // 컬럼 단위: setHeader(node, { def: amountHeader | "compact" })적용 순서 meta.header > Provider headers[] 중 when 이 참인 첫 정의 > 기본. ctx 는 { header, column, table, sort, labels, sortState, multiSortIndex },
슬롯·render 는 여기에 can(규칙 해석 결과) · defaultContent · openMenu(anchor) · menuEnabled 를 더 받는다. 내장 id: default · compact(메뉴 버튼·리사이즈 없음).
컨텍스트 메뉴 확장
컬럼 메뉴(헤더 버튼·우클릭)와 행 메뉴(본문 우클릭)는 MenuItemDef 레지스트리다 — { id, label, icon?, when?(ctx), run(ctx), disabled?(ctx), group?, order?, divider? }.
<KokoTableProvider
menus={{
column: { add: [{ id: "group.only", label: "이 컬럼으로 그룹핑", when: (ctx) => !!ctx.setGrouping, run: (ctx) => ctx.setGrouping?.([ctx.column.id]) }],
remove: ["hide"], replace: [{ id: "copy.header", label: "헤더+값 복사", run: (ctx) => ctx.copy(…) }], order: ["group.only"] },
row: { add: [{ id: "detail", label: "상세 보기", when: (ctx) => !!ctx.row, run: (ctx) => open(ctx.row!.original) }] },
}}
onOpenFilter={(column) => …} // 있을 때만 filter.open 이 보인다 (TableContent onOpenFilter 도 같다)
/>ColumnMenuCtx = 헤더 ctx + { can, grouping, setGrouping?, openFilter?, copy }, RowMenuCtx = { row?, group?, table, copy, labels } (kind 로 구분).
기본 id — 컬럼 sort.asc · sort.desc · sort.clear · pin.left · pin.right · pin.clear · move.left · move.right · hide · group.add · group.remove · filter.open · resize.reset · copy.header,
행 copy.row · expand · collapse · expandToDepth. 중첩 Provider 패치는 바깥 위에 쌓이고, columnMenuItems/rowMenuItems prop 이 해석 결과(defaultItems)를 마지막으로 손본다.
셀·집계·export 확장
셀 렌더러·집계 함수·export 포맷터는 모두 { id, when?, … } 항목의 레지스트리다(createRegistry, core). 내장 세트도 같은 API 로 등록돼 있다.
const p90 = defineAggregation({ id: "p90", label: "90분위", shortLabel: "P90", applies: (t) => t === "number", compute: (values) => percentile(values, 0.9) });
const badge = defineCellRenderer({ id: "status-badge", when: ({ columnId }) => columnId === "status", render: ({ value }) => <Chip label={String(value)} /> });
aggregations.register(p90); // 앱 전체 — core `aggregations` · `exportFormatters`, mui `cellRenderers`
<KokoTableProvider cellRenderers={[badge]} aggregations={[p90]} exportFormatters={[defineExportFormatter({ id: "won", when: (c) => c.id === "amount", format: (v) => `${v}원` })]} />| 확장점 | 해석 순서 | 소비처 |
|---|---|---|
| 셀 (CellRendererDef) | 컬럼 cell 결과가 renderedValue → when 참 중 order 순 → id === dataType → 그대로; meta.chip/link/placeholder 는 같은 id 슬롯이 감싼다 | renderCellValue(cell, { registry: useCellRenderers() }) |
| 집계 (AggregationDef, core) | applies(dataType) 가 칩 후보, compute(values, ctx) 가 계산, server 없으면 점선(클라이언트 전용), format 이 칩 문자열 | useGrouping({ aggregations: useAggregations() }) · 그룹 행 칩(클릭 전환) · 헤더 메뉴 aggregate(그룹핑 중) · GroupingSettingsButton(그룹핑 바 오른쪽 톱니 — 그룹 컬럼 배치·더 보기 배치 크기·집계 함수를 한 팝오버에) |
| export (ExportFormatterDef, core) | when(column) 참 → id === dataType → formatValueForExport | useTableExport(table, adapter, { formatters: useExportFormatters() }) · toCsv(data, { formatters }) · exp.formatRow |
Provider 항목은 내장 위에 같은 id 로 얹힌다(빼려면 registry.with({ remove }) 를 직접 넘긴다). AGG_LABELS 등 상수는 레지스트리에서 파생된다. 스토리 "확장/셀·집계·export 레지스트리".
필터 kind 정의하기
컬럼의 filter.kind 에 끼우는 정의 하나가 연산자·클라이언트 판정·요청 값·문구·URL codec·입력을 정한다.
내장 text · number · date · period · boolean · enum · commonCode · cascade · relation · schedule · linear 도 같은 defineFilterKind 로 정의돼 있고 helper 프리셋이 그것을 쓴다.
// core — 의미 부분. 값·연산자 타입은 operators 에서 추론된다
const moneyKind = defineFilterKind({
id: "money",
operators: {
between: { client: (cell, v: MoneyValue) => …, serialize: (v) => [v.lo, v.hi], wire: "betweenInclusive", describe: (v) => `${v.lo} ~ ${v.hi} ${v.currency}` },
gte: { client: (cell, v: MoneyValue) => …, wire: "greaterThanOrEqualTo", describe: (v) => `${v.lo} ${v.currency} 이상` },
},
defaultOperator: "between",
isEmpty: (v) => !v || (v.lo == null && v.hi == null),
codec: { encode: (v) => [v.lo, v.hi, v.currency], decode: (raw) => … },
});
// mui — 표시 부분. Provider `filterKinds={{ money: withEditor(moneyKind, { editor: MoneyInput }) }}` 로도 끼운다
helper.ColumnIsNumber({ key: "amount", header: "금액" }).setFilter({ kind: withEditor(moneyKind, { editor: (f) => <MoneyInput field={f} /> }), defaultOperator: "gte" });
// 문자열 id 로 좁히려면 레지스트리를 병합한다 → filter: { kind: "money", defaultOperator: "gte" } (틀리면 컴파일 에러)
declare module "@koko-table/core" { interface FilterKindRegistry { money: typeof moneyKind } }kind 가 없는 컬럼은 지금의 variant 경로 그대로다. 입력 우선순위는 filter.render → Provider(filterKinds[id].editor → filterInputs[variant]) → kind.editor → 내장. 스토리 "확장/필터 kind 정의하기".
없는 것 (의도적)
- XLSX/PDF/print 변환 —
useTableExport가 행·컬럼을 내주고 변환은 소비 앱이 동적 import 한다. - 프리셋 서버 API —
PresetStore인터페이스만 두고 저장소는 소비 앱이 구현한다(localStorage·memory 구현은 제공). - 라우터 — 링크 셀은
onNavigate콜백으로 연결한다. - 날짜 열의 기간 그룹핑(
groupBy: "day" | "week" | "month") — 요청 계약의grouping은 컬럼 키 배열이라 기간 버킷은 백엔드와 같이 정해야 한다. 클라이언트에만 넣으면 같은 정의가 모드에 따라 다르게 동작한다 ("client / server 의미 일치"). 계약에 transform 이 들어갈 때ColumnIsDate({ groupBy })로 함께 낸다. 그 전에는 날짜·주·월 문자열 열을 파생시켜(setGroup(true)) 숨겨 두고 그룹핑 바에서 꺼내 쓴다.
테이블 정의 한 장
배포를 가정하면 "테이블 하나를 정의하는 타입" 이 있어야 소비자가 필요한 것만 채워 쓴다.
TableDefinition<T> 가 그 자리다 — 컬럼·기본값·서버 연결·기능 토글·그룹핑·URL 동기화를 객체 하나에 선언하고,
조립 훅이 1·2·3층 + 애드온을 TableBundle 로 묶어 주며, TableScreen 이 features 대로 화면을 배치한다.
// 1. 정의 — 필요한 것만 채운다 (모듈 상수나 useMemo 로 참조를 고정한다)
const salesTable = defineTable<SalesRow>({
key: "sales", columns, data, // 서버 모드면 data 대신 server: { queryFn, queryKey }
features: { rowNumbers: true, selection: true }, // expand · rowActions · globalFilter · filterStatus · virtualColumns …
grouping: { enabled: true }, urlSync: true, getRowId: (row) => String(row.idx),
});
// 2. 조립 — 정의의 모드(data / server)를 보고 고른다. react-query 없이 클라이언트만 쓰면 kokotable 의 useClientTable
const bundle = useTable(salesTable);
// 3. 화면 — 슬롯으로 구역을 갈아끼운다 (filter / content / pagination / groupingBar / selectionActions / emptyState / detailPanel / rowActions)
return <TableScreen bundle={bundle} title="판매 주문" slots={{ detailPanel: (row) => <Items row={row.original} /> }} />;| 정의 항목 | 뜻 |
|---|---|
| columns · data / server · mode | 컬럼, 클라이언트 배열 또는 서버 조회(queryFn·queryKey·staleTime·transformResponse·keepPreviousData·refetchInterval·retry). mode 미지정이면 server 유무로 판정 |
| search: { mode, debounceMs } | 검색 방식 — "button"(기본, 검색 버튼을 눌러야 조회) / "auto"(입력하면 debounceMs 뒤 자동 조회, 검색 버튼 없음). 미지정이면 configureTableDefaults 값 |
| initial · initialOperators · globalFilterFields · now | 1·2층 옵션 그대로 |
| urlSync · history · grouping | 애드온 — bundle.urlSync / bundle.history / bundle.grouping(items·expand·loadMore·rows·batchSize) |
| features | 렌더러가 읽는 토글: rowNumbers selection{multi,selectOnRowClick,canSelect} expand{canExpand} rowActions groupedColumnMode density cellAlign columnMenu columnDrag globalFilter(기본 true, 검색 바 왼쪽 · "toolbar") filterStatus columnVisibilityMenu virtualColumns virtualize remember(사용자 설정 기억 — 컬럼 폭·순서·표시·고정·페이지 크기, zustand persist, 프리셋과 별개) |
| getRowId · fieldErrors · tableOptions | 3층. 서버 모드에서 선택·확장을 재조회 뒤에도 유지하려면 getRowId 가 필수다. 표마다 같은 식이면 앱 기본 configureTableDefaults({ getRowId }) 로 한 번 — 정의 → tableOptions.getRowId → 앱 기본 → 행 순번 순이고, 셋 다 없으면 조립 때 한 번 경고한다 |
defineTable 은 타입 추론용 identity 이면서 검증한다 — 중복 컬럼 id, 서버 모드인데 queryFn 없음은 마운트 전에 던진다.
조립 훅은 셋이다 — useTable(kokotable/query, 정의의 모드로 둘 중 하나를 부른다) · useClientTable(kokotable) · useServerTable(kokotable/query).
옛 이름 useTableFromDefinition 은 useClientTable 의 별칭으로 남아 있다. useTable 은 마운트된 동안 정의의 모드가 바뀌면 던진다(훅 순서가 달라진다 — key 로 새로 마운트한다).
반환 bundle 은 조립 결과 묶음 — TableScreen 에 통째로 넘기는 손잡이이고, 화면 밖에서 조각을 꺼내 쓴다
(bundle.table.getSelectedRowModel() · bundle.search.apply() · bundle.fetchAllRows() · 서버면 bundle.query.refetch()).
TableBundle 은 { definition, mode, search, data, table, grouping?, urlSync?, history?, request, tableColumns, setTableColumns, fetchAllRows } 이고 서버 조립은 query 를 더한다 —
fetchAllRows() 는 현재 조건의 전체 행(서버는 pagination 없이 한 번 더 조회)이라 extractExportData 에 그대로 넘긴다.
표시 전용 열·가상 컬럼 값 열은 TableScreen 이 bundle.setTableColumns(withDisplayColumns(...)) 로 3층에 등록한다(코어는 MUI 를 모른다).
가상화(features.virtualize)는 import { VirtualTableScreen } from "@koko-table/mui/virtual" 로 그린다 — 메인 TableScreen 은 무시하고 경고만 낸다.
스토리 "테이블 정의" 와 "서버 모드 기능" 에 기능별 예가 있다.
