Files
invyone/frontend/lib/registry/components/input/use-option-loader.ts
T
DDD1542 4a8413000b
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Consolidate canonical input migration
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
2026-05-12 18:36:43 +09:00

531 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api/client";
import type { FieldOption, FieldRef } from "@/types/invyone-component";
/**
* 로더가 반환하는 옵션 형식 — select-pickers 의 SelectOption 과 호환.
*
* FieldOption 은 `string | {value,label}` union 이라 picker 들이 그대로 받기
* 어려움. 로더 내부에서 객체 형태로 정규화한 결과를 반환한다.
*/
export interface LoadedOption {
value: string;
label: string;
}
/**
* use-option-loader
*
* InputComponent 의 select 계열 (single / multi / radio / check / tag) 의 옵션을
* 5 가지 source 에서 비동기로 로드한다.
*
* static — config.options 그대로 사용
* code — 공통코드 (/common-codes/categories/{group}/options) — code_group / codeCategory
* category — 사용자 정의 카테고리 (/table-categories/{table}/{column}/values)
* distinct — 현재 테이블 컬럼의 distinct (/entity/{table}/distinct/{column})
* api — 임의 endpoint
*
* 옛 선택 컴포넌트의 옵션 로딩 로직을 input canonical 용으로 정리. apiClient 만 의존.
*
* 디자인 모드 가드 — isDesignMode === true 면 static 만 처리하고 API 호출은 모두
* skip. 캔버스 위에서 옵션 컴포넌트를 끌어 놓는 동안 API 폭주를 방지.
*
* 캐시 — 같은 url 결과를 module-scoped Map 에 저장 (브라우저 세션 동안). race 조건
* 방지로 effect 내 cancelled flag 사용.
*
* 계층 / cascade — code source 의 단일 hierarchical (parentField 의 value 로 자식
* 코드 조회) 까지만 지원. swap / 다단 cascade 는 TODO.
*
* 사용 예:
* const { options, loading } = useOptionLoader({
* config: componentConfig,
* tableName,
* columnName,
* formData,
* isDesignMode,
* });
*/
/**
* 옵션 검색 필터 (canonical) — InvFieldConfigPanel 의 옵션 필터 UI 가 이 형태로
* 저장한다. 옛 V2 입력/선택 본체가 삭제되며 canonical 위치로 이전됨.
*
* value_type:
* static — value 가 고정값
* field — field_ref 가 다른 폼 필드명, runtime 에 그 필드 값 치환
* user — user_field 가 로그인 사용자 메타 (companyCode 등), runtime 에 치환
*/
export interface OptionFilter {
column: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
value_type?: "static" | "field" | "user";
value?: unknown;
field_ref?: string;
user_field?: "companyCode" | "userId" | "deptCode" | "userName";
}
export type OptionSource =
| "static"
| "code"
| "category"
| "entity"
| "distinct"
| "select"
| "api"
| "db";
interface RawOptionItem {
value?: unknown;
label?: unknown;
// camelCase (legacy / 일부 endpoint)
valueCode?: string;
valueLabel?: string;
// snake_case (백엔드 표준 응답 — table-categories / common-codes hierarchy)
value_code?: string;
value_label?: string;
children?: RawOptionItem[];
}
export interface OptionLoaderConfig {
source?: string;
options?: Array<FieldOption | { value: string; label?: string }> | undefined;
codeGroup?: string;
codeCategory?: string;
categoryTable?: string;
categoryColumn?: string;
entityTable?: string;
entityValueColumn?: string;
entityLabelColumn?: string;
ref?: FieldRef;
table?: string;
valueColumn?: string;
labelColumn?: string;
apiEndpoint?: string;
hierarchical?: boolean;
parentField?: string;
/**
* 옵션 검색 필터 (canonical) — runtime 에 formData / user context 로 치환된 후
* 백엔드의 `filters` query 파라미터에 JSON 문자열로 전달됨.
*/
filters?: OptionFilter[];
}
/**
* 로그인 사용자 메타 — `value_type === "user"` 필터 치환에 사용.
*/
export interface OptionUserContext {
companyCode?: string;
userId?: string;
deptCode?: string;
userName?: string;
}
export interface UseOptionLoaderArgs {
config: OptionLoaderConfig & Record<string, any>;
tableName?: string;
columnName?: string;
formData?: Record<string, any>;
/** `value_type === "user"` 필터 치환용 사용자 메타. 미지정 시 user 필터는 skip. */
userContext?: OptionUserContext;
isDesignMode?: boolean;
}
export interface UseOptionLoaderResult {
options: LoadedOption[];
loading: boolean;
}
// FieldOption (string | object) 도 받을 수 있게 normalizeOptions 가 처리.
// FieldOption 은 import 만 하고 직접 시그니처에는 노출하지 않는다.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _FieldOptionSentinel = FieldOption;
// 모듈 스코프 캐시 — url 단위. apiClient 의 baseURL 이 같다는 전제.
// 캐시 무효화 트리거가 필요한 경우 입력 컴포넌트가 unmount/remount 되어야 함.
const responseCache = new Map<string, LoadedOption[]>();
function normalizeOptions(raw: any[]): LoadedOption[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item): LoadedOption | null => {
if (item == null) return null;
if (typeof item === "string") return { value: item, label: item };
if (typeof item === "object") {
// 값 키 우선순위: value / code / id (보편)
// + valueCode / value_code (table-categories tree leaf)
// + codeValue / code_value (common-codes hierarchy)
const v =
item.value ??
item.code ??
item.id ??
item.valueCode ??
item.value_code ??
item.codeValue ??
item.code_value;
// 라벨 키 우선순위: label / name (보편)
// + valueLabel / value_label (table-categories)
// + codeName / code_name (common-codes hierarchy)
const l =
item.label ??
item.name ??
item.valueLabel ??
item.value_label ??
item.codeName ??
item.code_name ??
v;
if (v == null || String(v) === "") return null;
return { value: String(v), label: l != null ? String(l) : String(v) };
}
return null;
})
.filter((x): x is LoadedOption => x !== null);
}
function flattenCategoryTree(items: RawOptionItem[], depth = 0): LoadedOption[] {
const out: LoadedOption[] = [];
for (const item of items) {
// 백엔드 응답은 snake_case (value_code/value_label) — camelCase 호환 유지.
const code =
item.valueCode ??
item.value_code ??
(item.value != null ? String(item.value) : "");
if (!code) continue;
const labelRaw =
item.valueLabel ??
item.value_label ??
(item.label != null ? String(item.label) : code);
const prefix = depth > 0 ? "   ".repeat(depth) + "└ " : "";
out.push({ value: code, label: prefix + labelRaw });
if (Array.isArray(item.children) && item.children.length > 0) {
out.push(...flattenCategoryTree(item.children, depth + 1));
}
}
return out;
}
function isCanonicalEmpty(v: any): boolean {
return v === undefined || v === null || v === "";
}
/** ResolvedFilter — runtime 에 value 가 실제 값으로 치환된 후의 형태. */
interface ResolvedFilter {
column: string;
operator: OptionFilter["operator"];
/** isNull/isNotNull 은 value 없이 전달. in/notIn 은 배열. */
value?: unknown;
}
/**
* OptionFilter 배열을 runtime 값으로 치환.
* - column 빈 값 → skip
* - value_type=field: formData[field_ref] 값으로 치환. 값 없으면 skip
* (단 isNull/isNotNull 은 value 없이 통과)
* - value_type=user: userContext[user_field] 로 치환. 값 없으면 skip
* - in/notIn: 배열 또는 "a,b,c" 문자열 모두 trim 된 배열로 정규화
* - isNull/isNotNull: value 없이 항상 통과
*/
function resolveFilters(
filters: OptionFilter[] | undefined,
formData: Record<string, any> | undefined,
userContext: OptionUserContext | undefined,
): ResolvedFilter[] {
if (!Array.isArray(filters) || filters.length === 0) return [];
const out: ResolvedFilter[] = [];
for (const f of filters) {
const column = typeof f?.column === "string" ? f.column.trim() : "";
if (!column) continue;
const op = f.operator;
// isNull / isNotNull 은 value 없이 통과
if (op === "isNull" || op === "isNotNull") {
out.push({ column, operator: op });
continue;
}
let raw: unknown;
const vt = f.value_type || "static";
if (vt === "static") {
raw = f.value;
} else if (vt === "field") {
if (!f.field_ref) continue;
raw = formData?.[f.field_ref];
if (isCanonicalEmpty(raw)) continue;
} else if (vt === "user") {
if (!f.user_field) continue;
raw = userContext?.[f.user_field];
if (isCanonicalEmpty(raw)) continue;
} else {
raw = f.value;
}
// in / notIn 정규화: 배열 또는 콤마 구분 문자열
if (op === "in" || op === "notIn") {
let arr: unknown[];
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === "string") {
arr = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
} else if (isCanonicalEmpty(raw)) {
continue;
} else {
arr = [raw];
}
if (arr.length === 0) continue;
out.push({ column, operator: op, value: arr });
continue;
}
// 그 외 operator 는 빈 값 skip
if (isCanonicalEmpty(raw)) continue;
out.push({ column, operator: op, value: raw });
}
return out;
}
function resolveSource(config: OptionLoaderConfig): OptionSource {
const raw = (config.source || "").toString().toLowerCase();
if (raw === "static" || raw === "code" || raw === "category" || raw === "entity" ||
raw === "distinct" || raw === "select" || raw === "api" || raw === "db") {
return raw as OptionSource;
}
// source 미지정 — 기존 데이터를 보고 추정 (옛 키 호환 최소)
if (Array.isArray(config.options) && config.options.length > 0) return "static";
if (config.codeGroup || config.codeCategory) return "code";
if (config.categoryTable && config.categoryColumn) return "category";
if (config.entityTable || config.ref?.table) return "entity";
if (config.apiEndpoint) return "api";
return "static";
}
export function useOptionLoader({
config,
tableName,
columnName,
formData,
userContext,
isDesignMode,
}: UseOptionLoaderArgs): UseOptionLoaderResult {
const source = resolveSource(config);
// static options 는 외부 의존성 없이 즉시 결정.
const staticOptions = useMemo<LoadedOption[]>(() => {
return normalizeOptions(config.options as any[] | undefined ?? []);
}, [config.options]);
// runtime 치환된 filter JSON — fetchUrl 의 dep 으로 stable string 사용해서
// formData / filters 객체 reference 변경에 의한 effect 폭주 방지.
// 결과가 빈 배열이면 undefined 로 두어 URL 에 filters query 자체를 안 붙임.
const resolvedFiltersJson = useMemo<string | undefined>(() => {
const resolved = resolveFilters(config.filters, formData, userContext);
if (resolved.length === 0) return undefined;
return JSON.stringify(resolved);
}, [
config.filters,
formData,
userContext?.companyCode,
userContext?.userId,
userContext?.deptCode,
userContext?.userName,
]);
// 계층 코드의 parent 값 (다른 폼 필드 참조)
const parentValue = useMemo<string | undefined>(() => {
if (!config.hierarchical || !config.parentField) return undefined;
const v = formData?.[config.parentField];
if (v == null || v === "") return undefined;
return String(v);
}, [config.hierarchical, config.parentField, formData]);
// API 호출이 필요한 source 인지
const needsFetch =
source !== "static" &&
// 디자인 모드에서는 캔버스 드래그/리사이즈 등으로 effect 가 자주 트리거되므로
// API 호출을 통째로 skip 한다. 운영 모드에서만 fetch.
!isDesignMode;
// fetch url 결정 (memo 로 effect dep 안정화)
const fetchUrl = useMemo<string | null>(() => {
if (!needsFetch) return null;
// filters query 헬퍼 — 이미 resolvedFiltersJson 으로 stable string 화 되어 있음.
// 백엔드 표준: `filters=` URL-encoded JSON. 빈 결과면 query 자체를 안 붙임.
const filtersQuery = resolvedFiltersJson
? `filters=${encodeURIComponent(resolvedFiltersJson)}`
: "";
const appendFilters = (url: string): string => {
if (!filtersQuery) return url;
return url + (url.includes("?") ? "&" : "?") + filtersQuery;
};
if (source === "code") {
const group = config.codeGroup || config.codeCategory;
if (!group) return null;
if (config.hierarchical) {
// 백엔드 endpoint 는 `/hierarchy?parentCodeValue=...` (children 은 미존재)
const q = parentValue ? `?parentCodeValue=${encodeURIComponent(parentValue)}` : "";
// filters 는 공통코드 endpoint 가 처리 안 함 → query 미추가 (TODO: backend 지원 시 활성화)
return `/common-codes/categories/${encodeURIComponent(group)}/hierarchy${q}`;
}
return `/common-codes/categories/${encodeURIComponent(group)}/options`;
}
if (source === "category") {
const t = config.categoryTable || tableName;
const c = config.categoryColumn || columnName;
if (!t || !c) return null;
// filters 는 table-categories endpoint 가 처리 안 함 → query 미추가
return `/table-categories/${encodeURIComponent(t)}/${encodeURIComponent(c)}/values`;
}
if (source === "distinct" || source === "select") {
// tableName 필수, columnName 은 가상컬럼 (comp_*) 제외
if (!tableName || !columnName) return null;
if (columnName.startsWith("comp_")) return null;
return appendFilters(
`/entity/${encodeURIComponent(tableName)}/distinct/${encodeURIComponent(columnName)}`,
);
}
if (source === "api") {
const ep = config.apiEndpoint;
if (!ep) return null;
// 외부 endpoint 는 filter 스펙이 정의되어 있지 않으므로 통과 (사용자 정의 URL 그대로)
return ep;
}
if (source === "entity") {
// entity 는 DB FK 를 직접 걸 수 없는 경우 화면 설정에서 참조 테이블과
// value/label 컬럼을 지정해 code-name 옵션으로 읽는다.
const ref = config.ref;
const t = config.entityTable || ref?.table;
if (!t) return null;
const v = config.entityValueColumn || ref?.valueColumn || "id";
const l = config.entityLabelColumn || ref?.displayColumn || "name";
return appendFilters(
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
);
}
if (source === "db") {
// legacy V2 source. table/value/label 컬럼으로 옵션 펼치기.
const t = config.table;
if (!t) return null;
const v = config.valueColumn || "id";
const l = config.labelColumn || "name";
return appendFilters(
`/entity/${encodeURIComponent(t)}/options?value=${encodeURIComponent(v)}&label=${encodeURIComponent(l)}`,
);
}
return null;
}, [
needsFetch,
source,
config.codeGroup,
config.codeCategory,
config.hierarchical,
parentValue,
config.categoryTable,
config.categoryColumn,
config.apiEndpoint,
config.table,
config.valueColumn,
config.labelColumn,
// entity 분기 의존성 — entityTable / entityValueColumn / entityLabelColumn 변경 시
// 옵션 재로드 되도록 명시. ref 는 객체 자체가 매번 바뀌면 effect 폭주하므로
// 내부 키만 dep 으로 추출.
config.entityTable,
config.entityValueColumn,
config.entityLabelColumn,
config.ref?.table,
config.ref?.valueColumn,
config.ref?.displayColumn,
// 필터 변경 시 URL 재생성 → cache key 자동 신규화
resolvedFiltersJson,
tableName,
columnName,
]);
const [fetched, setFetched] = useState<LoadedOption[] | null>(() => {
return fetchUrl ? (responseCache.get(fetchUrl) ?? null) : null;
});
const [loading, setLoading] = useState(false);
const reqIdRef = useRef(0);
useEffect(() => {
if (!fetchUrl) {
// fetch 가 필요 없는 source / config 미충족 → 이전 fetch 의 loading 상태가
// 남아있지 않도록 명시적으로 false. race guard 차원에서 reqId 도 +1.
reqIdRef.current++;
setFetched(null);
setLoading(false);
return;
}
const cached = responseCache.get(fetchUrl);
if (cached) {
// cache hit — 비동기 분기로 빠지지 않으므로 finally 가 안 돌아간다.
// loading 이 true 인 상태에서 cache hit 가 발생하면 stuck 위험 → 직접 false.
reqIdRef.current++;
setFetched(cached);
setLoading(false);
return;
}
let cancelled = false;
const myReq = ++reqIdRef.current;
setLoading(true);
apiClient
.get(fetchUrl)
.then((response) => {
if (cancelled || myReq !== reqIdRef.current) return;
const data = response?.data;
let opts: LoadedOption[] = [];
// 응답 shape 적응
if (Array.isArray(data)) {
opts = normalizeOptions(data);
} else if (data && typeof data === "object") {
if (data.success && Array.isArray(data.data)) {
// category 트리 vs flat 분기
if (source === "category") {
opts = flattenCategoryTree(data.data as RawOptionItem[]);
} else {
opts = normalizeOptions(data.data);
}
} else if (Array.isArray((data as any).options)) {
opts = normalizeOptions((data as any).options);
}
}
responseCache.set(fetchUrl, opts);
setFetched(opts);
})
.catch((err) => {
if (cancelled || myReq !== reqIdRef.current) return;
console.warn("[useOptionLoader] load failed:", fetchUrl, err);
setFetched([]);
})
.finally(() => {
if (cancelled || myReq !== reqIdRef.current) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [fetchUrl, source]);
// 최종 옵션 결정
// - source = static : staticOptions
// - source = api 등 외부 : fetched 가 null 이면 (아직 로딩 전) static fallback,
// fetched 가 있으면 fetched 우선
// - isDesignMode 에서는 fetch 자체를 skip 하므로 staticOptions 만 사용
const options = useMemo<LoadedOption[]>(() => {
if (source === "static") return staticOptions;
if (isDesignMode) return staticOptions;
if (fetched == null) return staticOptions; // 첫 로드 전 — static 으로 silhouette
return fetched;
}, [source, isDesignMode, staticOptions, fetched]);
return { options, loading };
}