Files
invyone/frontend/lib/registry/components/table/useTableData.ts
T
DDD1542 7d204bfffd
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
refactor: complete canonical table cleanup
2026-05-21 11:55:08 +09:00

260 lines
8.0 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { DataFilterConfig } from "@/types/screen-management";
/**
* useTableData — 통합 table 컴포넌트 데이터 fetch 훅
*
* entityJoinApi.getTableDataWithJoins() 호출.
* 페이지네이션, 정렬, 검색 상태를 관리.
*
* Phase D.2 (2026-05-20) — `dataFilter` / `excludeFilter` 를 entityJoinApi 에 그대로 전달.
* 호출자는 객체 ref 가 매 렌더마다 신규 생성되지 않도록 memoize 하는 책임이 있다 — 내부적으로도
* stable JSON string 으로 dep 추적해 ref 변동만으로 fetch 폭주가 나지 않게 한다.
*/
export interface ExcludeFilterPayload {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}
export interface UseTableDataParams {
tableName?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
search?: Record<string, any>;
enabled?: boolean; // false면 fetch 안 함 (디자인 모드)
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
dataFilter?: DataFilterConfig;
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
excludeFilter?: ExcludeFilterPayload;
}
export interface UseTableDataResult {
data: Record<string, any>[];
total: number;
totalPages: number;
page: number;
pageSize: number;
sortBy: string;
sortOrder: "asc" | "desc";
loading: boolean;
error: string | null;
// 액션
setPage: (p: number) => void;
setPageSize: (s: number) => void;
setSortBy: (col: string) => void;
toggleSort: (col: string) => void;
setSearch: (s: Record<string, any>) => void;
refresh: () => void;
/**
* Phase D.9 (2026-05-20) — DataReceivable.receiveData() 가 local data 를 override.
* append/replace/merge 결과를 통째 적용. fetch refresh 전까지 유지. totalOverride 미지정 시 length 사용.
*/
setLocalData: (next: Record<string, any>[], totalOverride?: number) => void;
}
export function useTableData(params: UseTableDataParams): UseTableDataResult {
const {
tableName,
page: initialPage = 1,
pageSize: initialPageSize = 20,
sortBy: initialSortBy = "",
sortOrder: initialSortOrder = "desc",
search: externalSearch,
enabled = true,
dataFilter,
excludeFilter,
} = params;
// D.2 — dataFilter / excludeFilter 객체 ref 가 매 렌더마다 신규여도 dep 으로 안 잡히도록
// stable JSON string 으로 변환해 fetchData dep 으로 사용. 호출자 책임 보강.
const dataFilterJson = useMemo(
() => (dataFilter && (dataFilter as any).enabled ? JSON.stringify(dataFilter) : null),
[dataFilter],
);
const excludeFilterJson = useMemo(
() => (excludeFilter && excludeFilter.enabled ? JSON.stringify(excludeFilter) : null),
[excludeFilter],
);
const [data, setData] = useState<Record<string, any>[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
const [sortBy, setSortBy] = useState(initialSortBy);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">(initialSortOrder);
const [search, setSearch] = useState<Record<string, any>>(externalSearch || {});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialStateRef = useRef({
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
});
// 외부 검색 조건 동기화.
// D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도
// 내부 search state 를 비워야 stale 검색 조건이 남지 않는다.
useEffect(() => {
const nextSearch = externalSearch || {};
setSearch((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextSearch);
const changed =
prevKeys.length !== nextKeys.length ||
nextKeys.some((key) => prev[key] !== nextSearch[key]);
if (!changed) return prev;
return nextSearch;
});
setPage(1);
}, [externalSearch]);
// 데이터 fetch
const fetchData = useCallback(async () => {
if (!tableName || !enabled) return;
setLoading(true);
setError(null);
try {
// 동적 import — 번들 사이즈 최적화
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const response = await entityJoinApi.getTableDataWithJoins(tableName, {
page,
size: pageSize,
sortBy: sortBy || undefined,
sortOrder,
search: Object.keys(search).length > 0 ? search : undefined,
enableEntityJoin: true,
// D.2 — JSON 으로 변환된 stable string 이 dep 이지만 실제 payload 는 원본 객체 사용.
dataFilter:
dataFilter && (dataFilter as any).enabled ? dataFilter : undefined,
excludeFilter:
excludeFilter && excludeFilter.enabled ? excludeFilter : undefined,
});
setData(response.data || []);
setTotal(response.total || 0);
setTotalPages(response.totalPages || 1);
} catch (err: any) {
console.error("[useTableData] fetch 실패:", err);
setError(err?.message || "데이터 로드 실패");
setData([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
// dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
tableName,
page,
pageSize,
sortBy,
sortOrder,
search,
enabled,
dataFilterJson,
excludeFilterJson,
]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 테이블 / config 초기값 변경 시 런타임 상태 동기화.
// 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다.
useEffect(() => {
const prev = initialStateRef.current;
const changed =
prev.tableName !== tableName ||
prev.page !== initialPage ||
prev.pageSize !== initialPageSize ||
prev.sortBy !== initialSortBy ||
prev.sortOrder !== initialSortOrder;
if (!changed) return;
initialStateRef.current = {
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
};
setPage(initialPage);
setPageSize(initialPageSize);
setSortBy(initialSortBy);
setSortOrder(initialSortOrder);
setSearch(externalSearch || {});
}, [tableName, initialPage, initialPageSize, initialSortBy, initialSortOrder, externalSearch]);
const toggleSort = useCallback((col: string) => {
setSortBy((prev) => {
if (prev === col) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"));
return col;
}
setSortOrder("asc");
return col;
});
}, []);
const refresh = useCallback(() => {
fetchData();
}, [fetchData]);
const setPageSizeAction = useCallback((s: number) => {
setPageSize(s);
setPage(1);
}, []);
const setSearchAction = useCallback((s: Record<string, any>) => {
setSearch(s);
setPage(1);
}, []);
const setLocalData = useCallback((next: Record<string, any>[], totalOverride?: number) => {
const arr = Array.isArray(next) ? next : [];
setData(arr);
const t = typeof totalOverride === "number" && totalOverride >= 0 ? totalOverride : arr.length;
setTotal(t);
const ps = pageSize > 0 ? pageSize : 20;
setTotalPages(Math.max(1, Math.ceil(t / ps)));
}, [pageSize]);
return {
data,
total,
totalPages,
page,
pageSize,
sortBy,
sortOrder,
loading,
error,
setPage,
setPageSize: setPageSizeAction,
setSortBy,
toggleSort,
setSearch: setSearchAction,
refresh,
// Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지.
setLocalData,
};
}