/** * ModalDataSourceWrapper.tsx * * 모달에 data_sources 지원을 추가하는 래퍼 컴포넌트 * * 기존 DataSourceManager를 재사용하여 모달이 열릴 때 data_sources를 fetch하고 * 결과를 전역 상태(_global)에 저장합니다. * * @package G7 * @subpackage Template Engine * @since 1.0.0 */ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { DataSourceManager, DataSource } from './DataSourceManager'; import { DataBindingEngine } from './DataBindingEngine'; import { createLogger } from '../utils/Logger'; const logger = createLogger('ModalDataSourceWrapper'); /** * 중첩된 객체에서 경로로 값을 추출하는 헬퍼 함수 * * @param obj 대상 객체 * @param path 점(.) 구분 경로 (예: "data.items", "[0].name", "data[0].value") * @returns 경로에 해당하는 값 */ function getNestedValue(obj: any, path: string): any { if (!obj || !path) return obj; // ?. 를 . 로 정규화 const normalizedPath = path.replace(/\?\./g, '.'); // 경로를 파싱하여 각 부분 추출 (배열 인덱스 포함) // "[0].name" → ["[0]", "name"] // "data[0].name" → ["data", "[0]", "name"] const parts = normalizedPath.split(/\.(?![^\[]*\])/).flatMap(part => { // 배열 인덱스 분리: "data[0]" → ["data", "[0]"] const matches = part.match(/^([^\[]*)((?:\[\d+\])*)$/); if (matches) { const [, base, indices] = matches; const result: string[] = []; if (base) result.push(base); const indexMatches = indices.match(/\[\d+\]/g); if (indexMatches) result.push(...indexMatches); return result; } return [part]; }).filter(p => p !== ''); let current = obj; for (const part of parts) { if (current === undefined || current === null) { return undefined; } // 배열 인덱스 처리: "[0]" → 0 if (part.startsWith('[') && part.endsWith(']')) { const index = parseInt(part.slice(1, -1), 10); current = current[index]; } else { current = current[part]; } } return current; } /** * ModalDataSourceWrapper Props */ interface ModalDataSourceWrapperProps { /** 모달이 열린 상태인지 */ isOpen: boolean; /** 모달 ID */ modalId: string; /** 모달에 정의된 data_sources */ dataSources?: DataSource[]; /** 현재 데이터 컨텍스트 */ dataContext: Record; /** 전역 상태 업데이터 (ActionDispatcher의 globalStateUpdater와 동일한 시그니처) */ globalStateUpdater?: (updates: Record) => void; /** DataBindingEngine 인스턴스 (표현식 치환용) */ bindingEngine?: DataBindingEngine; /** 디버그 모드 */ debug?: boolean; /** 자식 요소 (모달 컴포넌트) */ children: React.ReactNode; } /** * 모달 data_sources 래퍼 컴포넌트 * * 모달이 열릴 때 정의된 data_sources를 fetch하고 * 결과를 전역 상태에 저장합니다. * * 사용 예: * ```json * { * "id": "module_detail_modal", * "type": "composite", * "name": "Modal", * "data_sources": [ * { * "id": "moduleDetail", * "type": "api", * "endpoint": "/api/admin/modules/{{_global.selectedModuleIdentifier}}", * "auth_required": true, * "initGlobal": "selectedModule" * } * ], * ... * } * ``` */ export const ModalDataSourceWrapper: React.FC = ({ isOpen, modalId, dataSources, dataContext, globalStateUpdater, bindingEngine, debug = false, children, }) => { const [isLoading, setIsLoading] = useState(false); const [hasFetched, setHasFetched] = useState(false); const prevIsOpenRef = useRef(false); const dataSourceManagerRef = useRef(null); // DataSourceManager 싱글턴 생성 if (!dataSourceManagerRef.current) { dataSourceManagerRef.current = new DataSourceManager(); } /** * 모달의 data_sources를 fetch합니다. */ const fetchModalDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) return; if (!dataSourceManagerRef.current) return; setIsLoading(true); try { // 현재 URL에서 라우트/쿼리 파라미터 추출 const routeParams = dataContext.route || {}; const queryParams = new URLSearchParams(window.location.search); // data_sources의 endpoint에서 표현식 치환 // 예: /api/admin/modules/{{_global.selectedModuleIdentifier}} -> /api/admin/modules/sirsoft-ecommerce const resolvedDataSources = dataSources.map((ds) => { let resolvedDs = { ...ds }; if (ds.endpoint && bindingEngine) { // endpoint 내 표현식 치환 (캐시 사용 안 함 - 모달이 열릴 때마다 새로운 값 필요) const resolvedEndpoint = bindingEngine.resolveBindings( ds.endpoint, dataContext, { skipCache: true } ); resolvedDs.endpoint = resolvedEndpoint; } // params의 표현식도 치환 (캐시 사용 안 함) if (ds.params && bindingEngine) { const resolvedParams: Record = {}; Object.entries(ds.params).forEach(([key, value]) => { if (typeof value === 'string') { resolvedParams[key] = bindingEngine.resolveBindings(value, dataContext, { skipCache: true }); } else { resolvedParams[key] = value; } }); resolvedDs.params = resolvedParams; } return resolvedDs; }); if (debug) { logger.log(`[ModalDataSourceWrapper] Modal "${modalId}" fetching data sources:`, resolvedDataSources.map(ds => ({ id: ds.id, endpoint: ds.endpoint }))); } // DataSourceManager를 사용하여 fetch (기존 로직 재사용) const results = await dataSourceManagerRef.current.fetchDataSourcesWithResults( resolvedDataSources, routeParams, queryParams ); // 결과를 전역 상태에 저장 if (globalStateUpdater) { results.forEach((result) => { if (result.state === 'success' && result.data !== undefined) { // 해당 data_source 정의 찾기 const sourceDef = resolvedDataSources.find(ds => ds.id === result.id); if (sourceDef?.initGlobal) { // API 응답에서 실제 데이터 추출 (data.data 또는 data 자체) const actualData = result.data?.data ?? result.data; // initGlobal 옵션에 따라 _global에 데이터 저장 // ActionDispatcher의 globalStateUpdater는 { key: value } 형태를 받음 // 배열 형태: 여러 전역 상태 동시 초기화 // 문자열 형태: 전체 데이터 저장 // 객체 형태: { key, path }로 특정 필드만 저장 const initGlobalItems = Array.isArray(sourceDef.initGlobal) ? sourceDef.initGlobal : [sourceDef.initGlobal]; for (const item of initGlobalItems) { if (typeof item === 'string') { globalStateUpdater({ [item]: actualData, }); if (debug) { logger.log(`[ModalDataSourceWrapper] Modal "${modalId}" initGlobal: ${result.id}.data -> _global.${item}`); } } else if (typeof item === 'object' && item.key) { const { key, path } = item; // path가 지정되면 해당 경로의 데이터만 추출 const targetData = path ? getNestedValue(actualData, path) : actualData; globalStateUpdater({ [key]: targetData, }); if (debug) { logger.log(`Modal "${modalId}" initGlobal: ${result.id}.data${path ? '.' + path : ''} -> _global.${key}`); } } } } } else if (result.state === 'error') { logger.error(`Modal "${modalId}" data source error:`, result.id, result.error); } }); } if (debug) { logger.log(`Modal "${modalId}" data sources fetched successfully`); } } catch (error) { logger.error(`Modal "${modalId}" data source fetch error:`, error); } finally { setIsLoading(false); setHasFetched(true); } }, [dataSources, dataContext, globalStateUpdater, bindingEngine, modalId, debug]); // 모달 데이터 소스를 TemplateApp 레지스트리에 등록/해제 // refetchDataSource 핸들러에서 모달 데이터 소스를 찾을 수 있도록 함 useEffect(() => { if (!dataSources || dataSources.length === 0) return; const templateApp = (window as any).__templateApp; if (templateApp?.registerModalDataSources) { templateApp.registerModalDataSources(modalId, dataSources); } return () => { if (templateApp?.unregisterModalDataSources) { templateApp.unregisterModalDataSources(modalId); } }; }, [modalId, dataSources]); useEffect(() => { // 모달이 닫힐 때 hasFetched 리셋 (다음 열림 시 다시 fetch) if (!isOpen && prevIsOpenRef.current) { setHasFetched(false); if (debug) { logger.log(`[ModalDataSourceWrapper] Modal "${modalId}" closed, reset fetch state`); } } // 모달이 열릴 때 data_sources fetch if (isOpen && !prevIsOpenRef.current && dataSources && dataSources.length > 0 && !hasFetched) { fetchModalDataSources(); } prevIsOpenRef.current = isOpen; }, [isOpen, dataSources, hasFetched, fetchModalDataSources, modalId, debug]); // children에 로딩 상태 전달 (필요시 사용) // 현재는 단순히 children을 렌더링 return <>{children}; }; export default ModalDataSourceWrapper;