diff options
Diffstat (limited to 'components/client-table-v2')
| -rw-r--r-- | components/client-table-v2/GUIDE.md | 178 | ||||
| -rw-r--r-- | components/client-table-v2/adapter/create-table-service.ts | 101 | ||||
| -rw-r--r-- | components/client-table-v2/adapter/drizzle-table-adapter.ts | 173 |
3 files changed, 452 insertions, 0 deletions
diff --git a/components/client-table-v2/GUIDE.md b/components/client-table-v2/GUIDE.md new file mode 100644 index 00000000..4ccadfc7 --- /dev/null +++ b/components/client-table-v2/GUIDE.md @@ -0,0 +1,178 @@ +# Table Component & Data Fetching Guide + +이 가이드는 `ClientVirtualTable`을 사용하여 테이블을 구현하고, 데이터를 페칭하는 3가지 주요 패턴을 설명합니다. + +## 개요 + +프로젝트의 복잡도와 요구사항에 따라 아래 3가지 패턴 중 하나를 선택하여 사용할 수 있습니다. + +| 모드 | 패턴 | 적합한 상황 | 특징 | +|---|---|---|---| +| **Client** | 1. Client-Side | 데이터가 적을 때 (< 1000건), 빠른 인터랙션 필요 | 전체 데이터 로드 후 브라우저에서 처리 | +| **Server** | 2. Factory Service | 단순 CRUD, 마스터 테이블 | 코드 1줄로 서버 액션 생성, 빠른 개발 | +| **Server** | 3. Custom Service | 복잡한 조인, 비즈니스 로직 | 완전한 쿼리 제어, Adapter를 도구로 사용 | + +--- + +## 1. Client-Side (기본 모드) + +데이터가 많지 않을 때 가장 간단한 방법입니다. 모든 데이터를 한 번에 받아와 `data` prop으로 넘깁니다. + +### 사용법 + +```tsx +// page.tsx +import { getAllUsers } from "@/lib/api/users"; +import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table"; +import { columns } from "./columns"; + +export default async function UsersPage() { + const users = await getAllUsers(); // 전체 목록 조회 + + return ( + <ClientVirtualTable + fetchMode="client" + data={users} + columns={columns} + enablePagination + enableSorting + enableFiltering + /> + ); +} +``` + +--- + +## 2. Factory Service (추천 - 단순 조회용) + +`createTableService`를 사용하여 서버 사이드 페칭을 위한 액션을 자동으로 생성합니다. + +### 1) Server Action 생성 + +```typescript +// app/actions/user-table.ts +"use server" + +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { columns } from "@/components/users/columns"; +import { createTableService } from "@/components/client-table-v2/adapter/create-table-service"; + +// 팩토리 함수로 액션 생성 (한 줄로 끝!) +export const getUserTableData = createTableService({ + db, + schema: users, + columns: columns +}); +``` + +### 2) 클라이언트 컴포넌트 연결 + +```tsx +// components/users/user-table.tsx +"use client" + +import { getUserTableData } from "@/app/actions/user-table"; +import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table"; +import { columns } from "./columns"; +import { useState, useEffect } from "react"; + +export function UserTable() { + const [data, setData] = useState([]); + const [totalRows, setTotalRows] = useState(0); + const [loading, setLoading] = useState(false); + + // 테이블 상태 관리 + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + + // 데이터 페칭 + useEffect(() => { + const fetchData = async () => { + setLoading(true); + const result = await getUserTableData({ + pagination, + sorting, + columnFilters, + globalFilter + }); + setData(result.data); + setTotalRows(result.totalRows); + setLoading(false); + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + <ClientVirtualTable + fetchMode="server" + data={data} + rowCount={totalRows} + columns={columns} + isLoading={loading} + // 상태 연결 + pagination={pagination} onPaginationChange={setPagination} + sorting={sorting} onSortingChange={setSorting} + columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} onGlobalFilterChange={setGlobalFilter} + /> + ); +} +``` + +--- + +## 3. Custom Service (복잡한 로직용) + +여러 테이블을 조인하거나, 특정 권한 체크 등 복잡한 로직이 필요할 때는 `DrizzleTableAdapter`를 직접 사용합니다. + +### 1) Custom Server Action 작성 + +```typescript +// app/actions/order-table.ts +"use server" + +import { db } from "@/lib/db"; +import { orders, users } from "@/lib/db/schema"; +import { DrizzleTableAdapter } from "@/components/client-table-v2/adapter/drizzle-table-adapter"; +import { count, eq } from "drizzle-orm"; + +export async function getOrderTableData(tableState) { + // 1. 어댑터로 조건절 생성 + const adapter = new DrizzleTableAdapter(orders, columns); + const { where, orderBy, limit, offset } = adapter.getQueryParts(tableState); + + // 2. 커스텀 쿼리 작성 (예: 유저 조인) + const data = await db + .select({ + orderId: orders.id, + amount: orders.amount, + userName: users.name // 조인된 컬럼 + }) + .from(orders) + .leftJoin(users, eq(orders.userId, users.id)) + .where(where) + .orderBy(...orderBy) + .limit(limit) + .offset(offset); + + // 3. 카운트 쿼리 + const total = await db + .select({ count: count() }) + .from(orders) + .where(where); + + return { + data, + totalRows: total[0]?.count ?? 0 + }; +} +``` + +### 2) 클라이언트 연결 + +Factory Service 방식과 동일하게 `useEffect`에서 `getOrderTableData`를 호출하면 됩니다. diff --git a/components/client-table-v2/adapter/create-table-service.ts b/components/client-table-v2/adapter/create-table-service.ts new file mode 100644 index 00000000..41c38906 --- /dev/null +++ b/components/client-table-v2/adapter/create-table-service.ts @@ -0,0 +1,101 @@ +import { PgTable } from "drizzle-orm/pg-core"; +import { DrizzleTableAdapter, DrizzleTableState } from "./drizzle-table-adapter"; +import { ColumnDef } from "@tanstack/react-table"; +import { SQL, and, count } from "drizzle-orm"; + +// Define a minimal DB interface that we need +// Adjust this to match your actual db instance type +interface DbInstance { + select: (args?: any) => any; +} + +export interface CreateTableServiceConfig<TData> { + /** + * Drizzle Database Instance + */ + db: DbInstance; + + /** + * Drizzle Table Schema (e.g. users, orders) + */ + schema: PgTable; // Using PgTable as base, works for most Drizzle tables + + /** + * React Table Columns Definition + * Used to map accessorKeys to DB columns + */ + columns: ColumnDef<TData, any>[]; + + /** + * Optional: Custom WHERE clause to always apply (e.g. deleted_at IS NULL) + */ + defaultWhere?: SQL; + + /** + * Optional: Custom query modifier + * Allows joining other tables or selecting specific fields + */ + customQuery?: (queryBuilder: any) => any; +} + +/** + * Factory function to create a standardized server action for a table. + * + * @example + * export const getUsers = createTableService({ + * db, + * schema: users, + * columns: userColumns + * }); + */ +export function createTableService<TData>(config: CreateTableServiceConfig<TData>) { + const { db, schema, columns, defaultWhere, customQuery } = config; + + // Return the actual Server Action function + return async function getTableData(tableState: DrizzleTableState) { + const adapter = new DrizzleTableAdapter(schema, columns); + const { where, orderBy, limit, offset, groupBy } = adapter.getQueryParts(tableState); + + // Merge defaultWhere with dynamic where + const finalWhere = defaultWhere + ? (where ? and(defaultWhere, where) : defaultWhere) + : where; + + // 1. Build Data Query + let dataQuery = db.select() + .from(schema) + .where(finalWhere) + .orderBy(...orderBy) + .limit(limit) + .offset(offset); + + if (groupBy && groupBy.length > 0) { + dataQuery = dataQuery.groupBy(...groupBy); + } + + // Apply custom query modifications (joins, etc) + if (customQuery) { + dataQuery = customQuery(dataQuery); + } + + // 2. Build Count Query + const countQuery = db.select({ count: count() }) + .from(schema) + .where(finalWhere); + + // Execute queries + // We use Promise.all to run them in parallel + const [data, countResult] = await Promise.all([ + dataQuery, + countQuery + ]); + + const totalRows = Number(countResult[0]?.count ?? 0); + + return { + data: data as TData[], + totalRows, + pageCount: Math.ceil(totalRows / (tableState.pagination?.pageSize ?? 10)) + }; + }; +} diff --git a/components/client-table-v2/adapter/drizzle-table-adapter.ts b/components/client-table-v2/adapter/drizzle-table-adapter.ts new file mode 100644 index 00000000..05bf4c5f --- /dev/null +++ b/components/client-table-v2/adapter/drizzle-table-adapter.ts @@ -0,0 +1,173 @@ +import { + ColumnFiltersState, + SortingState, + PaginationState, + GroupingState, + ColumnDef +} from "@tanstack/react-table"; +import { + SQL, + and, + or, + eq, + ilike, + gt, + lt, + gte, + lte, + inArray, + asc, + desc, + getTableColumns, +} from "drizzle-orm"; +import { PgTable, PgView, PgColumn } from "drizzle-orm/pg-core"; + +// Helper to detect if value is empty or undefined +const isEmpty = (value: any) => value === undefined || value === null || value === ""; + +export interface DrizzleTableState { + sorting?: SortingState; + columnFilters?: ColumnFiltersState; + globalFilter?: string; + pagination?: PaginationState; + grouping?: GroupingState; +} + +export class DrizzleTableAdapter<TData> { + private columnMap: Map<string, PgColumn>; + + constructor( + private table: PgTable | PgView, + private columns: ColumnDef<TData, any>[] + ) { + // Create a map of accessorKey -> Drizzle Column for fast lookup + this.columnMap = new Map(); + // @ts-ignore - getTableColumns works on views in newer drizzle versions or we can cast + const drizzleColumns = getTableColumns(table as any); + + columns.forEach(col => { + // We currently only support accessorKey which maps directly to a DB column + if ('accessorKey' in col && typeof col.accessorKey === 'string') { + const dbCol = drizzleColumns[col.accessorKey]; + if (dbCol) { + this.columnMap.set(col.accessorKey, dbCol); + } + } + }); + } + + private getColumn(columnId: string): PgColumn | undefined { + return this.columnMap.get(columnId); + } + + /** + * Build the WHERE clause based on column filters and global filter + */ + getWhere(columnFilters?: ColumnFiltersState, globalFilter?: string): SQL | undefined { + const conditions: SQL[] = []; + + // 1. Column Filters + if (columnFilters) { + for (const filter of columnFilters) { + const column = this.getColumn(filter.id); + if (!column) continue; + + const value = filter.value; + if (isEmpty(value)) continue; + + // Handle Array (range or multiple select) + if (Array.isArray(value)) { + // Range filter (e.g. [min, max]) + if (value.length === 2 && (typeof value[0] === 'number' || typeof value[0] === 'string')) { + const [min, max] = value; + if (!isEmpty(min) && !isEmpty(max)) { + conditions.push(and(gte(column, min), lte(column, max))!); + } else if (!isEmpty(min)) { + conditions.push(gte(column, min)!); + } else if (!isEmpty(max)) { + conditions.push(lte(column, max)!); + } + } + // Multi-select (IN) + else if (value.length > 0) { + conditions.push(inArray(column, value)!); + } + } + // Boolean + else if (typeof value === 'boolean') { + conditions.push(eq(column, value)!); + } + // Number + else if (typeof value === 'number') { + conditions.push(eq(column, value)!); + } + // String (Search) + else if (typeof value === 'string') { + conditions.push(ilike(column, `%${value}%`)!); + } + } + } + + // 2. Global Filter + if (globalFilter) { + const searchConditions: SQL[] = []; + this.columnMap.forEach((column) => { + // Implicitly supports only text-compatible columns for ilike + // Drizzle might throw if type mismatch, so user should be aware + searchConditions.push(ilike(column, `%${globalFilter}%`)); + }); + + if (searchConditions.length > 0) { + conditions.push(or(...searchConditions)!); + } + } + + return conditions.length > 0 ? and(...conditions) : undefined; + } + + /** + * Build the ORDER BY clause + */ + getOrderBy(sorting?: SortingState): SQL[] { + if (!sorting || !sorting.length) return []; + + return sorting.map((sort) => { + const column = this.getColumn(sort.id); + if (!column) return null; + return sort.desc ? desc(column) : asc(column); + }).filter(Boolean) as SQL[]; + } + + /** + * Build the GROUP BY clause + */ + getGroupBy(grouping?: GroupingState): SQL[] { + if (!grouping || !grouping.length) return []; + + return grouping.map(g => this.getColumn(g)).filter(Boolean) as SQL[]; + } + + /** + * Get Limit and Offset + */ + getPagination(pagination?: PaginationState) { + if (!pagination) return { limit: 10, offset: 0 }; + return { + limit: pagination.pageSize, + offset: pagination.pageIndex * pagination.pageSize, + }; + } + + /** + * Helper to apply all state to a query builder. + * Returns the modifier objects that can be passed to drizzle query builder. + */ + getQueryParts(state: DrizzleTableState) { + return { + where: this.getWhere(state.columnFilters, state.globalFilter), + orderBy: this.getOrderBy(state.sorting), + groupBy: this.getGroupBy(state.grouping), + ...this.getPagination(state.pagination) + }; + } +} |
