4047 lines
139 KiB
TypeScript
4047 lines
139 KiB
TypeScript
/**
|
|
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
|
|
* development iterations, not G7 platform release versions.
|
|
*/
|
|
/**
|
|
* G7Core 전역 객체 초기화 모듈
|
|
*
|
|
* 템플릿 컴포넌트에서 사용할 수 있는 G7Core 전역 API를 노출합니다.
|
|
* template-engine.ts에서 분리되어 단일 책임 원칙을 따릅니다.
|
|
*
|
|
* @packageDocumentation
|
|
*/
|
|
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import { createPortal } from 'react-dom';
|
|
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
|
import { ComponentRegistry } from './ComponentRegistry';
|
|
import { TranslationEngine, TranslationContext } from './TranslationEngine';
|
|
import { ActionDispatcher } from './ActionDispatcher';
|
|
import { DataBindingEngine } from './DataBindingEngine';
|
|
import { useTransitionState } from './TransitionContext';
|
|
import { useTranslation } from './TranslationContext';
|
|
import { useResponsive } from './ResponsiveContext';
|
|
import { AuthManager } from '../auth/AuthManager';
|
|
import { getApiClient } from '../api/ApiClient';
|
|
import { createLogger, flushEarlyLogs } from '../utils/Logger';
|
|
import { WebSocketManager } from '../websocket/WebSocketManager';
|
|
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
|
|
import { DiagnosticEngine } from '../devtools/DiagnosticEngine';
|
|
import { getServerConnector } from '../devtools/ServerConnector';
|
|
import { DevToolsPanel } from '../devtools/ui/DevToolsPanel';
|
|
import { getStyleTracker } from '../devtools/StyleTracker';
|
|
import type { DiagnosticCategory } from '../devtools/types';
|
|
import {
|
|
renderItemChildren,
|
|
createChangeEvent,
|
|
createClickEvent,
|
|
createSubmitEvent,
|
|
createKeyboardEvent,
|
|
mergeClasses,
|
|
conditionalClass,
|
|
joinClasses,
|
|
} from './helpers';
|
|
import type { SlotContextValue } from './SlotContext';
|
|
import {
|
|
useControllableState,
|
|
shallowArrayEqual,
|
|
shallowObjectEqual,
|
|
} from '../hooks/useControllableState';
|
|
import { triggerModalParentUpdate } from './ParentContextProvider';
|
|
|
|
const logger = createLogger('G7CoreGlobals');
|
|
|
|
/**
|
|
* G7Core.devTools 인터페이스
|
|
*
|
|
* 템플릿 컴포넌트와 헬퍼 함수에서 DevTools 추적 기능에 접근하기 위한 통합 인터페이스입니다.
|
|
* G7DevToolsCore.getInstance() 직접 호출 대신 이 인터페이스를 사용합니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // renderItemChildren 등에서 사용
|
|
* if (G7Core.devTools?.isEnabled()) {
|
|
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
|
|
* }
|
|
* ```
|
|
*/
|
|
export interface G7DevToolsInterface {
|
|
/** DevTools 활성화 상태 확인 */
|
|
isEnabled(): boolean;
|
|
|
|
/** 렌더링 추적 */
|
|
trackRender(componentName: string): void;
|
|
|
|
/** iteration 추적 */
|
|
trackIteration(
|
|
id: string,
|
|
source: string,
|
|
itemVar: string,
|
|
indexVar: string | undefined,
|
|
itemCount: number
|
|
): void;
|
|
|
|
/** 조건부 렌더링 추적 */
|
|
trackIfCondition(
|
|
id: string,
|
|
condition: string,
|
|
result: boolean,
|
|
componentName?: string
|
|
): void;
|
|
|
|
/** 컴포넌트 마운트 추적 */
|
|
trackMount(componentId: string, info: { name: string; type: string; props?: Record<string, any>; parentId?: string }): void;
|
|
|
|
/** 컴포넌트 언마운트 추적 */
|
|
trackUnmount(componentId: string): void;
|
|
|
|
/** 표현식 평가 추적 */
|
|
trackExpressionEval(info: {
|
|
expression: string;
|
|
result: any;
|
|
resultType: string;
|
|
duration?: number;
|
|
method: 'resolve' | 'resolveBindings' | 'evaluateExpression';
|
|
componentId?: string;
|
|
componentName?: string;
|
|
propName?: string;
|
|
fromCache: boolean;
|
|
skipCache?: boolean;
|
|
steps?: Array<{
|
|
step: 'parse' | 'resolve' | 'evaluate' | 'cache';
|
|
input?: string;
|
|
output?: any;
|
|
path?: string;
|
|
value?: any;
|
|
fromCache?: boolean;
|
|
error?: string;
|
|
}>;
|
|
}): void;
|
|
|
|
/** 바인딩 평가 추적 */
|
|
trackBindingEval(expression?: string): void;
|
|
|
|
/** 캐시 히트 추적 */
|
|
recordCacheHit(): void;
|
|
|
|
/** 캐시 미스 추적 */
|
|
recordCacheMiss(): void;
|
|
|
|
/** 핸들러 등록 추적 */
|
|
trackHandlerRegistration(
|
|
name: string,
|
|
category: 'built-in' | 'custom' | 'module' | 'plugin',
|
|
description?: string,
|
|
source?: string
|
|
): void;
|
|
|
|
/** 핸들러 해제 추적 */
|
|
trackHandlerUnregistration(name: string): void;
|
|
|
|
/** 액션 로그 기록 */
|
|
logAction(action: {
|
|
id: string;
|
|
type: string;
|
|
params?: Record<string, any>;
|
|
resolvedParams?: Record<string, any>;
|
|
context?: Record<string, any>;
|
|
startTime: number;
|
|
endTime?: number;
|
|
duration?: number;
|
|
status: 'started' | 'success' | 'error';
|
|
result?: any;
|
|
error?: { name: string; message: string; stack?: string };
|
|
children?: Array<any>;
|
|
}): void;
|
|
|
|
/** 네트워크 요청 시작 추적 */
|
|
trackRequest(url: string, method: string): string;
|
|
|
|
/** 네트워크 요청 완료 추적 */
|
|
completeRequest(requestId: string, statusCode: number, response?: any): void;
|
|
|
|
/** 네트워크 요청 실패 추적 */
|
|
failRequest(requestId: string, error: string): void;
|
|
|
|
/** 데이터소스 정의 추적 */
|
|
trackDataSourceDefinition(info: {
|
|
id: string;
|
|
type: 'api' | 'static' | 'route_params' | 'query_params' | 'websocket';
|
|
endpoint?: string;
|
|
}): void;
|
|
|
|
/** 데이터소스 로딩 시작 추적 */
|
|
trackDataSourceLoading(id: string): void;
|
|
|
|
/** 데이터소스 로드 완료 추적 */
|
|
trackDataSourceLoaded(id: string, data: any, dataPath?: string): void;
|
|
|
|
/** 데이터소스 로드 실패 추적 */
|
|
trackDataSourceError(id: string, error: string): void;
|
|
|
|
/** Form 추적 */
|
|
trackForm(id: string, dataKey: string, inputs: Array<{ name: string; type: string; binding?: string }>): void;
|
|
|
|
/** Form 제거 추적 */
|
|
untrackForm(id: string): void;
|
|
|
|
/** setState 렌더링 상관관계 추적 - 시작 */
|
|
startStateChange(
|
|
statePath: string,
|
|
oldValue: any,
|
|
newValue: any,
|
|
trigger?: { actionId?: string; handlerType?: string; source?: string }
|
|
): string;
|
|
|
|
/** setState 렌더링 상관관계 추적 - 완료 */
|
|
completeStateChange(setStateId: string): void;
|
|
|
|
/** 컴포넌트 렌더링 추적 (상태 변경 컨텍스트 내) */
|
|
trackComponentRender(
|
|
componentId: string,
|
|
componentName: string,
|
|
renderDuration: number,
|
|
accessedStatePaths?: string[],
|
|
evaluatedBindings?: string[],
|
|
parentId?: string
|
|
): void;
|
|
|
|
/** _local 상태 업데이트 */
|
|
updateLocalState(localState: Record<string, any>): void;
|
|
|
|
/** _computed 상태 업데이트 */
|
|
updateComputedState(computedState: Record<string, any>): void;
|
|
|
|
/** $parent 컨텍스트 업데이트 (모달/자식 레이아웃에서 부모 상태 접근용) */
|
|
updateParentContext(parentContext: Record<string, any> | undefined): void;
|
|
|
|
/** 컴포넌트 상태 소스 추적 (g7-state-hierarchy) */
|
|
trackComponentStateSource?(
|
|
componentId: string,
|
|
componentName: string,
|
|
stateSource: {
|
|
global: string[];
|
|
local: string[];
|
|
context: string[];
|
|
},
|
|
stateProvider: {
|
|
type: 'globalState' | 'dynamicState' | 'componentContext';
|
|
hasComponentContext: boolean;
|
|
parentId?: string;
|
|
}
|
|
): void;
|
|
|
|
/** dynamicState 추적 (g7-state-hierarchy) */
|
|
trackDynamicState?(componentId: string, dynamicState: Record<string, any>): void;
|
|
|
|
/** 컨텍스트 플로우 추적 (g7-context-flow) */
|
|
trackContextFlow?(
|
|
componentId: string,
|
|
componentName: string,
|
|
contextReceived: boolean,
|
|
passedToChildren: boolean,
|
|
usedInRender: boolean,
|
|
parentId?: string
|
|
): void;
|
|
|
|
/** 컴포넌트 스타일 추적 (g7-styles) */
|
|
trackComponentStyle?(
|
|
componentId: string,
|
|
componentName: string,
|
|
classes: string[],
|
|
computedStyles: {
|
|
display: string;
|
|
visibility: string;
|
|
opacity: string;
|
|
position: string;
|
|
zIndex: string;
|
|
overflow: string;
|
|
width: string;
|
|
height: string;
|
|
backgroundColor: string;
|
|
color: string;
|
|
}
|
|
): void;
|
|
|
|
/** 인증 이벤트 추적 (g7-auth) */
|
|
trackAuthEvent?(
|
|
type: 'login' | 'logout' | 'token-refresh' | 'token-expired' | 'session-restored' | 'permission-denied' | 'api-unauthorized',
|
|
success: boolean,
|
|
error?: string,
|
|
details?: Record<string, any>
|
|
): void;
|
|
|
|
/** 인증 헤더 추적 (g7-auth) */
|
|
trackAuthHeader?(
|
|
url: string,
|
|
hasAuthHeader: boolean,
|
|
headerType?: string,
|
|
tokenValid?: boolean,
|
|
responseStatus?: number
|
|
): void;
|
|
|
|
/** 로그 추적 (g7-logs) */
|
|
trackLog?(
|
|
level: 'log' | 'warn' | 'error' | 'debug' | 'info',
|
|
prefix: string,
|
|
args: unknown[]
|
|
): void;
|
|
|
|
/**
|
|
* 액션 실행 추적 (debounce 지원)
|
|
*
|
|
* debounce 옵션이 있는 액션의 실행 상태를 추적합니다.
|
|
*/
|
|
trackAction?(info: {
|
|
handler: string;
|
|
type: string;
|
|
status: 'pending' | 'success' | 'error' | 'cancelled';
|
|
params?: Record<string, any>;
|
|
debounce?: {
|
|
delay: number;
|
|
status: 'pending' | 'executed' | 'cancelled';
|
|
scheduledAt?: number;
|
|
executedAt?: number;
|
|
mode?: 'leading' | 'trailing';
|
|
};
|
|
}): void;
|
|
|
|
/**
|
|
* 데이터소스 업데이트 추적 (부분 업데이트 지원)
|
|
*
|
|
* G7Core.dataSource.updateItem() 호출을 추적합니다.
|
|
*/
|
|
trackDataSourceUpdate?(info: {
|
|
dataSourceId: string;
|
|
updateType: 'full' | 'partial';
|
|
itemPath?: string;
|
|
itemId?: string | number;
|
|
updates?: Record<string, any>;
|
|
timestamp: number;
|
|
}): void;
|
|
|
|
// ============================================
|
|
// Sequence 실행 추적 메서드 (g7-sequence)
|
|
// ============================================
|
|
|
|
/**
|
|
* Sequence 실행 시작
|
|
*
|
|
* @param trigger 트리거 정보
|
|
* @returns 생성된 sequenceId
|
|
*/
|
|
startSequenceExecution?(trigger?: {
|
|
eventType?: string;
|
|
source?: string;
|
|
callbackType?: 'onSuccess' | 'onError';
|
|
}): string;
|
|
|
|
/**
|
|
* Sequence 내 액션 실행 전 상태 캡처
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param actionIndex 액션 인덱스
|
|
* @param handler 핸들러 이름
|
|
* @param params 액션 파라미터
|
|
* @param currentState 현재 상태
|
|
*/
|
|
captureSequenceActionBefore?(
|
|
sequenceId: string,
|
|
actionIndex: number,
|
|
handler: string,
|
|
params: Record<string, any>,
|
|
currentState: {
|
|
_global: Record<string, any>;
|
|
_local: Record<string, any>;
|
|
_isolated?: Record<string, any>;
|
|
}
|
|
): void;
|
|
|
|
/**
|
|
* Sequence 내 액션 실행 후 상태 캡처
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param actionIndex 액션 인덱스
|
|
* @param currentState 현재 상태
|
|
* @param duration 소요 시간
|
|
* @param result 액션 결과
|
|
* @param error 에러 (있는 경우)
|
|
*/
|
|
captureSequenceActionAfter?(
|
|
sequenceId: string,
|
|
actionIndex: number,
|
|
currentState: {
|
|
_global: Record<string, any>;
|
|
_local: Record<string, any>;
|
|
_isolated?: Record<string, any>;
|
|
},
|
|
duration: number,
|
|
result?: any,
|
|
error?: Error
|
|
): void;
|
|
|
|
/**
|
|
* Sequence 실행 완료
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param error 전체 에러 (있는 경우)
|
|
*/
|
|
endSequenceExecution?(sequenceId: string, error?: Error): void;
|
|
|
|
// ============================================
|
|
// Stale Closure 감지 메서드 (g7-stale-closure)
|
|
// ============================================
|
|
|
|
/**
|
|
* 상태 캡처 등록 (비동기 핸들러 시작 시)
|
|
*
|
|
* @param handlerId 핸들러 ID
|
|
* @param statePaths 캡처할 상태 경로들
|
|
* @param stateValues 현재 상태 값들
|
|
*/
|
|
registerStateCaptureForHandler?(
|
|
handlerId: string,
|
|
statePaths: string[],
|
|
stateValues: Record<string, any>
|
|
): void;
|
|
|
|
/**
|
|
* Stale Closure 감지 (비동기 작업 완료 후)
|
|
*
|
|
* @param handlerId 핸들러 ID
|
|
* @param location 발생 위치
|
|
* @param currentState 현재 상태
|
|
* @param warningType 경고 유형
|
|
* @param actionId 관련 액션 ID
|
|
*/
|
|
detectStaleClosure?(
|
|
handlerId: string,
|
|
location: string,
|
|
currentState: Record<string, any>,
|
|
warningType: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale',
|
|
actionId?: string
|
|
): any[];
|
|
|
|
/**
|
|
* Stale Closure 경고 직접 추가
|
|
*
|
|
* @param info 경고 정보
|
|
*/
|
|
trackStaleClosureWarning?(info: {
|
|
type: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale';
|
|
location: string;
|
|
capturedPath: string;
|
|
capturedValue: any;
|
|
capturedAt: number;
|
|
currentValue: any;
|
|
actionId?: string;
|
|
stackTrace?: string;
|
|
}): void;
|
|
|
|
// ============================================
|
|
// Modal State Scope 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 모달 열림 추적
|
|
*
|
|
* @param options 모달 정보
|
|
*/
|
|
trackModalOpen?(options: {
|
|
modalId: string;
|
|
modalName: string;
|
|
scopeType?: 'isolated' | 'shared' | 'inherited';
|
|
parentModalId?: string;
|
|
componentId?: string;
|
|
initialState?: Record<string, any>;
|
|
isolatedStateKeys?: string[];
|
|
sharedStateKeys?: string[];
|
|
}): void;
|
|
|
|
/**
|
|
* 모달 닫힘 추적
|
|
*
|
|
* @param modalId 모달 ID
|
|
* @param finalState 최종 상태 (선택)
|
|
*/
|
|
trackModalClose?(modalId: string, finalState?: Record<string, any>): void;
|
|
|
|
/**
|
|
* 모달 내 상태 변경 추적
|
|
*
|
|
* @param options 상태 변경 정보
|
|
*/
|
|
trackModalStateChange?(options: {
|
|
modalId: string;
|
|
stateKey: string;
|
|
previousValue: any;
|
|
newValue: any;
|
|
changeSource?: 'user-action' | 'api-response' | 'parent-sync' | 'init' | 'cleanup';
|
|
}): void;
|
|
|
|
// ============================================
|
|
// Nested Context 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Nested Context 추적 (expandChildren, cellChildren, iteration, modal, slot)
|
|
*
|
|
* @param info 컨텍스트 정보
|
|
* @returns 생성된 컨텍스트 ID
|
|
*/
|
|
trackNestedContext?(info: {
|
|
componentId: string;
|
|
componentType: 'expandChildren' | 'cellChildren' | 'iteration' | 'modal' | 'slot';
|
|
parentContext: {
|
|
available: string[];
|
|
values: Record<string, any>;
|
|
};
|
|
ownContext: {
|
|
added: string[];
|
|
values: Record<string, any>;
|
|
};
|
|
depth: number;
|
|
parentId?: string;
|
|
}): string;
|
|
|
|
/**
|
|
* Nested Context 접근 시도 기록
|
|
*
|
|
* @param contextId 컨텍스트 ID
|
|
* @param access 접근 정보
|
|
*/
|
|
trackNestedContextAccess?(contextId: string, access: {
|
|
path: string;
|
|
found: boolean;
|
|
value?: any;
|
|
error?: string;
|
|
}): void;
|
|
|
|
// ============================================
|
|
// Computed 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Computed 속성 등록
|
|
*
|
|
* @param name 속성명
|
|
* @param expression 표현식
|
|
* @param dependencies 의존성 배열
|
|
* @param value 현재 값
|
|
* @param computationTime 계산 시간
|
|
* @param componentId 컴포넌트 ID (선택)
|
|
* @param error 에러 메시지 (선택)
|
|
*/
|
|
trackComputedProperty?(
|
|
name: string,
|
|
expression: string,
|
|
dependencies: Array<{ type: string; path: string; value?: any }>,
|
|
value: any,
|
|
computationTime: number,
|
|
componentId?: string,
|
|
error?: string
|
|
): void;
|
|
|
|
/**
|
|
* Computed 재계산 기록
|
|
*
|
|
* @param computedName 속성명
|
|
* @param trigger 트리거 유형
|
|
* @param previousValue 이전 값
|
|
* @param newValue 새 값
|
|
* @param computationTime 계산 시간
|
|
* @param triggeredBy 트리거 정보 (선택)
|
|
* @param componentId 컴포넌트 ID (선택)
|
|
*/
|
|
trackComputedRecalc?(
|
|
computedName: string,
|
|
trigger: 'dependency-change' | 'manual' | 'init',
|
|
previousValue: any,
|
|
newValue: any,
|
|
computationTime: number,
|
|
triggeredBy?: {
|
|
type: string;
|
|
path: string;
|
|
oldValue?: any;
|
|
newValue?: any;
|
|
},
|
|
componentId?: string
|
|
): void;
|
|
|
|
// ============================================
|
|
// Named Actions 추적 메서드 (g7-named-actions)
|
|
// ============================================
|
|
|
|
/** named_actions 정의 등록 */
|
|
setNamedActionDefinitions?(definitions: Record<string, any>): void;
|
|
|
|
/** actionRef 해석 이력 기록 */
|
|
trackNamedActionRef?(log: {
|
|
actionRefName: string;
|
|
resolvedHandler: string;
|
|
timestamp: number;
|
|
}): void;
|
|
|
|
}
|
|
|
|
/**
|
|
* G7Core 전역 객체 초기화에 필요한 의존성 인터페이스
|
|
*/
|
|
export interface G7CoreDependencies {
|
|
/** 템플릿 엔진 상태 getter */
|
|
getState: () => {
|
|
translationEngine: TranslationEngine | null;
|
|
translationContext: TranslationContext;
|
|
bindingEngine: DataBindingEngine | null;
|
|
actionDispatcher: ActionDispatcher | null;
|
|
templateMetadata: { locales: string[] } | null;
|
|
};
|
|
/** TransitionManager 인스턴스 */
|
|
transitionManager: {
|
|
getIsPending: () => boolean;
|
|
subscribe: (callback: (isPending: boolean) => void) => () => void;
|
|
};
|
|
/** ResponsiveManager 인스턴스 */
|
|
responsiveManager: object;
|
|
/** WebSocketManager 인스턴스 */
|
|
webSocketManager: WebSocketManager;
|
|
/** SlotContext getter (SlotProvider 내부에서 설정) */
|
|
getSlotContext?: () => SlotContextValue | null;
|
|
/** DynamicRenderer 컴포넌트 getter */
|
|
getDynamicRenderer?: () => React.FC<any> | null;
|
|
/** ComponentRegistry getter */
|
|
getComponentRegistry?: () => any;
|
|
/** DataBindingEngine getter */
|
|
getDataBindingEngine?: () => DataBindingEngine | null;
|
|
/** TranslationEngine getter */
|
|
getTranslationEngine?: () => TranslationEngine | null;
|
|
/** ActionDispatcher getter */
|
|
getActionDispatcher?: () => ActionDispatcher | null;
|
|
}
|
|
|
|
/**
|
|
* React 라이브러리를 전역으로 노출
|
|
*
|
|
* 템플릿 번들에서 React를 사용할 수 있도록 합니다.
|
|
*/
|
|
function initReactGlobals(): void {
|
|
// React/ReactDOM/ReactJSXRuntime을 전역으로 노출 (템플릿 번들에서 사용)
|
|
(window as any).React = React;
|
|
// React 19에서 unstable_batchedUpdates가 제거되어 @dnd-kit 등에서 에러 발생
|
|
// no-op 폴리필을 제공하여 호환성 유지 (React 19에서는 자동 배칭이 기본값)
|
|
(window as any).ReactDOM = {
|
|
...ReactDOM,
|
|
createPortal,
|
|
unstable_batchedUpdates: (callback: () => void) => callback(),
|
|
};
|
|
(window as any).ReactJSXRuntime = ReactJSXRuntime;
|
|
|
|
logger.log('전역 객체 window.React, window.ReactDOM, window.ReactJSXRuntime에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 이벤트 시스템 초기화
|
|
*
|
|
* 컴포넌트 간 통신을 위한 이벤트 시스템을 제공합니다.
|
|
*/
|
|
function initComponentEventSystem(G7Core: any): void {
|
|
const componentEventListeners = new Map<string, Set<(data?: any) => void | Promise<any>>>();
|
|
const devTools = G7DevToolsCore.getInstance();
|
|
|
|
G7Core.componentEvent = {
|
|
/**
|
|
* 컴포넌트 이벤트를 구독합니다.
|
|
* @param eventName 이벤트명 (예: 'triggerUpload:site_logo_uploader')
|
|
* @param callback 이벤트 발생 시 호출될 콜백
|
|
* @returns 구독 해제 함수
|
|
*/
|
|
on: (eventName: string, callback: (data?: any) => void | Promise<any>) => {
|
|
if (!componentEventListeners.has(eventName)) {
|
|
componentEventListeners.set(eventName, new Set());
|
|
}
|
|
componentEventListeners.get(eventName)!.add(callback);
|
|
|
|
// DevTools 추적
|
|
devTools.trackEventSubscribe(eventName);
|
|
|
|
return () => {
|
|
componentEventListeners.get(eventName)?.delete(callback);
|
|
// DevTools 추적
|
|
devTools.trackEventUnsubscribe(eventName);
|
|
};
|
|
},
|
|
/**
|
|
* 컴포넌트 이벤트를 발생시킵니다.
|
|
* @param eventName 이벤트명
|
|
* @param data 전달할 데이터
|
|
* @returns 모든 리스너의 실행 결과 Promise 배열
|
|
*/
|
|
emit: async (eventName: string, data?: any): Promise<any[]> => {
|
|
const listeners = componentEventListeners.get(eventName);
|
|
const listenerCount = listeners?.size ?? 0;
|
|
|
|
if (!listeners || listeners.size === 0) {
|
|
// DevTools 추적 (리스너 없음)
|
|
devTools.trackEventEmit(eventName, data, 0);
|
|
return [];
|
|
}
|
|
|
|
let emitError: Error | undefined;
|
|
let results: any[] = [];
|
|
|
|
try {
|
|
results = await Promise.all(
|
|
Array.from(listeners).map(async (callback) => {
|
|
try {
|
|
return await callback(data);
|
|
} catch (error) {
|
|
logger.error(`componentEvent: Error in listener for "${eventName}":`, error);
|
|
throw error;
|
|
}
|
|
})
|
|
);
|
|
} catch (error) {
|
|
emitError = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
|
|
// DevTools 추적
|
|
devTools.trackEventEmit(eventName, data, listenerCount, results, emitError);
|
|
|
|
if (emitError) {
|
|
throw emitError;
|
|
}
|
|
|
|
return results;
|
|
},
|
|
/**
|
|
* 특정 이벤트의 모든 리스너를 제거합니다.
|
|
* @param eventName 이벤트명
|
|
*/
|
|
off: (eventName: string) => {
|
|
componentEventListeners.delete(eventName);
|
|
// DevTools 추적
|
|
devTools.trackEventOff(eventName);
|
|
},
|
|
/**
|
|
* 모든 이벤트 리스너를 제거합니다.
|
|
*/
|
|
clear: () => {
|
|
componentEventListeners.clear();
|
|
// DevTools 추적
|
|
devTools.trackEventClear();
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.componentEvent에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 번역 관련 API 초기화
|
|
*/
|
|
function initTranslationAPI(G7Core: any, deps: G7CoreDependencies): void {
|
|
// useTranslation 훅 노출 (템플릿 컴포넌트에서 다국어 번역 함수 접근용)
|
|
G7Core.useTranslation = useTranslation;
|
|
|
|
/**
|
|
* G7Core.t() - 다국어 번역 함수 직접 접근
|
|
*
|
|
* 컴포넌트에서 훅 없이 간편하게 번역 함수를 사용할 수 있습니다.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // 간단한 번역
|
|
* const text = G7Core.t('common.confirm');
|
|
*
|
|
* // 파라미터를 포함한 번역
|
|
* const message = G7Core.t('admin.users.pagination_info', { from: 1, to: 10, total: 100 });
|
|
* ```
|
|
*
|
|
* @param key 번역 키 (예: 'common.confirm')
|
|
* @param params 번역 파라미터 (선택, 예: { count: 5 })
|
|
* @returns 번역된 문자열, 번역이 없으면 키 자체 반환
|
|
*/
|
|
G7Core.t = (key: string, params?: Record<string, string | number>): string => {
|
|
const state = deps.getState();
|
|
// 템플릿 엔진이 초기화되지 않은 경우 키 자체 반환
|
|
if (!state.translationEngine || !state.translationContext) {
|
|
return key;
|
|
}
|
|
|
|
if (params) {
|
|
// 파라미터를 TranslationEngine 형식으로 변환 (|key=value|key2=value2)
|
|
const paramsStr = '|' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('|');
|
|
return state.translationEngine.translate(key, state.translationContext, paramsStr);
|
|
}
|
|
return state.translationEngine.translate(key, state.translationContext);
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.useTranslation에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 이벤트 헬퍼 및 렌더링 헬퍼 초기화
|
|
*/
|
|
function initHelperAPIs(G7Core: any, deps: G7CoreDependencies): void {
|
|
// 이벤트 헬퍼 노출 (템플릿 컴포넌트에서 ActionDispatcher 호환 이벤트 생성용)
|
|
G7Core.createChangeEvent = createChangeEvent;
|
|
G7Core.createClickEvent = createClickEvent;
|
|
G7Core.createSubmitEvent = createSubmitEvent;
|
|
G7Core.createKeyboardEvent = createKeyboardEvent;
|
|
|
|
// UUID 생성 함수 노출 (임시 키 등 고유 식별자 생성용)
|
|
// 사용법: G7Core.uuid() → "550e8400-e29b-41d4-a716-446655440000"
|
|
// 레이아웃 표현식에서: {{$uuid()}}
|
|
G7Core.uuid = (): string => {
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
return crypto.randomUUID();
|
|
}
|
|
// fallback: crypto.randomUUID 미지원 환경
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = (Math.random() * 16) | 0;
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
return v.toString(16);
|
|
});
|
|
};
|
|
|
|
// Logger 팩토리 노출 (모듈/템플릿에서 일관된 로깅을 위해)
|
|
G7Core.createLogger = createLogger;
|
|
logger.log('전역 객체 window.G7Core.createLogger에 노출됨');
|
|
|
|
// renderItemChildren 노출 (반복 아이템의 자식 컴포넌트 렌더링용)
|
|
// CardGrid 등에서 호출 시 translationContext, translationEngine, bindingEngine, actionDispatcher를 자동 주입
|
|
// 전역 상태(_global, _local, _computed)도 컨텍스트에 자동 병합하여 cellChildren에서 접근 가능
|
|
G7Core.renderItemChildren = (
|
|
children: any[],
|
|
itemContext: Record<string, any>,
|
|
componentMap: Record<string, any>,
|
|
keyPrefix?: string,
|
|
options?: any
|
|
) => {
|
|
const state = deps.getState();
|
|
|
|
// 전역 상태 가져오기
|
|
// TemplateApp.getGlobalState()는 globalState를 직접 반환 (예: { _remountKeys, sidebarOpen, ... })
|
|
// 이 값이 dataContext._global의 내용과 동일함
|
|
const templateApp = (window as any).__templateApp;
|
|
const globalStateContent = templateApp?.getGlobalState?.() || {};
|
|
|
|
// 전역 상태를 컨텍스트에 자동 병합 (cellChildren에서 _global 접근 가능)
|
|
// itemContext에 이미 해당 키가 있으면 itemContext 값 우선 (명시적 전달 존중)
|
|
// _local, _computed는 itemContext에서 전달받거나 비어있음
|
|
const mergedContext = {
|
|
_global: globalStateContent, // globalState 내용이 곧 _global
|
|
_local: itemContext._local || {},
|
|
_computed: itemContext._computed || {},
|
|
...itemContext,
|
|
};
|
|
|
|
// remount 지원: _global._remountKeys를 확인하여 컴포넌트 리마운트를 지원하는 키 생성 함수
|
|
// 적용된 remountId를 추적하여 중복 적용 방지
|
|
const appliedRemountIds = new Set<string>();
|
|
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
|
|
const remountKeys = mergedContext._global?._remountKeys;
|
|
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
|
|
appliedRemountIds.add(componentId);
|
|
return `${componentId}-remount-${remountKeys[componentId]}`;
|
|
}
|
|
return componentId || fallbackKey;
|
|
};
|
|
|
|
// options가 없거나 필요한 엔진이 없으면 현재 state에서 자동 주입
|
|
const mergedOptions = {
|
|
translationContext: state.translationContext,
|
|
translationEngine: state.translationEngine,
|
|
bindingEngine: state.bindingEngine,
|
|
actionDispatcher: state.actionDispatcher,
|
|
getRemountKey, // remount 지원 추가
|
|
...options,
|
|
};
|
|
return renderItemChildren(children, mergedContext, componentMap, keyPrefix, mergedOptions);
|
|
};
|
|
|
|
// getComponentMap 노출 (CardGrid 등에서 전체 컴포넌트 맵 접근용)
|
|
G7Core.getComponentMap = () => {
|
|
return ComponentRegistry.getInstance().getComponentMap();
|
|
};
|
|
|
|
/**
|
|
* component_layout을 렌더링하는 헬퍼 함수
|
|
*
|
|
* 컴포넌트에서 component_layout 정의를 받아서 React 요소로 렌더링합니다.
|
|
* 각 항목의 컨텍스트(item, index 등)를 주입하여 데이터 바인딩을 처리합니다.
|
|
*
|
|
* @param layoutDefs component_layout 정의 배열
|
|
* @param itemContext 항목별 컨텍스트 (예: { item: option, index: 0, isSelected: true })
|
|
* @param keyPrefix React key prefix
|
|
* @returns React 요소 배열
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // RichSelect 컴포넌트에서 사용
|
|
* const itemElements = G7Core.renderComponentLayout(
|
|
* __componentLayoutDefs?.item,
|
|
* { item: option, index, isSelected },
|
|
* `item-${index}`
|
|
* );
|
|
* ```
|
|
*/
|
|
G7Core.renderComponentLayout = (
|
|
layoutDefs: any[] | undefined,
|
|
itemContext: Record<string, any>,
|
|
keyPrefix?: string
|
|
): React.ReactNode => {
|
|
if (!layoutDefs || !Array.isArray(layoutDefs) || layoutDefs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const componentMap = ComponentRegistry.getInstance().getComponentMap();
|
|
const state = deps.getState();
|
|
|
|
// 전역 상태 가져오기 (remount 지원을 위해)
|
|
const templateApp = (window as any).__templateApp;
|
|
const globalState = templateApp?.getGlobalState?.() || {};
|
|
|
|
// 전역 상태를 컨텍스트에 병합 (remount 키 접근을 위해)
|
|
const mergedContext = {
|
|
_global: globalState._global || {},
|
|
_local: globalState._local || {},
|
|
_computed: globalState._computed || {},
|
|
...itemContext,
|
|
};
|
|
|
|
// remount 지원
|
|
const appliedRemountIds = new Set<string>();
|
|
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
|
|
const remountKeys = mergedContext._global?._remountKeys;
|
|
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
|
|
appliedRemountIds.add(componentId);
|
|
return `${componentId}-remount-${remountKeys[componentId]}`;
|
|
}
|
|
return componentId || fallbackKey;
|
|
};
|
|
|
|
return renderItemChildren(
|
|
layoutDefs,
|
|
mergedContext,
|
|
componentMap,
|
|
keyPrefix,
|
|
{
|
|
translationContext: state.translationContext,
|
|
translationEngine: state.translationEngine,
|
|
bindingEngine: state.bindingEngine,
|
|
actionDispatcher: state.actionDispatcher,
|
|
getRemountKey,
|
|
}
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 확장 행(expandChildren) 컨텐츠를 렌더링하는 헬퍼 함수 (Phase 1-2)
|
|
*
|
|
* DataGrid, CardGrid 등에서 확장 행 렌더링 시 필요한 복잡한 로직을
|
|
* 캡슐화하여 60줄+ 코드를 10줄 이하로 줄입니다.
|
|
*
|
|
* @param config.children 렌더링할 컴포넌트 정의 배열 (expandChildren)
|
|
* @param config.row 현재 행 데이터
|
|
* @param config.expandContext 확장 컨텍스트 (바인딩 표현식 자동 평가)
|
|
* @param config.componentContext 부모 컴포넌트 컨텍스트 (state, setState)
|
|
* @param config.componentMap 컴포넌트 맵 (선택, 없으면 자동 조회)
|
|
* @param config.keyPrefix React key prefix
|
|
* @returns React 요소 배열
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // DataGrid에서 확장 행 렌더링 (Before: 60줄+ → After: 10줄)
|
|
* const renderExpandedContent = useCallback((row: any) => {
|
|
* if (expandedRowRender) return expandedRowRender(row);
|
|
* if (expandChildren && expandChildren.length > 0) {
|
|
* return G7Core.renderExpandContent({
|
|
* children: expandChildren,
|
|
* row,
|
|
* expandContext,
|
|
* componentContext: __componentContext,
|
|
* keyPrefix: `expand-${row[idField]}`,
|
|
* });
|
|
* }
|
|
* return null;
|
|
* }, [expandChildren, expandContext, __componentContext, expandedRowRender, idField]);
|
|
* ```
|
|
*/
|
|
G7Core.renderExpandContent = (config: {
|
|
children: any[];
|
|
row: any;
|
|
expandContext?: Record<string, any>;
|
|
componentContext?: {
|
|
state?: Record<string, any>;
|
|
setState?: (updates: Record<string, any>) => void;
|
|
stateRef?: { current: Record<string, any> };
|
|
computedRef?: { current: Record<string, any> };
|
|
};
|
|
componentMap?: Record<string, React.ComponentType<any>>;
|
|
keyPrefix: string;
|
|
}): React.ReactNode => {
|
|
const { children, row, expandContext, componentContext, keyPrefix } = config;
|
|
|
|
if (!children || !Array.isArray(children) || children.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 컴포넌트 맵 조회 (없으면 자동)
|
|
const componentMap = config.componentMap || ComponentRegistry.getInstance().getComponentMap();
|
|
const state = deps.getState();
|
|
|
|
// 1. 전역 상태 가져오기
|
|
const globalState = G7Core.state?.get?.() || {};
|
|
|
|
// 2. componentContext에서 최신 _local 상태 가져오기
|
|
// stateRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 상태 접근
|
|
// stateRef가 없으면 기존 state 사용 (하위 호환성)
|
|
const latestLocalState = componentContext?.stateRef?.current ?? componentContext?.state ?? {};
|
|
|
|
const mergedLocal = {
|
|
...(globalState._local || {}),
|
|
...latestLocalState,
|
|
};
|
|
|
|
// 3. componentContext에서 최신 _computed 상태 가져오기
|
|
// computedRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 _computed 접근
|
|
// _computed는 _local 기반으로 계산되므로 _local 변경 시 함께 업데이트됨
|
|
const latestComputed = componentContext?.computedRef?.current ?? globalState._computed ?? {};
|
|
|
|
// 4. expandContext 바인딩 표현식 자동 평가
|
|
const bindingEngine = state.bindingEngine ?? G7Core.getDataBindingEngine?.();
|
|
const evalContext = {
|
|
...globalState,
|
|
_local: mergedLocal,
|
|
_computed: latestComputed,
|
|
row,
|
|
item: row,
|
|
$item: row,
|
|
};
|
|
|
|
let resolvedExpandContext: Record<string, any> = {};
|
|
if (expandContext && typeof expandContext === 'object' && bindingEngine) {
|
|
try {
|
|
// expandContext의 각 값을 수동으로 바인딩 평가
|
|
// resolveObject는 DEFAULT_SKIP_BINDING_KEYS 때문에 expandContext 내부 키를 스킵할 수 있음
|
|
for (const [key, value] of Object.entries(expandContext)) {
|
|
if (typeof value === 'string') {
|
|
// 단일 Mustache 표현식인지 확인 ({{expr}} 형태)
|
|
const singleBindingMatch = value.match(/^\{\{([^}]+)\}\}$/);
|
|
if (singleBindingMatch) {
|
|
// 단일 바인딩 표현식: evaluateExpression 사용하여 원본 타입 유지
|
|
// resolveBindings는 문자열 보간용이라 배열/객체를 JSON 문자열로 변환함
|
|
const expr = singleBindingMatch[1].trim();
|
|
try {
|
|
resolvedExpandContext[key] = bindingEngine.evaluateExpression(expr, evalContext, { skipCache: true });
|
|
} catch (e) {
|
|
// 평가 실패 시 undefined (폴백 처리는 표현식 자체에서 || 로 처리)
|
|
resolvedExpandContext[key] = undefined;
|
|
}
|
|
} else {
|
|
// 혼합 표현식 (예: "Hello {{name}}"): resolveBindings 사용
|
|
resolvedExpandContext[key] = bindingEngine.resolveBindings(value, evalContext, { skipCache: true });
|
|
}
|
|
} else {
|
|
resolvedExpandContext[key] = value;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('renderExpandContent: expandContext 평가 오류:', error);
|
|
}
|
|
}
|
|
|
|
// 4. 최종 컨텍스트 구성
|
|
const context = {
|
|
row,
|
|
item: row,
|
|
$item: row,
|
|
_local: mergedLocal,
|
|
_global: globalState._global || {},
|
|
_computed: globalState._computed || {},
|
|
...resolvedExpandContext,
|
|
};
|
|
|
|
// 5. remount 지원
|
|
const appliedRemountIds = new Set<string>();
|
|
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
|
|
const remountKeys = context._global?._remountKeys;
|
|
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
|
|
appliedRemountIds.add(componentId);
|
|
return `${componentId}-remount-${remountKeys[componentId]}`;
|
|
}
|
|
return componentId || fallbackKey;
|
|
};
|
|
|
|
// 6. 렌더링
|
|
return renderItemChildren(children, context, componentMap, keyPrefix, {
|
|
translationContext: state.translationContext,
|
|
translationEngine: state.translationEngine ?? undefined,
|
|
bindingEngine: state.bindingEngine ?? undefined,
|
|
actionDispatcher: state.actionDispatcher,
|
|
componentContext,
|
|
getRemountKey,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 깊은 객체 경로 접근 헬퍼 함수
|
|
*
|
|
* 동적 키를 포함한 깊은 객체 경로 접근과 폴백 값을 지원합니다.
|
|
* 다중 통화, 다국어, 중첩 설정 등 다양한 상황에서 활용할 수 있습니다.
|
|
*
|
|
* @param obj 접근할 객체
|
|
* @param path 경로 (문자열 또는 문자열 배열)
|
|
* @param fallback 경로가 존재하지 않을 때 반환할 기본값
|
|
* @returns 경로에 해당하는 값 또는 폴백 값
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 다중 통화 가격 접근
|
|
* $get(product.prices, [currency, 'formatted'], product.price_formatted)
|
|
*
|
|
* // 다국어 객체 접근
|
|
* $get(product.name, locale, product.name.ko)
|
|
*
|
|
* // 중첩 설정 접근
|
|
* $get(settings, ['drivers', 'mail', 'host'], 'localhost')
|
|
*
|
|
* // 동적 키 접근
|
|
* $get(prices, [selectedPlan, 'monthly'], 0)
|
|
* ```
|
|
*/
|
|
G7Core.$get = function $get(obj: any, path: string | string[], fallback: any = undefined): any {
|
|
// null/undefined 객체는 즉시 폴백 반환
|
|
if (obj === null || obj === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 경로를 배열로 정규화
|
|
const keys = Array.isArray(path) ? path : [path];
|
|
|
|
// 빈 경로는 객체 자체 반환
|
|
if (keys.length === 0) {
|
|
return obj;
|
|
}
|
|
|
|
let current = obj;
|
|
|
|
for (const key of keys) {
|
|
// 현재 위치가 null/undefined이면 폴백 반환
|
|
if (current === null || current === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 키가 null/undefined이면 폴백 반환
|
|
if (key === null || key === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
// 다음 레벨로 이동
|
|
current = current[key];
|
|
}
|
|
|
|
// 최종 값이 null/undefined이면 폴백 반환
|
|
return current ?? fallback;
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.renderItemChildren에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.renderComponentLayout에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.renderExpandContent에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.$get에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 액션 디스패치 API 초기화
|
|
*/
|
|
function initDispatchAPI(G7Core: any): void {
|
|
/**
|
|
* 액션을 디스패치합니다.
|
|
*
|
|
* @param action 실행할 액션 정의
|
|
* @param options 디스패치 옵션
|
|
* @param options.componentContext 컴포넌트 컨텍스트 (openModal 등에서 부모 컨텍스트 지정용)
|
|
* - state: 컴포넌트의 _local 상태
|
|
* - setState: 컴포넌트의 상태 업데이트 함수
|
|
* - 지정 시 __g7ActionContext보다 우선 사용됨
|
|
*
|
|
* @example
|
|
* // 기본 사용 (레이아웃 액션에서 자동으로 컨텍스트 설정됨)
|
|
* G7Core.dispatch({ handler: 'openModal', target: 'myModal' });
|
|
*
|
|
* // 컴포넌트에서 직접 호출 시 컨텍스트 명시적 전달
|
|
* G7Core.dispatch(
|
|
* { handler: 'openModal', target: 'myModal' },
|
|
* { componentContext: props.__componentContext }
|
|
* );
|
|
*/
|
|
G7Core.dispatch = async (action: any, options?: { componentContext?: any }) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (!templateApp) {
|
|
logger.warn('G7Core.dispatch: TemplateApp이 초기화되지 않았습니다.');
|
|
return { success: false, error: new Error('TemplateApp이 초기화되지 않았습니다.') };
|
|
}
|
|
|
|
const actionDispatcher = templateApp.getActionDispatcher?.();
|
|
if (!actionDispatcher) {
|
|
logger.warn('G7Core.dispatch: ActionDispatcher를 찾을 수 없습니다.');
|
|
return { success: false, error: new Error('ActionDispatcher를 찾을 수 없습니다.') };
|
|
}
|
|
|
|
const router = templateApp.getRouter?.();
|
|
const globalState = templateApp.getGlobalState?.();
|
|
const setGlobalState = templateApp.setGlobalState?.bind(templateApp);
|
|
|
|
// engine-v1.17.3: 컴포넌트에서 직접 dispatch 호출 시 componentContext 전달 지원
|
|
// openModal 등에서 부모 컨텍스트를 정확히 지정하기 위해 사용
|
|
// 우선순위: options.componentContext > __g7ActionContext > global fallback
|
|
const passedComponentContext = options?.componentContext;
|
|
|
|
// 현재 실행 중인 액션의 컨텍스트가 있으면 이를 우선 사용
|
|
// 핸들러 내부에서 G7Core.modal.open() 등을 호출할 때 컴포넌트 컨텍스트 유지
|
|
// engine-v1.16.0: $parent 바인딩 컨텍스트 지원을 위해 필수
|
|
const actionContext = passedComponentContext || (window as any).__g7ActionContext;
|
|
|
|
// engine-v1.17.0: setLocal 후 즉시 dispatch 호출 시 최신 로컬 상태 참조 지원
|
|
// React setState는 비동기이므로 actionContext.state에 반영되기 전에 dispatch가 호출될 수 있음
|
|
// __g7PendingLocalState가 있으면 이를 context.state로 사용하여 최신 상태 보장
|
|
const pendingLocalState = (window as any).__g7PendingLocalState;
|
|
let contextState = actionContext?.state ?? globalState;
|
|
|
|
// pending 상태가 있으면 context.state에 병합
|
|
// 커스텀 핸들러에서 setLocal 후 dispatch 호출 시 {{_local.xxx}} 표현식에서 최신 값 참조
|
|
if (pendingLocalState && actionContext) {
|
|
contextState = pendingLocalState;
|
|
logger.log('[dispatch] Using __g7PendingLocalState for context.state:', pendingLocalState);
|
|
}
|
|
|
|
// v1.19.1: pendingLocalState가 있으면 context.data._local도 갱신
|
|
// refetchDataSource 핸들러에서 context.data._local을 contextLocalState로 우선 참조하므로 (v1.19.0)
|
|
// 커스텀 핸들러에서 setLocal → dispatch 호출 시 context.data._local이 렌더 시점의 stale 상태이면
|
|
// merge 순서에 의해 pendingLocalState(NEW)를 contextLocalState(OLD)가 덮어쓰는 문제 발생
|
|
// sequence 핸들러는 G7Core.dispatch를 경유하지 않으므로 이 코드 경로에 영향 없음
|
|
let contextData = actionContext?.data ?? globalState;
|
|
if (pendingLocalState && actionContext?.data?._local) {
|
|
contextData = {
|
|
...actionContext.data,
|
|
_local: pendingLocalState,
|
|
};
|
|
logger.log('[dispatch] Updated context.data._local with pendingLocalState');
|
|
}
|
|
|
|
const context = {
|
|
navigate: router
|
|
? (path: string, options?: { replace?: boolean; state?: any }) =>
|
|
router.navigate(path, options)
|
|
: undefined,
|
|
// 액션 컨텍스트가 있으면 컴포넌트의 setState/state 사용, 없으면 전역 사용
|
|
setState: actionContext?.setState ?? setGlobalState,
|
|
state: contextState,
|
|
data: contextData,
|
|
// engine-v1.17.2: 비동기 콜백에서 dispatch 호출 시 target=local setState 처리 지원
|
|
// 컴포넌트 컨텍스트 없이 dispatch 호출된 경우 마커 설정
|
|
// handleSetState에서 이 마커를 확인하여 globalStateUpdater({ _local: ... }) 경로 사용
|
|
_isDispatchFallbackContext: !actionContext?.setState,
|
|
};
|
|
|
|
// engine-v1.41.0: debounce 옵션 처리
|
|
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
|
|
if (action.debounce) {
|
|
const debounceKey = action.debounceKey || `dispatch-${action.handler}`;
|
|
const delay = typeof action.debounce === 'number' ? action.debounce : action.debounce.delay;
|
|
actionDispatcher.debouncedCall(debounceKey, delay, () => {
|
|
// 디바운스 타이머 fire 시 최신 컨텍스트로 액션 실행
|
|
const { debounce: _d, debounceKey: _k, ...actionWithoutDebounce } = action;
|
|
actionDispatcher.dispatchAction(actionWithoutDebounce, context);
|
|
});
|
|
return { success: true, debounced: true };
|
|
}
|
|
|
|
try {
|
|
return await actionDispatcher.dispatchAction(action, context);
|
|
} catch (error) {
|
|
logger.error('G7Core.dispatch: 액션 실행 오류:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
};
|
|
}
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.dispatch에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* dot notation 경로를 중첩 객체로 변환합니다.
|
|
*
|
|
* @example
|
|
* convertDotNotationToObject({ "filter.orderStatus": ["paid"] })
|
|
* // Returns: { filter: { orderStatus: ["paid"] } }
|
|
*
|
|
* @param updates 변환할 객체
|
|
* @returns 중첩 객체로 변환된 결과
|
|
*/
|
|
function convertDotNotationToObject(updates: Record<string, any>): Record<string, any> {
|
|
const result: Record<string, any> = {};
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (key.includes('.')) {
|
|
// dot notation 경로를 배열로 분리
|
|
const keys = key.split('.');
|
|
let current = result;
|
|
|
|
// 중첩 객체 생성
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const k = keys[i];
|
|
if (!(k in current)) {
|
|
current[k] = {};
|
|
} else if (typeof current[k] !== 'object' || current[k] === null) {
|
|
// 기존 값이 객체가 아니면 객체로 교체
|
|
current[k] = {};
|
|
}
|
|
current = current[k];
|
|
}
|
|
|
|
// 마지막 키에 값 할당
|
|
current[keys[keys.length - 1]] = value;
|
|
} else {
|
|
// dot notation이 아니면 그대로 복사
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 중첩 객체에서 리프(leaf) 경로 전부를 dot notation 문자열 배열로 추출합니다.
|
|
*
|
|
* engine-v1.43.0+ 자동바인딩 경로 자동 승격 감지에 사용.
|
|
* `__g7AutoBindingPaths` 레지스트리에 fullPath 형식(`form.title`)으로 키가 등록되므로,
|
|
* setLocal의 `converted` 중첩 객체에서도 동일한 형식의 리프 경로를 추출해 교집합을 검사한다.
|
|
*
|
|
* 배열은 리프로 취급 (자동바인딩은 배열 자체 값에 매핑되므로).
|
|
*
|
|
* @example
|
|
* flattenLeafPaths({ form: { title: "X", content: "Y" } })
|
|
* // Returns: ["form.title", "form.content"]
|
|
*
|
|
* flattenLeafPaths({ hasChanges: true, form: { tags: ["a", "b"] } })
|
|
* // Returns: ["hasChanges", "form.tags"]
|
|
*
|
|
* @param obj 중첩 객체
|
|
* @param prefix 재귀용 prefix (내부)
|
|
* @returns 리프 경로 문자열 배열
|
|
*/
|
|
function flattenLeafPaths(obj: Record<string, any>, prefix = ''): string[] {
|
|
const paths: string[] = [];
|
|
if (!obj || typeof obj !== 'object') return paths;
|
|
for (const key of Object.keys(obj)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
const value = obj[key];
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
paths.push(...flattenLeafPaths(value, fullKey));
|
|
} else {
|
|
paths.push(fullKey);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* 객체의 모든 키가 숫자(배열 인덱스)인지 확인합니다.
|
|
*
|
|
* @param obj 확인할 객체
|
|
* @returns 모든 키가 숫자이면 true
|
|
*/
|
|
function hasOnlyNumericKeys(obj: Record<string, any>): boolean {
|
|
const keys = Object.keys(obj);
|
|
return keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
|
|
}
|
|
|
|
/**
|
|
* 두 객체를 깊은 병합(deep merge)합니다.
|
|
*
|
|
* 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우,
|
|
* source의 각 키를 배열 인덱스로 해석하여 해당 위치의 요소를 병합합니다.
|
|
* 예: target = [{a:1}, {b:2}], source = {"0": {c:3}} → [{a:1, c:3}, {b:2}]
|
|
*
|
|
* @param target 병합 대상 객체
|
|
* @param source 병합할 소스 객체
|
|
* @returns 병합된 결과 객체
|
|
*/
|
|
/**
|
|
* base 객체에 없는 leaf 키만 extra에서 추가합니다.
|
|
*
|
|
* deepMerge와 달리 base에 이미 존재하는 값(배열 포함)은 절대 덮어쓰지 않습니다.
|
|
* extra에만 존재하는 키는 재귀적으로 추가됩니다.
|
|
*
|
|
* 용도: setLocal에서 dynamicLocal(actionContext.state)의 setState 전용 키를 globalLocal에
|
|
* 안전하게 추가할 때 사용. dynamicLocal의 stale 배열(init_actions 기본값)이 globalLocal의
|
|
* 정상 API 데이터를 덮어쓰는 것을 방지합니다.
|
|
*
|
|
* @since engine-v1.41.0
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const base = { form: { category_ids: [381, 384], name: 'A' } };
|
|
* const extra = { form: { category_ids: [], options: [] }, selectedProducts: [1] };
|
|
* addMissingLeafKeys(base, extra);
|
|
* // → { form: { category_ids: [381, 384], name: 'A', options: [] }, selectedProducts: [1] }
|
|
* // base의 category_ids는 보존, extra의 selectedProducts와 options는 추가
|
|
* ```
|
|
*/
|
|
function addMissingLeafKeys(base: Record<string, any>, extra: Record<string, any>): Record<string, any> {
|
|
const result = { ...base };
|
|
for (const key of Object.keys(extra)) {
|
|
if (!(key in result)) {
|
|
// base에 없는 키: extra 값 그대로 추가
|
|
result[key] = extra[key];
|
|
} else if (
|
|
result[key] !== null &&
|
|
typeof result[key] === 'object' &&
|
|
!Array.isArray(result[key]) &&
|
|
extra[key] !== null &&
|
|
typeof extra[key] === 'object' &&
|
|
!Array.isArray(extra[key])
|
|
) {
|
|
// 양쪽 모두 plain object: 재귀적으로 처리
|
|
result[key] = addMissingLeafKeys(result[key], extra[key]);
|
|
}
|
|
// base에 이미 존재하는 leaf 값(배열, 문자열, 숫자 등): 건너뜀 (base 값 보존)
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
|
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
|
|
// → 배열 요소를 인덱스별로 병합
|
|
if (Array.isArray(target) && !Array.isArray(source) && hasOnlyNumericKeys(source)) {
|
|
const numericKeys = Object.keys(source).map((k) => parseInt(k, 10));
|
|
const maxKey = numericKeys.length > 0 ? Math.max(...numericKeys) : 0;
|
|
|
|
// Sparse array 방지: source의 최대 키가 target 배열 범위를 크게 초과하면
|
|
// 배열 인덱스가 아닌 ID/키 매핑(예: product_option_id → coupon)으로 간주
|
|
// → 배열 병합 대신 일반 객체 병합으로 처리 (sparse array 생성 방지)
|
|
if (maxKey >= target.length + numericKeys.length + 10) {
|
|
// Fall through to normal object merge below
|
|
} else {
|
|
const result = [...target];
|
|
for (const [key, value] of Object.entries(source)) {
|
|
const index = parseInt(key, 10);
|
|
if (index >= 0 && index < result.length) {
|
|
// 인덱스가 범위 내에 있으면 해당 요소와 병합
|
|
if (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
result[index] !== null &&
|
|
typeof result[index] === 'object'
|
|
) {
|
|
result[index] = deepMerge(result[index], value);
|
|
} else {
|
|
result[index] = value;
|
|
}
|
|
} else if (index >= result.length) {
|
|
// 인덱스가 범위를 초과하면 배열 확장
|
|
result[index] = value;
|
|
}
|
|
}
|
|
return result as any;
|
|
}
|
|
}
|
|
|
|
const result = { ...target };
|
|
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
// 중첩 객체인 경우 재귀적으로 병합
|
|
if (result[key] !== null && typeof result[key] === 'object') {
|
|
// target[key]가 배열이든 객체든 deepMerge가 처리
|
|
result[key] = deepMerge(result[key], value);
|
|
} else {
|
|
result[key] = { ...value };
|
|
}
|
|
} else {
|
|
// 원시 값 또는 배열인 경우 그대로 할당
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 전역 상태 관리 API 초기화
|
|
*/
|
|
function initStateAPI(G7Core: any): void {
|
|
G7Core.state = {
|
|
/**
|
|
* 현재 전역 상태를 반환합니다.
|
|
*/
|
|
get: () => {
|
|
const templateApp = (window as any).__templateApp;
|
|
return templateApp?.getGlobalState?.() || {};
|
|
},
|
|
/**
|
|
* 전역 상태를 업데이트합니다.
|
|
*
|
|
* dot notation을 지원합니다.
|
|
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
|
|
*
|
|
* @param updates 업데이트할 상태 객체
|
|
* @param options 병합 옵션 { merge?: 'replace' | 'shallow' | 'deep' }
|
|
*/
|
|
set: (updates: Record<string, any>, options?: { merge?: 'replace' | 'shallow' | 'deep'; render?: boolean }) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.setGlobalState && templateApp?.getGlobalState) {
|
|
const mergeMode = options?.merge || 'deep';
|
|
// dot notation을 중첩 객체로 변환
|
|
const converted = convertDotNotationToObject(updates);
|
|
|
|
let finalState: Record<string, any>;
|
|
if (mergeMode === 'replace') {
|
|
finalState = converted;
|
|
} else if (mergeMode === 'shallow') {
|
|
const currentState = templateApp.getGlobalState();
|
|
finalState = { ...currentState, ...converted };
|
|
} else {
|
|
// deep (기본값)
|
|
const currentState = templateApp.getGlobalState();
|
|
finalState = deepMerge(currentState, converted);
|
|
}
|
|
|
|
// engine-v1.42.0: render 옵션 전파
|
|
templateApp.setGlobalState(finalState, { render: options?.render });
|
|
} else {
|
|
logger.warn('G7Core.state.set: TemplateApp이 초기화되지 않았습니다.');
|
|
}
|
|
},
|
|
/**
|
|
* 컴포넌트 로컬 상태(_local)를 업데이트합니다.
|
|
*
|
|
* 커스텀 핸들러에서 컴포넌트의 로컬 상태를 직접 업데이트할 때 사용합니다.
|
|
* 이 메서드는 ActionDispatcher의 액션 실행 중에만 유효합니다.
|
|
*
|
|
* - 액션 컨텍스트가 있는 경우: 컴포넌트의 dynamicState를 직접 업데이트하여 즉시 UI에 반영
|
|
* - 액션 컨텍스트가 없는 경우: 전역 _local을 업데이트 (fallback)
|
|
*
|
|
* dot notation을 지원합니다.
|
|
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
|
|
*
|
|
* @param updates 업데이트할 로컬 상태 객체
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 커스텀 핸들러에서 사용
|
|
* export function myHandler(action: any, context: any): void {
|
|
* const G7Core = (window as any).G7Core;
|
|
*
|
|
* // 현재 로컬 상태 가져오기
|
|
* const currentLocal = G7Core.state.getLocal();
|
|
*
|
|
* // 로컬 상태 업데이트 (현재 컴포넌트의 상태)
|
|
* G7Core.state.setLocal({
|
|
* selectedItems: [...currentLocal.selectedItems, newItem],
|
|
* isLoading: false,
|
|
* });
|
|
*
|
|
* // dot notation 지원
|
|
* G7Core.state.setLocal({
|
|
* "filter.orderStatus": ["paid", "shipped"],
|
|
* });
|
|
*
|
|
* // scope 옵션으로 부모/루트 레이아웃에 상태 저장 (모달 등에서 유용)
|
|
* G7Core.state.setLocal({
|
|
* "form.fields": updatedFields,
|
|
* }, { scope: 'parent' });
|
|
* }
|
|
* ```
|
|
*/
|
|
setLocal: (updates: Record<string, any>, options?: { scope?: 'current' | 'parent' | 'root'; merge?: 'replace' | 'shallow' | 'deep'; debounce?: number; debounceKey?: string; render?: boolean; selfManaged?: boolean }) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
|
|
// [engine-v1.43.0+] 이중 저장소 동기화 보호 — 자동바인딩 경로 자동 승격
|
|
//
|
|
// 배경: 엔진은 폼 데이터를 React localDynamicState(저장소 A)와 globalState._local(저장소 B)에
|
|
// 이중 저장한다. render:false 호출이 저장소 A에 자동바인딩된 경로를 건드리면 A가 갱신되지 않아
|
|
// Input이 stale 값을 표시한다. DynamicRenderer의 propsWithAutoBinding 주변 useEffect가 활성
|
|
// 자동바인딩 경로를 __g7AutoBindingPaths: Map<string, number>에 ref count로 등록한다.
|
|
//
|
|
// 규칙: setLocal({render:false})가 레지스트리에 등록된 경로를 업데이트하면 render:true로
|
|
// 강제 승격하여 A↔B 정합성 보장. 예외는 selfManaged:true를 명시한 호출 — CKEditor5 등
|
|
// 자체 DOM 관리 플러그인이 의도적으로 render:false를 유지하려 할 때 사용. 누락 시 엔진이
|
|
// 자동 승격하여 안전 확보 (safe-by-default).
|
|
//
|
|
// 자세한 설계 배경: DynamicRenderer.tsx의 performStateUpdate 상단 주석 참조.
|
|
if (options?.render === false && !options?.selfManaged) {
|
|
const registry = (window as any).__g7AutoBindingPaths as Map<string, number> | undefined;
|
|
if (registry && registry.size > 0) {
|
|
const leafPaths = flattenLeafPaths(convertDotNotationToObject(updates));
|
|
if (leafPaths.some((path) => registry.has(path))) {
|
|
logger.log(
|
|
'[setLocal] render:false + 자동바인딩 경로 겹침 감지 → render:true 자동 승격 (engine-v1.43.0)'
|
|
);
|
|
options = { ...options, render: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
// engine-v1.41.0: debounce 옵션 처리
|
|
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
|
|
// 컴포넌트 언마운트 시 자동 정리 + flushPendingDebounceTimers 연동
|
|
if (options?.debounce) {
|
|
const actionDispatcher = templateApp?.getActionDispatcher?.();
|
|
if (actionDispatcher) {
|
|
const key = options.debounceKey || `setLocal-${Object.keys(updates).join(',')}`;
|
|
// 디바운스 타이머 fire 시 원래 setLocal 로직을 debounce 없이 재호출
|
|
const { debounce: _d, debounceKey: _k, ...restOptions } = options;
|
|
actionDispatcher.debouncedCall(key, options.debounce, () => {
|
|
G7Core.state.setLocal(updates, restOptions);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const scope = options?.scope ?? 'current';
|
|
const mergeMode = options?.merge || 'deep';
|
|
|
|
// ActionDispatcher에서 설정한 현재 컴포넌트 컨텍스트 확인
|
|
const actionContext = (window as any).__g7ActionContext;
|
|
|
|
// dot notation을 중첩 객체로 변환
|
|
const converted = convertDotNotationToObject(updates);
|
|
|
|
// scope가 'parent' 또는 'root'인 경우 레이아웃 컨텍스트 스택에서 타겟 컨텍스트 사용
|
|
if (scope === 'parent' || scope === 'root') {
|
|
const contextStack: Array<{ state: Record<string, any>; setState: (updates: any) => void }> =
|
|
(window as any).__g7LayoutContextStack || [];
|
|
|
|
if (contextStack.length > 0) {
|
|
// parent: 스택의 마지막 (바로 이전 컨텍스트)
|
|
// root: 스택의 첫 번째 (최상위 컨텍스트)
|
|
const targetContext = scope === 'parent'
|
|
? contextStack[contextStack.length - 1]
|
|
: contextStack[0];
|
|
|
|
if (targetContext?.setState) {
|
|
// 함수형 업데이트를 사용하여 항상 최신 상태와 병합
|
|
// React setState는 비동기이므로 스냅샷 대신 함수형 업데이트 필수
|
|
targetContext.setState((currentLocal: Record<string, any>) => {
|
|
if (mergeMode === 'replace') return converted;
|
|
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
|
|
return deepMerge(currentLocal || {}, converted);
|
|
});
|
|
logger.log(`[setLocal] scope=${scope}: 타겟 컨텍스트에 상태 업데이트 (mergeMode=${mergeMode})`, converted);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 스택에 컨텍스트가 없으면 경고 후 current로 폴백
|
|
logger.warn(`[setLocal] scope=${scope}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`);
|
|
}
|
|
|
|
// scope: 'current' (기본값)
|
|
// engine-v1.17.7: actionContext 유무와 관계없이 항상 globalLocal을 업데이트
|
|
// - actionContext.setState()는 클릭된 컴포넌트(ChipCheckbox)의 상태만 업데이트
|
|
// - ChipCheckbox의 상태에는 form.label_assignments가 없으므로 잘못된 대상
|
|
// - templateApp.setGlobalState({ _local: ... })가 실제 _local 상태를 관리
|
|
// - _localInit에서도 동일한 메커니즘 사용 (setGlobalState({ _local: ... }))
|
|
const pendingState = (window as any).__g7PendingLocalState;
|
|
const globalLocal = templateApp?.getGlobalState?.()?._local || {};
|
|
// engine-v1.22.1: actionContext.state(=dynamicState)도 병합하여 전체 _local 반영
|
|
// ActionDispatcher setState(target: "_local")로 설정된 값은 localDynamicState에만 존재하고
|
|
// globalLocal(=templateApp.getGlobalState()._local)에는 없음.
|
|
// setLocal 후 openModal 시 __g7PendingLocalState가 $parent._local 스냅샷으로 사용되므로,
|
|
// dynamicState의 값(예: selectedProducts)이 누락되는 문제 발생.
|
|
//
|
|
// engine-v1.24.7: 모달 내부에서 호출 시 actionContext.state 병합 제외
|
|
//
|
|
// engine-v1.41.0: deepMerge(globalLocal, dynamicLocal)은 stale dynamicLocal의 빈 배열이
|
|
// globalLocal의 정상 배열을 교체하는 오염 경로를 생성함 (CKEditor + SPA 네비게이션 시 재현).
|
|
// dynamicLocal은 actionContext.state(=컴포넌트 렌더 시점 스냅샷)이므로
|
|
// init_actions 기본값(category_ids:[], options:[])을 포함할 수 있음.
|
|
// globalLocal은 최신 committed 상태이므로 dynamicLocal과 충돌 시 globalLocal이 정확함.
|
|
// → globalLocal을 기반으로 dynamicLocal에서 globalLocal에 없는 키만 추가하는
|
|
// addMissingLeafKeys 전략으로 변경. v1.22.1 목적(setState 전용 키 보존)은 유지하면서
|
|
// stale 배열 덮어쓰기 방지.
|
|
const layoutContextStack = (window as any).__g7LayoutContextStack || [];
|
|
const isInsideModal = layoutContextStack.length > 0;
|
|
const dynamicLocal = (!isInsideModal && actionContext?.state) ? actionContext.state : undefined;
|
|
const baseLocal = dynamicLocal
|
|
? addMissingLeafKeys(globalLocal, dynamicLocal)
|
|
: globalLocal;
|
|
const currentSnapshot = pendingState || baseLocal;
|
|
let mergedPending: Record<string, any>;
|
|
if (mergeMode === 'replace') {
|
|
mergedPending = converted;
|
|
} else if (mergeMode === 'shallow') {
|
|
mergedPending = { ...currentSnapshot, ...converted };
|
|
} else {
|
|
mergedPending = deepMerge(currentSnapshot, converted);
|
|
}
|
|
|
|
// 1. 전역 _local 상태 업데이트 (핵심!)
|
|
// engine-v1.42.0: render 옵션 전파 — render: false이면 값만 저장, React 렌더링 건너뜀
|
|
if (templateApp?.setGlobalState) {
|
|
templateApp.setGlobalState({ _local: mergedPending }, { render: options?.render });
|
|
logger.log(`[setLocal] globalLocal updated via setGlobalState (mergeMode=${mergeMode}, render=${options?.render ?? true}):`, converted);
|
|
}
|
|
|
|
// 2. pendingState도 업데이트 (같은 이벤트 루프 내 후속 getLocal() 호출용)
|
|
(window as any).__g7PendingLocalState = mergedPending;
|
|
logger.log('[setLocal] __g7PendingLocalState updated:', mergedPending);
|
|
|
|
// 2-1. sequence 내 커스텀 핸들러 동기화용 별도 변수 설정
|
|
// __g7PendingLocalState는 setGlobalState → updateTemplateData → root.render() →
|
|
// useLayoutEffect에서 null로 클리어될 수 있음 (await 해제 시 마이크로태스크 플러시)
|
|
// __g7SequenceLocalSync는 handleSequence에서만 읽고 초기화하므로 안전
|
|
(window as any).__g7SequenceLocalSync = mergedPending;
|
|
|
|
// 2-2. engine-v1.21.2: getLocal() await fallback용 스냅샷
|
|
// __g7PendingLocalState가 useLayoutEffect에서 클리어된 후에도 getLocal()이
|
|
// 최신 setLocal 값을 반환할 수 있도록 별도 스냅샷 유지.
|
|
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
|
|
// dataContext._local이 갱신되면 클리어됨 (DynamicRenderer useLayoutEffect).
|
|
(window as any).__g7LastSetLocalSnapshot = mergedPending;
|
|
|
|
// 3. forcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
|
|
if (mergeMode === 'replace') {
|
|
// replace 모드: 기존 forcedLocalFields 초기화 후 새 값으로 교체
|
|
(window as any).__g7ForcedLocalFields = converted;
|
|
} else {
|
|
const existingForced = (window as any).__g7ForcedLocalFields || {};
|
|
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
|
|
}
|
|
logger.log('[setLocal] __g7ForcedLocalFields updated:', converted);
|
|
|
|
// engine-v1.17.8: setLocal이 업데이트한 키를 기록하여 ROOT의 localDynamicState에서 제거
|
|
// 폼 자동 바인딩이 localDynamicState에 기록한 stale 배열이 dataContext._local을 덮어쓰는 문제 방지
|
|
// setState(target: "local")가 아닌 setLocal만 기록 (setState는 localDynamicState를 직접 업데이트)
|
|
if (mergeMode === 'replace') {
|
|
(window as any).__g7SetLocalOverrideKeys = converted;
|
|
} else {
|
|
const existingKeys = (window as any).__g7SetLocalOverrideKeys || {};
|
|
(window as any).__g7SetLocalOverrideKeys = deepMerge(existingKeys, converted);
|
|
}
|
|
logger.log('[setLocal] __g7SetLocalOverrideKeys updated:', converted);
|
|
|
|
// 4. actionContext가 있으면 컴포넌트 로컬 상태도 업데이트 (리액티비티용)
|
|
if (actionContext?.setState) {
|
|
actionContext.setState((currentLocal: Record<string, any>) => {
|
|
if (mergeMode === 'replace') return converted;
|
|
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
|
|
return deepMerge(currentLocal || {}, converted);
|
|
});
|
|
}
|
|
|
|
// templateApp이 없는 경우 경고
|
|
if (!templateApp?.setGlobalState) {
|
|
logger.warn('G7Core.state.setLocal: TemplateApp이 없습니다.');
|
|
}
|
|
},
|
|
/**
|
|
* 현재 컴포넌트 로컬 상태를 반환합니다.
|
|
*
|
|
* 커스텀 핸들러에서 현재 컴포넌트의 로컬 상태를 조회할 때 사용합니다.
|
|
*
|
|
* @returns 현재 로컬 상태 객체
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 현재 컴포넌트 로컬 상태
|
|
* const currentLocal = G7Core.state.getLocal();
|
|
* console.log(currentLocal.selectedItems);
|
|
* ```
|
|
*/
|
|
getLocal: (): Record<string, any> => {
|
|
const templateApp = (window as any).__templateApp;
|
|
|
|
// engine-v1.17.6: globalLocal + pendingState 2단계 병합
|
|
//
|
|
// 주의: componentState(actionContext.state)를 사용하면 안 됨!
|
|
// - actionContext는 클릭된 컴포넌트(예: ChipCheckbox)의 컨텍스트
|
|
// - ChipCheckbox의 state에는 form.label_assignments가 없거나 빈 배열
|
|
// - 이것이 globalLocal의 정확한 API 데이터를 덮어씀
|
|
//
|
|
// globalLocal은 _localInit에서 API 데이터가 적용된 후 동기화됨
|
|
// (DynamicRenderer의 _localInit effect가 setGlobalState({ _local: ... }) 호출)
|
|
//
|
|
// 참고: __g7CommittedLocalState(React 커밋 후 extendedDataContext._local)를
|
|
// 폴백으로 사용하면 다중 root-level DynamicRenderer(메인 + 모달) 환경에서
|
|
// 마지막으로 렌더된 모달의 _local이 메인 페이지 상태를 덮어쓰는 문제 발생.
|
|
// 비동기 콜백에서 최신 _local이 필요한 경우, 호출자가 직접
|
|
// handlerContext.state(버튼 클릭 시점의 componentContext.state)를 사용해야 함.
|
|
//
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// [모달 scope 제한사항] (Issue #29 분석, 2026-03-25)
|
|
//
|
|
// 현재 getLocal()은 항상 페이지의 globalLocal을 반환한다.
|
|
// modals 섹션의 isolated scope 모달 내부 커스텀 핸들러에서 호출해도
|
|
// 모달의 _local이 아닌 페이지의 _local을 읽는다.
|
|
//
|
|
// 반면 layout JSON의 `setState target: "local"`은 ActionDispatcher가
|
|
// context.setState를 사용하여 모달 DynamicRenderer의 state를 직접 업데이트한다.
|
|
//
|
|
// 이 비대칭으로 인해:
|
|
// - 모달 onMount에서 setState로 모달 scope에 데이터를 복사하면,
|
|
// 이후 핸들러의 getLocal()은 페이지 scope를 읽어 scope 단절 발생
|
|
// - setLocal()도 globalLocal에 쓰므로 모달 UI에 반영되지 않음
|
|
// (단, actionContext.setState 호출은 있어 컴포넌트 레벨 업데이트는 가능)
|
|
//
|
|
// 현재 우회책: 모달에서 onMount setState를 사용하지 않고,
|
|
// 모달 열기 전 핸들러에서 setLocal()로 페이지 _local에 초기화 후 modal.open().
|
|
// 이렇게 하면 모달이 페이지 _local을 그대로 상속하여 getLocal/setLocal과 일관.
|
|
// (사용자 취소 모달 _modal_cancel.json, 관리자 취소 모달 _modal_cancel_order.json)
|
|
//
|
|
// [향후 리팩토링 방향]
|
|
// getLocal()/setLocal()을 모달-인식(modal-aware)으로 개선:
|
|
// 1. __g7ActionContext에 모달 루트 DynamicRenderer의 state/setState 추가
|
|
// (현재는 클릭된 리프 컴포넌트의 state만 있어 부분적임)
|
|
// 2. getLocal()에서 __g7ActionContext의 모달 루트 state를 우선 반환
|
|
// 3. setLocal()에서 모달 루트 setState를 우선 호출
|
|
//
|
|
// 주의 사항 (회귀 위험):
|
|
// - __g7PendingLocalState가 scope 무인식 전역 싱글톤이라
|
|
// setLocal→getLocal→dispatch 체인에서 scope mismatch 발생 가능
|
|
// - troubleshooting 사례 5,7(pendingState), 사례 3(SPA navigate),
|
|
// global 사례 4(sequence→refetch) 등 기존 수정과 충돌 가능
|
|
// - actionContext.state가 리프 컴포넌트의 부분 상태인 문제 해결 필요
|
|
// (모달 루트 DynamicRenderer의 전체 _local state 접근 경로 확보)
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
const globalState = templateApp?.getGlobalState?.() || {};
|
|
const globalLocal = globalState._local || {};
|
|
|
|
// pendingState: setLocal() 호출 후 아직 React에 커밋되지 않은 업데이트
|
|
const pendingState = (window as any).__g7PendingLocalState;
|
|
if (pendingState) {
|
|
return deepMerge(globalLocal, pendingState);
|
|
}
|
|
|
|
// engine-v1.21.2: __g7LastSetLocalSnapshot fallback
|
|
// __g7PendingLocalState는 useLayoutEffect에서 매 렌더마다 null로 클리어됨.
|
|
// 이는 handleLocalSetState에서의 stale pendingState 오염을 방지하기 위해 필요하지만,
|
|
// await 경계에서 React 18 마이크로태스크 배칭이 렌더를 플러시하면
|
|
// pendingState가 사라져 getLocal()이 stale한 globalLocal만 반환하는 문제 발생.
|
|
//
|
|
// __g7LastSetLocalSnapshot은 setLocal()에서 함께 설정되지만 useLayoutEffect에서
|
|
// 클리어되지 않으므로, await 이후에도 최신 setLocal 값을 유지함.
|
|
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
|
|
// globalLocal과 동일 참조인 경우 이미 커밋된 것이므로 스킵.
|
|
const lastSnapshot = (window as any).__g7LastSetLocalSnapshot;
|
|
if (lastSnapshot && lastSnapshot !== globalLocal) {
|
|
return deepMerge(globalLocal, lastSnapshot);
|
|
}
|
|
|
|
return globalLocal;
|
|
},
|
|
/**
|
|
* 현재 상태를 기반으로 전역 상태를 업데이트합니다.
|
|
* React의 setState 함수형 업데이트와 유사한 패턴입니다.
|
|
* @param updater 현재 상태를 받아 새 상태를 반환하는 함수
|
|
*/
|
|
update: (updater: (prevState: Record<string, any>) => Record<string, any>) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.getGlobalState && templateApp?.setGlobalState) {
|
|
const currentState = templateApp.getGlobalState();
|
|
const newUpdates = updater(currentState);
|
|
templateApp.setGlobalState(newUpdates);
|
|
} else {
|
|
logger.warn('G7Core.state.update: TemplateApp이 초기화되지 않았습니다.');
|
|
}
|
|
},
|
|
/**
|
|
* 전역 상태 변경을 구독합니다.
|
|
* @param listener 상태 변경 시 호출될 콜백 함수
|
|
* @returns 구독 해제 함수
|
|
*/
|
|
subscribe: (listener: (state: Record<string, any>) => void) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.onGlobalStateChange) {
|
|
return templateApp.onGlobalStateChange(listener);
|
|
}
|
|
logger.warn('G7Core.state.subscribe: TemplateApp이 초기화되지 않았습니다.');
|
|
return () => {}; // no-op unsubscribe
|
|
},
|
|
/**
|
|
* 데이터 소스 값을 가져옵니다.
|
|
* @param dataSourceId 데이터 소스 ID (예: 'menus', 'users')
|
|
* @returns 데이터 소스 값 또는 undefined
|
|
*/
|
|
getDataSource: (dataSourceId: string) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
return templateApp?.getDataSource?.(dataSourceId);
|
|
},
|
|
/**
|
|
* 격리된 상태(isolated state)를 scopeId로 조회합니다.
|
|
*
|
|
* 커스텀 핸들러에서 특정 격리 스코프의 상태를 조회할 때 사용합니다.
|
|
* 액션 컨텍스트가 있는 경우 해당 컨텍스트의 isolatedContext를 우선 사용합니다.
|
|
*
|
|
* @param scopeId 격리 스코프 ID (선택, 미지정 시 현재 액션 컨텍스트의 격리 상태 반환)
|
|
* @returns 격리된 상태 객체 또는 null
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 현재 액션 컨텍스트의 격리 상태 조회
|
|
* const isolated = G7Core.state.getIsolated();
|
|
* console.log(isolated?.step);
|
|
*
|
|
* // 특정 scopeId로 조회 (레지스트리에서)
|
|
* const categoryState = G7Core.state.getIsolated('category-selector');
|
|
* console.log(categoryState?.selectedItems);
|
|
* ```
|
|
*/
|
|
getIsolated: (scopeId?: string): Record<string, any> | null => {
|
|
// 1. scopeId가 지정된 경우 레지스트리에서 조회
|
|
if (scopeId) {
|
|
const registry = (window as any).__g7IsolatedStates;
|
|
const scope = registry?.[scopeId];
|
|
return scope?.state ?? null;
|
|
}
|
|
|
|
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
|
|
const actionContext = (window as any).__g7ActionContext;
|
|
return actionContext?.isolatedContext?.state ?? null;
|
|
},
|
|
/**
|
|
* 격리된 상태(isolated state)를 업데이트합니다.
|
|
*
|
|
* 커스텀 핸들러에서 격리된 상태를 직접 업데이트할 때 사용합니다.
|
|
* scopeId가 지정된 경우 해당 스코프를, 미지정 시 현재 액션 컨텍스트의 격리 상태를 업데이트합니다.
|
|
*
|
|
* dot notation을 지원합니다.
|
|
* 예: `{ "form.email": "test@example.com" }` → `{ form: { email: "test@example.com" } }`
|
|
*
|
|
* @param scopeIdOrUpdates scopeId (문자열) 또는 업데이트 객체
|
|
* @param maybeUpdates scopeId가 지정된 경우 업데이트 객체
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 현재 액션 컨텍스트의 격리 상태 업데이트
|
|
* G7Core.state.setIsolated({ step: 2, selectedItem: item });
|
|
*
|
|
* // 특정 scopeId로 업데이트
|
|
* G7Core.state.setIsolated('category-selector', { step: 2 });
|
|
*
|
|
* // dot notation 지원
|
|
* G7Core.state.setIsolated({ "form.email": "test@example.com" });
|
|
* ```
|
|
*/
|
|
setIsolated: (
|
|
scopeIdOrUpdates: string | Record<string, any>,
|
|
maybeUpdates?: Record<string, any>,
|
|
options?: { merge?: 'replace' | 'shallow' | 'deep' }
|
|
): void => {
|
|
let scopeId: string | undefined;
|
|
let updates: Record<string, any>;
|
|
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
|
|
|
|
// 오버로드 처리: (updates) 또는 (scopeId, updates) 또는 (updates, options)
|
|
if (typeof scopeIdOrUpdates === 'string') {
|
|
scopeId = scopeIdOrUpdates;
|
|
updates = maybeUpdates || {};
|
|
mergeOpts = options;
|
|
} else {
|
|
updates = scopeIdOrUpdates;
|
|
// 두 번째 인자가 options인지 확인
|
|
mergeOpts = maybeUpdates && typeof maybeUpdates === 'object' && ('merge' in maybeUpdates) ? maybeUpdates as any : options;
|
|
}
|
|
const mergeMode: 'replace' | 'shallow' | 'deep' = mergeOpts?.merge || 'deep';
|
|
|
|
// dot notation을 중첩 객체로 변환
|
|
const converted = convertDotNotationToObject(updates);
|
|
|
|
// 1. scopeId가 지정된 경우 레지스트리에서 조회하여 업데이트
|
|
if (scopeId) {
|
|
const registry = (window as any).__g7IsolatedStates;
|
|
const scope = registry?.[scopeId];
|
|
if (scope?.mergeState) {
|
|
scope.mergeState(converted, mergeMode);
|
|
} else {
|
|
logger.warn(`G7Core.state.setIsolated: scopeId '${scopeId}'를 찾을 수 없습니다.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
|
|
const actionContext = (window as any).__g7ActionContext;
|
|
if (actionContext?.isolatedContext?.mergeState) {
|
|
actionContext.isolatedContext.mergeState(converted, mergeMode);
|
|
} else {
|
|
logger.warn('G7Core.state.setIsolated: 액션 컨텍스트에 isolatedContext가 없습니다.');
|
|
}
|
|
},
|
|
/**
|
|
* 부모 레이아웃의 컨텍스트를 반환합니다.
|
|
*
|
|
* 모달 등 자식 레이아웃에서 부모 레이아웃의 상태에 접근할 때 사용합니다.
|
|
* `__g7LayoutContextStack`에서 부모 컨텍스트를 가져옵니다.
|
|
*
|
|
* @returns 부모 컨텍스트 객체 또는 null
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 커스텀 핸들러에서 부모 상태 조회
|
|
* const parentContext = G7Core.state.getParent();
|
|
* if (parentContext) {
|
|
* const parentLocal = parentContext._local;
|
|
* const parentGlobal = parentContext._global;
|
|
* console.log('부모 로컬 상태:', parentLocal);
|
|
* }
|
|
* ```
|
|
*
|
|
* @since engine-v1.16.0
|
|
*/
|
|
getParent: (): { _local: Record<string, any>; _global: Record<string, any>; setState: (updates: any) => void } | null => {
|
|
const contextStack: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
|
|
if (contextStack.length === 0) {
|
|
logger.log('[getParent] 레이아웃 컨텍스트 스택이 비어있습니다.');
|
|
return null;
|
|
}
|
|
|
|
// 스택의 마지막 항목이 부모 컨텍스트
|
|
const parentEntry = contextStack[contextStack.length - 1];
|
|
if (!parentEntry) {
|
|
return null;
|
|
}
|
|
|
|
// dataContext가 있으면 $parent 바인딩에서 저장한 확장 컨텍스트 사용
|
|
const parentData = parentEntry.dataContext || {};
|
|
|
|
return {
|
|
_local: parentEntry.state || parentData._local || {},
|
|
_global: parentData._global || G7Core.state.get() || {},
|
|
setState: parentEntry.setState,
|
|
};
|
|
},
|
|
/**
|
|
* 부모 레이아웃의 로컬 상태(_local)를 업데이트합니다.
|
|
*
|
|
* 모달 등 자식 레이아웃에서 부모 레이아웃의 로컬 상태를 직접 수정할 때 사용합니다.
|
|
*
|
|
* dot notation을 지원합니다.
|
|
* 예: `{ "form.name": "value" }` → `{ form: { name: "value" } }`
|
|
*
|
|
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
|
|
* @param maybeValue 경로가 지정된 경우 설정할 값
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 객체로 업데이트
|
|
* G7Core.state.setParentLocal({
|
|
* 'form.label_assignments': updatedAssignments,
|
|
* hasChanges: true,
|
|
* });
|
|
*
|
|
* // 경로와 값으로 업데이트
|
|
* G7Core.state.setParentLocal('form.name', 'newValue');
|
|
* ```
|
|
*
|
|
* @since engine-v1.16.0
|
|
*/
|
|
setParentLocal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any, options?: { merge?: 'replace' | 'shallow' | 'deep' }): void => {
|
|
const contextStack: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
|
|
if (contextStack.length === 0) {
|
|
logger.warn('[setParentLocal] 레이아웃 컨텍스트 스택이 비어있습니다.');
|
|
return;
|
|
}
|
|
|
|
const parentEntry = contextStack[contextStack.length - 1];
|
|
if (!parentEntry?.setState) {
|
|
logger.warn('[setParentLocal] 부모 컨텍스트에 setState가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
let updates: Record<string, any>;
|
|
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
|
|
if (typeof pathOrUpdates === 'string') {
|
|
// 경로와 값으로 호출된 경우
|
|
updates = { [pathOrUpdates]: maybeValue };
|
|
mergeOpts = options;
|
|
} else {
|
|
updates = pathOrUpdates;
|
|
// 객체로 호출 시 두 번째 인자가 options일 수 있음
|
|
mergeOpts = maybeValue && typeof maybeValue === 'object' && ('merge' in maybeValue) ? maybeValue : options;
|
|
}
|
|
const mergeMode = mergeOpts?.merge || 'deep';
|
|
|
|
// dot notation을 중첩 객체로 변환
|
|
const converted = convertDotNotationToObject(updates);
|
|
|
|
// parentEntry.state가 전체 데이터 컨텍스트(_local 포함)인지 확인
|
|
// _local이 있으면 그 안의 상태를 업데이트, 없으면 state 자체가 로컬 상태
|
|
const hasNestedLocal = parentEntry.state?._local !== undefined;
|
|
const currentLocal = hasNestedLocal ? parentEntry.state._local : (parentEntry.state || {});
|
|
let merged: Record<string, any>;
|
|
if (mergeMode === 'replace') {
|
|
merged = converted;
|
|
} else if (mergeMode === 'shallow') {
|
|
merged = { ...currentLocal, ...converted };
|
|
} else {
|
|
merged = deepMerge(currentLocal, converted);
|
|
}
|
|
|
|
// CRITICAL: __g7PendingLocalState를 setState 호출 전에 설정해야 함!
|
|
// React가 setState 배치를 처리할 때 handleLocalSetState 콜백이 실행되는데,
|
|
// 이 콜백에서 __g7PendingLocalState를 읽어 최신 상태를 effectivePrev로 사용함.
|
|
// 따라서 setState 호출 전에 설정해야 콜백이 최신 값을 읽을 수 있음.
|
|
(window as any).__g7PendingLocalState = merged;
|
|
|
|
// engine-v1.17.12: 전역 _local 상태도 업데이트 (setLocal과 동일)
|
|
// setParentLocal은 parentEntry.setState()로 React 상태를 업데이트하지만,
|
|
// getLocal()은 templateApp.getGlobalState()._local에서 읽음.
|
|
// __g7PendingLocalState가 렌더 후 클리어되면 getLocal()이 stale 데이터를 반환하므로,
|
|
// globalState._local도 함께 업데이트해야 후속 getLocal() 호출이 최신 값을 반환함.
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.setGlobalState) {
|
|
templateApp.setGlobalState({ _local: merged });
|
|
}
|
|
|
|
parentEntry.setState(merged);
|
|
|
|
// 스택의 state와 dataContext._local 모두 업데이트하여
|
|
// 다음 호출 시 currentLocal이 최신 값을 읽고,
|
|
// 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함
|
|
if (hasNestedLocal) {
|
|
parentEntry.state._local = merged;
|
|
} else {
|
|
parentEntry.state = merged;
|
|
}
|
|
if (parentEntry.dataContext) {
|
|
parentEntry.dataContext._local = merged;
|
|
}
|
|
|
|
// __g7ForcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
|
|
// setParentLocal로 업데이트된 필드가 Form 자동 바인딩에 의해 덮어쓰이지 않도록 함
|
|
if (mergeMode === 'replace') {
|
|
(window as any).__g7ForcedLocalFields = converted;
|
|
} else {
|
|
const existingForced = (window as any).__g7ForcedLocalFields || {};
|
|
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
|
|
}
|
|
|
|
// ParentContextProvider를 통해 모달만 선택적으로 리렌더링
|
|
// 전체 앱 리렌더링 없이 모달만 업데이트됨
|
|
triggerModalParentUpdate();
|
|
},
|
|
/**
|
|
* 부모 레이아웃의 전역 상태(_global)를 업데이트합니다.
|
|
*
|
|
* 모달 등 자식 레이아웃에서 전역 상태를 직접 수정할 때 사용합니다.
|
|
* 참고: 전역 상태는 앱 전체에서 공유되므로, 부모/자식 구분 없이
|
|
* G7Core.state.set()과 동일하게 동작합니다. 이 메서드는 의미적 명확성을 위해 제공됩니다.
|
|
*
|
|
* dot notation을 지원합니다.
|
|
*
|
|
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
|
|
* @param maybeValue 경로가 지정된 경우 설정할 값
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 객체로 업데이트
|
|
* G7Core.state.setParentGlobal({
|
|
* modalResult: { confirmed: true, data: selectedItems },
|
|
* });
|
|
*
|
|
* // 경로와 값으로 업데이트
|
|
* G7Core.state.setParentGlobal('modalResult.confirmed', true);
|
|
* ```
|
|
*
|
|
* @since engine-v1.16.0
|
|
*/
|
|
setParentGlobal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any): void => {
|
|
let updates: Record<string, any>;
|
|
if (typeof pathOrUpdates === 'string') {
|
|
updates = { [pathOrUpdates]: maybeValue };
|
|
} else {
|
|
updates = pathOrUpdates;
|
|
}
|
|
|
|
// 전역 상태는 앱 전체에서 공유되므로 G7Core.state.set() 사용
|
|
G7Core.state.set(updates);
|
|
logger.log('[setParentGlobal] 전역 상태 업데이트:', updates);
|
|
},
|
|
};
|
|
|
|
// 격리 상태 레지스트리 초기화
|
|
if (!(window as any).__g7IsolatedStates) {
|
|
(window as any).__g7IsolatedStates = {};
|
|
}
|
|
|
|
// 별칭 메서드 추가 (하위 호환성)
|
|
G7Core.state.getGlobal = G7Core.state.get;
|
|
G7Core.state.setGlobal = G7Core.state.set;
|
|
|
|
logger.log('전역 객체 window.G7Core.state에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 관리 API 초기화
|
|
*
|
|
* 데이터 소스의 조회, 설정, refetch 등을 관리합니다.
|
|
*/
|
|
function initDataSourceAPI(G7Core: any): void {
|
|
G7Core.dataSource = {
|
|
/**
|
|
* 데이터 소스 값을 가져옵니다.
|
|
* @param dataSourceId 데이터 소스 ID
|
|
* @returns 데이터 소스 값 또는 undefined
|
|
*/
|
|
get: (dataSourceId: string) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
return templateApp?.getDataSource?.(dataSourceId);
|
|
},
|
|
/**
|
|
* 데이터 소스 값을 설정하고 UI를 리렌더링합니다.
|
|
*
|
|
* 서버 refetch 없이 클라이언트 측에서 데이터 소스를 직접 업데이트할 때 사용합니다.
|
|
*
|
|
* @param dataSourceId 데이터 소스 ID
|
|
* @param data 설정할 데이터
|
|
* @param options 옵션
|
|
* @param options.merge true면 기존 데이터와 병합 (기본값: false)
|
|
* @param options.sync true면 동기 업데이트 (기본값: false)
|
|
*
|
|
* @example
|
|
* // 데이터 전체 교체
|
|
* G7Core.dataSource.set('products', { data: updatedProducts, meta: {...} });
|
|
*
|
|
* // 기존 데이터와 병합
|
|
* G7Core.dataSource.set('products', { data: updatedProducts }, { merge: true });
|
|
*/
|
|
set: (
|
|
dataSourceId: string,
|
|
data: any,
|
|
options?: { merge?: boolean; sync?: boolean }
|
|
) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.setDataSource) {
|
|
templateApp.setDataSource(dataSourceId, data, options);
|
|
} else {
|
|
logger.warn('G7Core.dataSource.set: TemplateApp이 초기화되지 않았습니다.');
|
|
}
|
|
},
|
|
/**
|
|
* 데이터 소스를 서버에서 다시 가져옵니다.
|
|
* @param dataSourceId 데이터 소스 ID
|
|
* @param options 옵션
|
|
* @returns refetch 결과 데이터
|
|
*/
|
|
refetch: async (dataSourceId: string, options?: { skipCache?: boolean; sync?: boolean }) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.refetchDataSource) {
|
|
return templateApp.refetchDataSource(dataSourceId, options);
|
|
}
|
|
logger.warn('G7Core.dataSource.refetch: TemplateApp이 초기화되지 않았습니다.');
|
|
return undefined;
|
|
},
|
|
|
|
/**
|
|
* 데이터 소스 내 특정 배열 아이템만 업데이트합니다.
|
|
*
|
|
* 전체 데이터소스를 교체하지 않고 특정 아이템만 수정하여
|
|
* 불필요한 리렌더링을 방지합니다.
|
|
*
|
|
* @param dataSourceId 데이터 소스 ID
|
|
* @param itemPath 배열 경로 (예: "data.data", "data.data[0].options")
|
|
* @param itemId 업데이트할 아이템의 ID
|
|
* @param updates 업데이트할 필드들
|
|
* @param options 옵션
|
|
* @returns 성공 여부
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // 상품 옵션 업데이트
|
|
* G7Core.dataSource.updateItem(
|
|
* 'products',
|
|
* 'data.data[0].options',
|
|
* 123, // optionId
|
|
* { selling_price: 15000, _modified: true }
|
|
* );
|
|
*
|
|
* // 사용자 정보 업데이트 (커스텀 ID 필드 사용)
|
|
* G7Core.dataSource.updateItem(
|
|
* 'users',
|
|
* 'data',
|
|
* 'user-456',
|
|
* { name: '홍길동' },
|
|
* { idField: 'uuid' }
|
|
* );
|
|
* ```
|
|
*/
|
|
updateItem: (
|
|
dataSourceId: string,
|
|
itemPath: string,
|
|
itemId: string | number,
|
|
updates: Record<string, any>,
|
|
options?: {
|
|
/** ID 필드명 (기본: "id") */
|
|
idField?: string;
|
|
/** 깊은 병합 여부 (기본: true) */
|
|
merge?: boolean;
|
|
/** 렌더링 스킵 여부 (기본: false) */
|
|
skipRender?: boolean;
|
|
}
|
|
): boolean => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.updateDataSourceItem) {
|
|
return templateApp.updateDataSourceItem(dataSourceId, itemPath, itemId, updates, options);
|
|
}
|
|
logger.warn('G7Core.dataSource.updateItem: TemplateApp이 초기화되지 않았습니다.');
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* 데이터 소스 내 특정 경로의 배열에 새 데이터를 병합합니다.
|
|
*
|
|
* 무한 스크롤 등에서 기존 데이터에 새 데이터를 추가할 때 사용합니다.
|
|
*
|
|
* @param dataSourceId 데이터 소스 ID
|
|
* @param dataPath 배열 경로 (예: "data", "data.items"). null이면 루트 배열
|
|
* @param newData 추가할 새 데이터 (배열)
|
|
* @param mode 병합 모드 - 'append': 끝에 추가, 'prepend': 앞에 추가 (기본: 'append')
|
|
* @returns 성공 여부
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // 무한 스크롤: 기존 목록 뒤에 새 데이터 추가
|
|
* G7Core.dataSource.updateData('templates', 'data', newTemplates, 'append');
|
|
*
|
|
* // 새로고침: 새 데이터를 앞에 추가
|
|
* G7Core.dataSource.updateData('notifications', 'data', newNotifications, 'prepend');
|
|
* ```
|
|
*/
|
|
updateData: (
|
|
dataSourceId: string,
|
|
dataPath: string | null,
|
|
newData: any[],
|
|
mode: 'append' | 'prepend' = 'append'
|
|
): boolean => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (!templateApp?.getDataSource || !templateApp?.setDataSource) {
|
|
logger.warn('G7Core.dataSource.updateData: TemplateApp이 초기화되지 않았습니다.');
|
|
return false;
|
|
}
|
|
|
|
// 현재 데이터 소스 가져오기
|
|
const currentDataSource = templateApp.getDataSource(dataSourceId);
|
|
if (!currentDataSource) {
|
|
logger.warn(`G7Core.dataSource.updateData: 데이터 소스 '${dataSourceId}'를 찾을 수 없습니다.`);
|
|
return false;
|
|
}
|
|
|
|
// 경로에서 대상 배열 가져오기
|
|
let targetArray: any[];
|
|
let parentObj: any = currentDataSource;
|
|
let lastKey: string | null = null;
|
|
|
|
if (dataPath) {
|
|
const pathParts = dataPath.split('.');
|
|
lastKey = pathParts.pop()!;
|
|
|
|
for (const part of pathParts) {
|
|
if (parentObj && typeof parentObj === 'object' && part in parentObj) {
|
|
parentObj = parentObj[part];
|
|
} else {
|
|
logger.warn(`G7Core.dataSource.updateData: 경로 '${dataPath}'를 찾을 수 없습니다.`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
targetArray = parentObj[lastKey];
|
|
} else {
|
|
targetArray = currentDataSource;
|
|
}
|
|
|
|
if (!Array.isArray(targetArray)) {
|
|
logger.warn(`G7Core.dataSource.updateData: 대상 경로의 데이터가 배열이 아닙니다.`);
|
|
return false;
|
|
}
|
|
|
|
if (!Array.isArray(newData)) {
|
|
logger.warn(`G7Core.dataSource.updateData: newData가 배열이 아닙니다.`);
|
|
return false;
|
|
}
|
|
|
|
// 배열 병합
|
|
const mergedArray = mode === 'prepend'
|
|
? [...newData, ...targetArray]
|
|
: [...targetArray, ...newData];
|
|
|
|
// 업데이트된 데이터 설정
|
|
if (dataPath && lastKey) {
|
|
parentObj[lastKey] = mergedArray;
|
|
templateApp.setDataSource(dataSourceId, currentDataSource, { merge: false });
|
|
} else {
|
|
templateApp.setDataSource(dataSourceId, mergedArray, { merge: false });
|
|
}
|
|
|
|
logger.log(`G7Core.dataSource.updateData: '${dataSourceId}'에 ${newData.length}개 항목 ${mode === 'prepend' ? '앞에' : '뒤에'} 추가됨`);
|
|
return true;
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.dataSource에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 로케일 관리 API 초기화
|
|
*/
|
|
function initLocaleAPI(G7Core: any, deps: G7CoreDependencies): void {
|
|
G7Core.locale = {
|
|
/**
|
|
* 현재 로케일을 반환합니다.
|
|
*/
|
|
current: () => {
|
|
const templateApp = (window as any).__templateApp;
|
|
return templateApp?.getLocale?.() || 'ko';
|
|
},
|
|
/**
|
|
* 지원하는 로케일 목록을 반환합니다.
|
|
*/
|
|
supported: () => {
|
|
const state = deps.getState();
|
|
return state.templateMetadata?.locales || ['ko', 'en'];
|
|
},
|
|
/**
|
|
* 로케일을 변경합니다.
|
|
* @param locale 변경할 로케일 (예: 'ko', 'en')
|
|
*/
|
|
change: async (locale: string) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.changeLocale) {
|
|
await templateApp.changeLocale(locale);
|
|
} else {
|
|
logger.warn('G7Core.locale.change: TemplateApp이 초기화되지 않았습니다.');
|
|
}
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.locale에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 토스트 알림 API 초기화
|
|
*/
|
|
function initToastAPI(G7Core: any): void {
|
|
G7Core.toast = {
|
|
/**
|
|
* 토스트 알림을 표시합니다.
|
|
* @param message 표시할 메시지
|
|
* @param options type: 'success'|'error'|'warning'|'info', duration: ms
|
|
*/
|
|
show: (message: string, options?: { type?: 'success' | 'error' | 'warning' | 'info'; duration?: number }) => {
|
|
G7Core.dispatch({
|
|
handler: 'toast',
|
|
params: {
|
|
message,
|
|
type: options?.type || 'info',
|
|
...(options?.duration && { duration: options.duration }),
|
|
},
|
|
});
|
|
},
|
|
success: (message: string, duration?: number) => {
|
|
G7Core.toast.show(message, { type: 'success', duration });
|
|
},
|
|
error: (message: string, duration?: number) => {
|
|
G7Core.toast.show(message, { type: 'error', duration });
|
|
},
|
|
warning: (message: string, duration?: number) => {
|
|
G7Core.toast.show(message, { type: 'warning', duration });
|
|
},
|
|
info: (message: string, duration?: number) => {
|
|
G7Core.toast.show(message, { type: 'info', duration });
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.toast에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 모달 관리 API 초기화
|
|
*/
|
|
function initModalAPI(G7Core: any): void {
|
|
G7Core.modal = {
|
|
/**
|
|
* 모달을 엽니다.
|
|
* @param modalId 열 모달의 ID
|
|
*/
|
|
open: (modalId: string) => {
|
|
G7Core.dispatch({
|
|
handler: 'openModal',
|
|
target: modalId,
|
|
});
|
|
},
|
|
/**
|
|
* 모달을 닫습니다.
|
|
* @param modalId 닫을 모달의 ID (생략 시 최상위 모달 닫기)
|
|
*/
|
|
close: (modalId?: string) => {
|
|
G7Core.dispatch({
|
|
handler: 'closeModal',
|
|
...(modalId && { target: modalId }),
|
|
});
|
|
},
|
|
/**
|
|
* 모든 모달을 닫습니다.
|
|
*/
|
|
closeAll: () => {
|
|
G7Core.dispatch({
|
|
handler: 'closeAllModals',
|
|
});
|
|
},
|
|
/**
|
|
* 특정 모달이 열려있는지 확인합니다.
|
|
* @param modalId 확인할 모달의 ID
|
|
*/
|
|
isOpen: (modalId: string) => {
|
|
const state = G7Core.state.get();
|
|
return state._global?.activeModal === modalId ||
|
|
state._global?.modalStack?.includes(modalId) ||
|
|
false;
|
|
},
|
|
/**
|
|
* 현재 열린 모달 스택을 반환합니다.
|
|
*/
|
|
getStack: () => {
|
|
const state = G7Core.state.get();
|
|
return state._global?.modalStack || [];
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.modal에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 스타일 헬퍼 API 초기화
|
|
*/
|
|
function initStyleAPI(G7Core: any): void {
|
|
G7Core.style = {
|
|
/**
|
|
* Tailwind CSS 클래스를 런타임에서 병합합니다.
|
|
* 같은 CSS 속성을 제어하는 클래스가 충돌하면 override 클래스를 우선 적용합니다.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* G7Core.style.mergeClasses('justify-center items-center', 'justify-between')
|
|
* // 결과: 'items-center justify-between'
|
|
* ```
|
|
*/
|
|
mergeClasses,
|
|
/**
|
|
* 조건에 따라 클래스를 적용합니다.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* G7Core.style.conditionalClass({
|
|
* 'bg-blue-500': isPrimary,
|
|
* 'opacity-50': isDisabled,
|
|
* })
|
|
* ```
|
|
*/
|
|
conditionalClass,
|
|
/**
|
|
* 여러 클래스 문자열을 하나로 합칩니다.
|
|
* falsy 값은 무시됩니다.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* G7Core.style.joinClasses('flex', isActive && 'bg-blue-500', 'p-4')
|
|
* ```
|
|
*/
|
|
joinClasses,
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.style에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* WebSocket 관리 API 초기화
|
|
*/
|
|
function initWebSocketAPI(G7Core: any, deps: G7CoreDependencies): void {
|
|
const { webSocketManager } = deps;
|
|
|
|
G7Core.websocket = {
|
|
/**
|
|
* WebSocketManager 인스턴스를 반환합니다.
|
|
*/
|
|
manager: webSocketManager,
|
|
/**
|
|
* WebSocket 채널을 구독합니다.
|
|
* @param channel 채널명 (예: 'admin.dashboard')
|
|
* @param event 이벤트명 (예: 'dashboard.stats.updated')
|
|
* @param callback 이벤트 수신 시 호출될 콜백
|
|
* @param options 구독 옵션 { channelType: 'public' | 'private' | 'presence' }
|
|
* @returns 구독 키 (구독 해제 시 사용)
|
|
*/
|
|
subscribe: (
|
|
channel: string,
|
|
event: string,
|
|
callback: (data: unknown) => void,
|
|
options?: { channelType?: 'public' | 'private' | 'presence' }
|
|
) => webSocketManager.subscribe(channel, event, callback, options),
|
|
/**
|
|
* WebSocket 구독을 해제합니다.
|
|
* @param subscriptionKey 구독 키
|
|
*/
|
|
unsubscribe: (subscriptionKey: string) => webSocketManager.unsubscribe(subscriptionKey),
|
|
/**
|
|
* 특정 채널의 모든 구독을 해제합니다.
|
|
* @param channel 채널명
|
|
*/
|
|
leaveChannel: (channel: string) => webSocketManager.leaveChannel(channel),
|
|
/**
|
|
* 모든 WebSocket 연결을 종료합니다.
|
|
*/
|
|
disconnect: () => webSocketManager.disconnect(),
|
|
/**
|
|
* WebSocket 초기화 여부를 반환합니다.
|
|
*/
|
|
isInitialized: () => webSocketManager.isInitialized(),
|
|
/**
|
|
* 현재 활성 구독 수를 반환합니다.
|
|
*/
|
|
getSubscriptionCount: () => webSocketManager.getSubscriptionCount(),
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.websocket에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 API 초기화
|
|
*/
|
|
function initNavigationAPI(G7Core: any, deps: G7CoreDependencies): void {
|
|
const { transitionManager } = deps;
|
|
|
|
G7Core.navigation = {
|
|
/**
|
|
* 현재 페이지 전환이 진행 중인지 확인합니다.
|
|
* @returns 전환 진행 중이면 true
|
|
*/
|
|
isPending: () => {
|
|
return transitionManager?.getIsPending?.() || false;
|
|
},
|
|
/**
|
|
* 다음 페이지 전환이 완료되면 콜백을 실행합니다.
|
|
* 전환이 시작(isPending=true)된 후 완료(isPending=false)될 때 콜백을 실행합니다.
|
|
* @param callback 전환 완료 시 실행할 콜백
|
|
* @returns 구독 해제 함수
|
|
*/
|
|
onComplete: (callback: () => void) => {
|
|
if (!transitionManager) {
|
|
// TransitionManager가 없으면 즉시 실행
|
|
callback();
|
|
return () => {};
|
|
}
|
|
|
|
// 전환 시작 여부 추적
|
|
let transitionStarted = transitionManager.getIsPending();
|
|
|
|
// 전환 상태 변화 감지 (true → false 순서로 감지되면 콜백 실행)
|
|
const unsubscribe = transitionManager.subscribe((isPending: boolean) => {
|
|
if (isPending) {
|
|
// 전환 시작됨
|
|
transitionStarted = true;
|
|
} else if (transitionStarted) {
|
|
// 전환이 시작된 후 완료됨
|
|
callback();
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.navigation에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 플러그인 설정 접근 API 초기화
|
|
*
|
|
* 활성화된 플러그인의 환경설정 값에 접근합니다.
|
|
* admin.blade.php에서 G7Config.plugins로 주입된 값을 사용합니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 플러그인 전체 설정 조회
|
|
* const daumSettings = G7Core.plugin.getSettings('sirsoft-daum_postcode');
|
|
* // { display_mode: 'layer', popup_width: 900, ... }
|
|
*
|
|
* // 특정 설정 값 조회
|
|
* const displayMode = G7Core.plugin.get('sirsoft-daum_postcode', 'display_mode');
|
|
* // 'layer'
|
|
*
|
|
* // 기본값과 함께 조회
|
|
* const width = G7Core.plugin.get('sirsoft-daum_postcode', 'popup_width', 500);
|
|
* ```
|
|
*/
|
|
function initPluginAPI(G7Core: any): void {
|
|
G7Core.plugin = {
|
|
/**
|
|
* 플러그인의 전체 설정을 조회합니다.
|
|
* @param identifier 플러그인 식별자
|
|
* @returns 플러그인 설정 객체 또는 undefined
|
|
*/
|
|
getSettings: (identifier: string): Record<string, any> | undefined => {
|
|
return (window as any).G7Config?.plugins?.[identifier];
|
|
},
|
|
/**
|
|
* 플러그인의 특정 설정 값을 조회합니다.
|
|
* @param identifier 플러그인 식별자
|
|
* @param key 설정 키
|
|
* @param defaultValue 기본값
|
|
* @returns 설정 값 또는 기본값
|
|
*/
|
|
get: (identifier: string, key: string, defaultValue?: any): any => {
|
|
const settings = (window as any).G7Config?.plugins?.[identifier];
|
|
return settings?.[key] ?? defaultValue;
|
|
},
|
|
/**
|
|
* 모든 활성화된 플러그인 설정을 조회합니다.
|
|
* @returns 플러그인 식별자를 키로 하는 설정 객체
|
|
*/
|
|
getAll: (): Record<string, Record<string, any>> => {
|
|
return (window as any).G7Config?.plugins ?? {};
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.plugin에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 접근 API 초기화
|
|
*
|
|
* 활성화된 모듈의 환경설정 값에 접근합니다.
|
|
* admin.blade.php에서 G7Config.modules로 주입된 값을 사용합니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 모듈 전체 설정 조회
|
|
* const ecommerceSettings = G7Core.module.getSettings('sirsoft-ecommerce');
|
|
*
|
|
* // 특정 설정 값 조회
|
|
* const currency = G7Core.module.get('sirsoft-ecommerce', 'default_currency', 'KRW');
|
|
* ```
|
|
*/
|
|
function initModuleAPI(G7Core: any): void {
|
|
G7Core.module = {
|
|
/**
|
|
* 모듈의 전체 설정을 조회합니다.
|
|
* @param identifier 모듈 식별자
|
|
* @returns 모듈 설정 객체 또는 undefined
|
|
*/
|
|
getSettings: (identifier: string): Record<string, any> | undefined => {
|
|
return (window as any).G7Config?.modules?.[identifier];
|
|
},
|
|
/**
|
|
* 모듈의 특정 설정 값을 조회합니다.
|
|
* @param identifier 모듈 식별자
|
|
* @param key 설정 키
|
|
* @param defaultValue 기본값
|
|
* @returns 설정 값 또는 기본값
|
|
*/
|
|
get: (identifier: string, key: string, defaultValue?: any): any => {
|
|
const settings = (window as any).G7Config?.modules?.[identifier];
|
|
return settings?.[key] ?? defaultValue;
|
|
},
|
|
/**
|
|
* 모든 활성화된 모듈 설정을 조회합니다.
|
|
* @returns 모듈 식별자를 키로 하는 설정 객체
|
|
*/
|
|
getAll: (): Record<string, Record<string, any>> => {
|
|
return (window as any).G7Config?.modules ?? {};
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.module에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 코어 API 초기화 (AuthManager, ApiClient, 훅 등)
|
|
*/
|
|
function initCoreAPIs(G7Core: any, deps: G7CoreDependencies): void {
|
|
const { transitionManager, responsiveManager } = deps;
|
|
|
|
// AuthManager 노출 (템플릿 컴포넌트에서 사용자 정보 접근용)
|
|
G7Core.AuthManager = AuthManager;
|
|
|
|
// ApiClient 노출 (템플릿 컴포넌트에서 API 호출용)
|
|
G7Core.api = getApiClient();
|
|
|
|
// TransitionManager 노출 (템플릿 컴포넌트에서 페이지 전환 상태 접근용)
|
|
// 동기적으로 노출하여 컴포넌트 마운트 시점에 바로 사용 가능하도록 함
|
|
G7Core.TransitionManager = transitionManager;
|
|
|
|
// useTransitionState 훅 노출 (템플릿 컴포넌트에서 전환 상태 구독용)
|
|
G7Core.useTransitionState = useTransitionState;
|
|
|
|
// ResponsiveManager 노출 (템플릿 컴포넌트에서 반응형 상태 접근용)
|
|
G7Core.ResponsiveManager = responsiveManager;
|
|
|
|
// useResponsive 훅 노출 (템플릿 컴포넌트에서 반응형 상태 구독용)
|
|
G7Core.useResponsive = useResponsive;
|
|
|
|
// useControllableState 훅 노출 (Phase 1-4)
|
|
// Controlled/Uncontrolled 상태 패턴을 단순화하는 훅
|
|
G7Core.useControllableState = useControllableState;
|
|
G7Core.shallowArrayEqual = shallowArrayEqual;
|
|
G7Core.shallowObjectEqual = shallowObjectEqual;
|
|
|
|
// updateQueryParams 노출 (같은 페이지 내 쿼리 변경 시 컴포넌트 리마운트 없이 데이터 갱신)
|
|
// navigate 핸들러에서 replace: true 사용 시 자동 호출됨
|
|
// options.transitionOverlayTarget: 호출별 transition_overlay.target 동적 override (@since engine-v1.36.0)
|
|
G7Core.updateQueryParams = async (
|
|
newPath: string,
|
|
options?: { transitionOverlayTarget?: string }
|
|
) => {
|
|
const templateApp = (window as any).__templateApp;
|
|
if (templateApp?.updateQueryParams) {
|
|
return templateApp.updateQueryParams(newPath, options);
|
|
}
|
|
logger.warn('G7Core.updateQueryParams: TemplateApp이 초기화되지 않았습니다.');
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core.AuthManager에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.api에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.ResponsiveManager에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.useResponsive에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.useControllableState에 노출됨');
|
|
logger.log('전역 객체 window.G7Core.updateQueryParams에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* 슬롯 시스템 API 초기화
|
|
*
|
|
* SlotContext와 DynamicRenderer를 외부(템플릿 컴포넌트)에서 접근할 수 있도록 합니다.
|
|
* SlotContainer 컴포넌트에서 이 API를 사용하여 슬롯에 등록된 컴포넌트를 렌더링합니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // SlotContainer.tsx에서 사용
|
|
* const slotContext = G7Core.getSlotContext();
|
|
* const DynamicRenderer = G7Core.getDynamicRenderer();
|
|
* const registry = G7Core.getComponentRegistry();
|
|
* ```
|
|
*/
|
|
function initSlotAPI(G7Core: any, deps: G7CoreDependencies): void {
|
|
/**
|
|
* SlotContext 값을 가져옵니다.
|
|
* SlotProvider 내부에서만 유효한 값을 반환합니다.
|
|
*
|
|
* SlotProvider가 마운트되면 window.__slotContextValue에 값이 설정됩니다.
|
|
*/
|
|
G7Core.getSlotContext = () => {
|
|
// SlotProvider에서 설정한 전역 값 참조
|
|
return (window as any).__slotContextValue ?? null;
|
|
};
|
|
|
|
/**
|
|
* DynamicRenderer 컴포넌트를 가져옵니다.
|
|
* SlotContainer에서 슬롯에 등록된 컴포넌트를 렌더링할 때 사용합니다.
|
|
*
|
|
* DynamicRenderer가 로드되면 window.__DynamicRenderer에 값이 설정됩니다.
|
|
*/
|
|
G7Core.getDynamicRenderer = () => {
|
|
// DynamicRenderer 모듈에서 설정한 전역 값 참조
|
|
return (window as any).__DynamicRenderer ?? null;
|
|
};
|
|
|
|
/**
|
|
* ComponentRegistry를 가져옵니다.
|
|
*/
|
|
G7Core.getComponentRegistry = () => {
|
|
return deps.getComponentRegistry?.() ?? ComponentRegistry.getInstance();
|
|
};
|
|
|
|
/**
|
|
* DataBindingEngine을 가져옵니다.
|
|
*/
|
|
G7Core.getDataBindingEngine = () => {
|
|
return deps.getDataBindingEngine?.() ?? deps.getState().bindingEngine;
|
|
};
|
|
|
|
/**
|
|
* TranslationEngine을 가져옵니다.
|
|
*/
|
|
G7Core.getTranslationEngine = () => {
|
|
return deps.getTranslationEngine?.() ?? deps.getState().translationEngine;
|
|
};
|
|
|
|
/**
|
|
* ActionDispatcher를 가져옵니다.
|
|
*/
|
|
G7Core.getActionDispatcher = () => {
|
|
return deps.getActionDispatcher?.() ?? deps.getState().actionDispatcher;
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core 슬롯 API에 노출됨 (getSlotContext, getDynamicRenderer 등)');
|
|
}
|
|
|
|
/**
|
|
* 위지윅 편집기 API 초기화
|
|
*
|
|
* 위지윅 레이아웃 편집기 관련 전역 API를 window.G7Core에 노출합니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 편집 모드 확인
|
|
* if (G7Core.wysiwyg.isEditMode()) {
|
|
* // 편집 모드 전용 로직
|
|
* }
|
|
*
|
|
* // 편집 모드로 진입
|
|
* G7Core.wysiwyg.enterEditMode('home', 'sirsoft-basic');
|
|
*
|
|
* // 편집 모드 종료
|
|
* G7Core.wysiwyg.exitEditMode();
|
|
* ```
|
|
*/
|
|
function initWysiwygEditorAPI(G7Core: any): void {
|
|
// 편집 모드 상태 (전역)
|
|
let editModeEnabled = false;
|
|
let currentLayoutName: string | null = null;
|
|
let currentTemplateId: string | null = null;
|
|
|
|
G7Core.wysiwyg = {
|
|
/**
|
|
* 현재 편집 모드 여부를 반환합니다.
|
|
*
|
|
* @returns boolean 편집 모드 여부
|
|
*/
|
|
isEditMode: (): boolean => {
|
|
return editModeEnabled;
|
|
},
|
|
|
|
/**
|
|
* 편집 모드를 활성화합니다.
|
|
*
|
|
* @param layoutName 편집할 레이아웃명
|
|
* @param templateId 템플릿 ID
|
|
*/
|
|
setEditMode: (layoutName: string, templateId: string): void => {
|
|
editModeEnabled = true;
|
|
currentLayoutName = layoutName;
|
|
currentTemplateId = templateId;
|
|
logger.log(`위지윅 편집 모드 활성화: ${layoutName} (${templateId})`);
|
|
},
|
|
|
|
/**
|
|
* 편집 모드를 비활성화합니다.
|
|
*/
|
|
clearEditMode: (): void => {
|
|
editModeEnabled = false;
|
|
currentLayoutName = null;
|
|
currentTemplateId = null;
|
|
logger.log('위지윅 편집 모드 비활성화');
|
|
},
|
|
|
|
/**
|
|
* 현재 편집 중인 레이아웃명을 반환합니다.
|
|
*
|
|
* @returns string | null 레이아웃명 또는 null
|
|
*/
|
|
getCurrentLayoutName: (): string | null => {
|
|
return currentLayoutName;
|
|
},
|
|
|
|
/**
|
|
* 현재 편집 중인 템플릿 ID를 반환합니다.
|
|
*
|
|
* @returns string | null 템플릿 ID 또는 null
|
|
*/
|
|
getCurrentTemplateId: (): string | null => {
|
|
return currentTemplateId;
|
|
},
|
|
|
|
/**
|
|
* URL 쿼리 파라미터에서 편집 모드 여부를 확인합니다.
|
|
*
|
|
* @returns boolean 편집 모드 여부
|
|
*/
|
|
isEditModeFromUrl: (): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get('mode') === 'edit';
|
|
},
|
|
|
|
/**
|
|
* 편집 모드 URL을 생성합니다.
|
|
* 라우트 기반 URL 형식: /{route}?mode=edit&template={templateId}
|
|
*
|
|
* @param route 라우트 경로 (예: '/', '/shop', '/board/posts')
|
|
* @param templateId 템플릿 ID (예: 'sirsoft-basic')
|
|
* @returns string 편집 모드 URL
|
|
*/
|
|
getEditModeUrl: (route: string, templateId: string): string => {
|
|
const baseUrl = window.location.origin;
|
|
// 라우트가 '/'로 시작하지 않으면 추가
|
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
return `${baseUrl}${normalizedRoute}?mode=edit&template=${encodeURIComponent(templateId)}`;
|
|
},
|
|
|
|
/**
|
|
* 편집 모드로 진입합니다. (페이지 이동)
|
|
* 라우트 기반으로 편집 모드에 진입합니다.
|
|
*
|
|
* @param route 라우트 경로 (예: '/', '/shop', '/board/posts')
|
|
* @param templateId 템플릿 ID (예: 'sirsoft-basic')
|
|
*/
|
|
enterEditMode: (route: string, templateId: string): void => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
const url = G7Core.wysiwyg.getEditModeUrl(route, templateId);
|
|
window.location.href = url;
|
|
},
|
|
|
|
/**
|
|
* 편집 모드를 종료하고 일반 페이지로 이동합니다.
|
|
* mode, template 파라미터를 제거하고 현재 라우트에 머무릅니다.
|
|
*/
|
|
exitEditMode: (): void => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
const params = new URLSearchParams(window.location.search);
|
|
params.delete('mode');
|
|
params.delete('template');
|
|
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
|
window.location.href = newUrl;
|
|
},
|
|
|
|
/**
|
|
* 위지윅 편집기 모듈 버전을 반환합니다.
|
|
*
|
|
* @returns string 버전 문자열
|
|
*/
|
|
getVersion: (): string => {
|
|
return '1.0.0';
|
|
},
|
|
|
|
/**
|
|
* 현재 구현된 Phase를 반환합니다.
|
|
*
|
|
* @returns number Phase 번호
|
|
*/
|
|
getPhase: (): number => {
|
|
return 1;
|
|
},
|
|
};
|
|
|
|
logger.log('전역 객체 window.G7Core 위지윅 편집기 API에 노출됨 (wysiwyg)');
|
|
}
|
|
|
|
/**
|
|
* G7Core 전역 객체를 초기화합니다.
|
|
*
|
|
* 템플릿 컴포넌트에서 사용할 수 있는 모든 전역 API를 window.G7Core에 노출합니다.
|
|
*
|
|
* @param deps - 초기화에 필요한 의존성 객체
|
|
*/
|
|
export function initializeG7CoreGlobals(deps: G7CoreDependencies): void {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
// React 전역 노출
|
|
initReactGlobals();
|
|
|
|
// G7Core 네임스페이스가 없으면 생성 (TemplateApp.ts에서 이미 생성했을 수 있음)
|
|
if (!(window as any).G7Core) {
|
|
(window as any).G7Core = {};
|
|
}
|
|
|
|
const G7Core = (window as any).G7Core;
|
|
|
|
// 각 API 그룹 초기화
|
|
initComponentEventSystem(G7Core);
|
|
initCoreAPIs(G7Core, deps);
|
|
initTranslationAPI(G7Core, deps);
|
|
initHelperAPIs(G7Core, deps);
|
|
initDispatchAPI(G7Core);
|
|
initStateAPI(G7Core);
|
|
initDataSourceAPI(G7Core);
|
|
initLocaleAPI(G7Core, deps);
|
|
initToastAPI(G7Core);
|
|
initModalAPI(G7Core);
|
|
initStyleAPI(G7Core);
|
|
initWebSocketAPI(G7Core, deps);
|
|
initNavigationAPI(G7Core, deps);
|
|
initPluginAPI(G7Core);
|
|
initModuleAPI(G7Core);
|
|
initSlotAPI(G7Core, deps);
|
|
initWysiwygEditorAPI(G7Core);
|
|
|
|
logger.log('G7Core 전역 객체 초기화 완료');
|
|
|
|
// DevTools API는 initTemplateApp 이후에 초기화됨 (debug 옵션 확인 필요)
|
|
// initDevToolsAPI는 reinitializeDevTools()를 통해 호출됨
|
|
}
|
|
|
|
/**
|
|
* G7Core.devTools 인터페이스 초기화
|
|
*
|
|
* G7DevToolsCore.getInstance() 직접 호출 대신 통합 인터페이스를 통해 추적 기능에 접근합니다.
|
|
* 템플릿 컴포넌트, 헬퍼 함수, 모듈에서 일관된 방식으로 DevTools에 접근할 수 있습니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // renderItemChildren 등에서 사용
|
|
* const G7Core = (window as any).G7Core;
|
|
* if (G7Core?.devTools?.isEnabled()) {
|
|
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
|
|
* }
|
|
* ```
|
|
*/
|
|
export function initDevToolsInterface(): void {
|
|
const G7Core = (window as any).G7Core;
|
|
if (!G7Core) {
|
|
logger.warn('G7Core가 초기화되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
const getDevTools = () => G7DevToolsCore.getInstance();
|
|
|
|
const devToolsInterface: G7DevToolsInterface = {
|
|
isEnabled: () => {
|
|
try {
|
|
return getDevTools().isEnabled();
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
trackRender: (componentName: string) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackRender(componentName);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackIteration: (id, source, itemVar, indexVar, itemCount) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackIteration(id, {
|
|
source,
|
|
itemVar,
|
|
indexVar,
|
|
sourceLength: itemCount,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackIfCondition: (id, condition, result, componentName) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackIfCondition(id, condition, result);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackMount: (componentId, info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackMount(componentId, { ...info, id: componentId });
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackUnmount: (componentId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackUnmount(componentId);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackExpressionEval: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackExpressionEval(info);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackBindingEval: (expression?: string) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackBindingEval(expression);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
recordCacheHit: () => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().recordCacheHit();
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
recordCacheMiss: () => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().recordCacheMiss();
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackHandlerRegistration: (name, category, description, source) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackHandlerRegistration(name, category, description, source);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackHandlerUnregistration: (name) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackHandlerUnregistration(name);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
logAction: (action) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().logAction(action);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackRequest: (url, method) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
return getDevTools().trackRequest(url, method);
|
|
}
|
|
return '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
completeRequest: (requestId, statusCode, response) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().completeRequest(requestId, statusCode, response);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
failRequest: (requestId, error) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().failRequest(requestId, error);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackDataSourceDefinition: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDataSourceDefinition(info);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackDataSourceLoading: (id) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDataSourceLoading(id);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackDataSourceLoaded: (id, data, dataPath) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDataSourceLoaded(id, data, dataPath);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackDataSourceError: (id, error) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDataSourceError(id, error);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackForm: (id, dataKey, inputs) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackForm(id, dataKey, inputs);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
untrackForm: (id) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().untrackForm(id);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
startStateChange: (statePath, oldValue, newValue, trigger) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
return getDevTools().startStateChange(statePath, oldValue, newValue, trigger);
|
|
}
|
|
return '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
completeStateChange: (setStateId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().completeStateChange(setStateId);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackComponentRender: (componentId, componentName, renderDuration, accessedStatePaths, evaluatedBindings, parentId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackComponentRender(
|
|
componentId,
|
|
componentName,
|
|
renderDuration,
|
|
accessedStatePaths,
|
|
evaluatedBindings,
|
|
parentId
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
updateLocalState: (localState) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().updateLocalState(localState);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
updateComputedState: (computedState) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().updateComputedState(computedState);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
updateParentContext: (parentContext) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().updateParentContext(parentContext);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackComponentStateSource: (componentId, componentName, stateSource, stateProvider) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackComponentStateSource(componentId, componentName, stateSource, stateProvider);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackDynamicState: (componentId, dynamicState) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDynamicState(componentId, dynamicState);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackContextFlow: (componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackContextFlow(componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackComponentStyle: (componentId, componentName, classes, computedStyles) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackComponentStyle(componentId, componentName, classes, computedStyles);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackAuthEvent: (type, success, error, details) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackAuthEvent(type, success, error, details);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackAuthHeader: (url, hasAuthHeader, headerType, tokenValid, responseStatus) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackAuthHeader(url, hasAuthHeader, headerType, tokenValid, responseStatus);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 렌더링은 계속
|
|
}
|
|
},
|
|
|
|
trackLog: (level, prefix, args) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackLog(level, prefix, args);
|
|
}
|
|
} catch {
|
|
// DevTools 추적 실패해도 로깅은 계속
|
|
}
|
|
},
|
|
|
|
trackAction: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackAction?.(info);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 액션 실행은 계속
|
|
}
|
|
},
|
|
|
|
trackDataSourceUpdate: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackDataSourceUpdate?.(info);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 업데이트는 계속
|
|
}
|
|
},
|
|
|
|
// ============================================
|
|
// Sequence 실행 추적 메서드
|
|
// ============================================
|
|
|
|
startSequenceExecution: (trigger) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
return getDevTools().startSequenceExecution(trigger);
|
|
}
|
|
return '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
captureSequenceActionBefore: (sequenceId, actionIndex, handler, params, currentState) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().captureSequenceActionBefore(sequenceId, actionIndex, handler, params, currentState);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
captureSequenceActionAfter: (sequenceId, actionIndex, currentState, duration, result, error) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().captureSequenceActionAfter(sequenceId, actionIndex, currentState, duration, result, error);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
endSequenceExecution: (sequenceId, error) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().endSequenceExecution(sequenceId, error);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
// ============================================
|
|
// Stale Closure 감지 메서드
|
|
// ============================================
|
|
|
|
registerStateCaptureForHandler: (handlerId, statePaths, stateValues) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().registerStateCaptureForHandler(handlerId, statePaths, stateValues);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
detectStaleClosure: (handlerId, location, currentState, warningType, actionId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
return getDevTools().detectStaleClosure(handlerId, location, currentState, warningType, actionId);
|
|
}
|
|
return [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
trackStaleClosureWarning: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackStaleClosureWarning(info);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
// ============================================
|
|
// Modal State Scope 추적 메서드
|
|
// ============================================
|
|
|
|
trackModalOpen: (options) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackModalOpen(options);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
trackModalClose: (modalId, finalState) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackModalClose(modalId, finalState);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
trackModalStateChange: (options) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackModalStateChange(options);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
// ============================================
|
|
// Nested Context 추적 메서드
|
|
// ============================================
|
|
|
|
trackNestedContext: (info) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
return getDevTools().trackNestedContext(info);
|
|
}
|
|
return '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
trackNestedContextAccess: (contextId, access) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackNestedContextAccess(contextId, access);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
// ============================================
|
|
// Computed 추적 메서드
|
|
// ============================================
|
|
|
|
trackComputedProperty: (name, expression, dependencies, value, computationTime, componentId, error) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackComputedProperty(name, expression, dependencies as any, value, computationTime, componentId, error);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
trackComputedRecalc: (computedName, trigger, previousValue, newValue, computationTime, triggeredBy, componentId) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackComputedRecalc(computedName, trigger as any, previousValue, newValue, computationTime, triggeredBy as any, componentId);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
setNamedActionDefinitions: (definitions) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().setNamedActionDefinitions(definitions);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
|
|
trackNamedActionRef: (log) => {
|
|
try {
|
|
if (getDevTools().isEnabled()) {
|
|
getDevTools().trackNamedActionRef(log);
|
|
}
|
|
} catch (e) {
|
|
// DevTools 추적 실패해도 실행은 계속
|
|
}
|
|
},
|
|
};
|
|
|
|
// G7Core.devTools에 인터페이스 할당
|
|
G7Core.devTools = devToolsInterface;
|
|
|
|
logger.log('G7Core.devTools 인터페이스 초기화 완료');
|
|
}
|
|
|
|
/**
|
|
* G7 DevTools API 초기화
|
|
*
|
|
* 디버깅 및 개발 도구 API를 window.G7DevTools에 노출합니다.
|
|
* 환경설정의 debug_mode가 활성화된 경우에만 전체 기능이 활성화됩니다.
|
|
*
|
|
* initTemplateApp에서 debug 옵션 설정 후 호출해야 합니다.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // 상태 조회
|
|
* G7DevTools.state.get()
|
|
*
|
|
* // 액션 이력 조회
|
|
* G7DevTools.actions.getHistory()
|
|
*
|
|
* // 자동 진단
|
|
* G7DevTools.diagnose.analyze(['이전 값이 전송됨'])
|
|
*
|
|
* // 서버로 상태 덤프
|
|
* G7DevTools.server.dumpState()
|
|
* ```
|
|
*/
|
|
export function initDevToolsAPI(): void {
|
|
const G7Core = (window as any).G7Core;
|
|
if (!G7Core) {
|
|
logger.warn('G7Core가 초기화되지 않았습니다.');
|
|
return;
|
|
}
|
|
const devToolsCore = G7DevToolsCore.getInstance();
|
|
devToolsCore.initialize();
|
|
|
|
// DevTools가 비활성화된 경우 최소 API만 노출
|
|
if (!devToolsCore.isEnabled()) {
|
|
(window as any).G7DevTools = {
|
|
isEnabled: () => false,
|
|
enable: () => {
|
|
logger.log('환경설정 > 고급 설정 > 디버그 모드를 켜세요');
|
|
},
|
|
};
|
|
// 비활성화 상태에서도 버퍼 정리 (보안)
|
|
flushEarlyLogs();
|
|
logger.log('G7DevTools 비활성화됨 (디버그 모드 꺼짐)');
|
|
return;
|
|
}
|
|
|
|
// G7Core.devTools 인터페이스 초기화 (renderItemChildren 등에서 사용)
|
|
initDevToolsInterface();
|
|
|
|
// 초기화 전에 버퍼링된 에러/경고 로그 flush
|
|
flushEarlyLogs();
|
|
|
|
// StyleTracker 활성화 (DOM 스타일 이상 감지)
|
|
try {
|
|
const styleTracker = getStyleTracker();
|
|
styleTracker.enable();
|
|
logger.log('StyleTracker 활성화됨');
|
|
} catch (error) {
|
|
logger.warn('StyleTracker 활성화 실패:', error);
|
|
}
|
|
|
|
const diagnosticEngine = new DiagnosticEngine();
|
|
const serverConnector = getServerConnector();
|
|
|
|
const G7DevTools = {
|
|
// === 활성화 상태 ===
|
|
isEnabled: () => devToolsCore.isEnabled(),
|
|
|
|
// === 상태 관리 ===
|
|
state: {
|
|
/**
|
|
* 현재 상태를 조회합니다.
|
|
* @returns { _global, _local, _computed }
|
|
*/
|
|
get: () => devToolsCore.getState(),
|
|
/**
|
|
* 상태 변경 이력을 조회합니다.
|
|
* @returns StateSnapshot[]
|
|
*/
|
|
getHistory: () => devToolsCore.getStateHistory(),
|
|
/**
|
|
* 상태 변경을 구독합니다.
|
|
* @param path 감시할 경로 ('*'는 모든 변경)
|
|
* @param callback 변경 시 호출될 콜백
|
|
* @returns 구독 해제 함수
|
|
*/
|
|
watch: (path: string, callback: (newVal: any, oldVal: any) => void) =>
|
|
devToolsCore.watchState(path, callback),
|
|
},
|
|
|
|
// === 액션 추적 ===
|
|
actions: {
|
|
/**
|
|
* 액션 실행 이력을 조회합니다.
|
|
* @returns ActionLog[]
|
|
*/
|
|
getHistory: () => devToolsCore.getActionHistory(),
|
|
/**
|
|
* 액션 실행을 구독합니다.
|
|
* @param callback 액션 실행 시 호출될 콜백
|
|
* @returns 구독 해제 함수
|
|
*/
|
|
watch: (callback: (action: any) => void) => devToolsCore.watchActions(callback),
|
|
/**
|
|
* 액션 실행 메트릭을 조회합니다.
|
|
* @returns ActionMetrics
|
|
*/
|
|
getMetrics: () => devToolsCore.getActionMetrics(),
|
|
},
|
|
|
|
// === 바인딩 & 캐시 ===
|
|
binding: {
|
|
/**
|
|
* 표현식을 평가합니다.
|
|
* @param expr 평가할 표현식
|
|
* @returns 평가 결과
|
|
*/
|
|
evaluate: (expr: string) => {
|
|
const engine = G7Core.getDataBindingEngine?.();
|
|
if (!engine) {
|
|
logger.warn('DataBindingEngine이 없습니다.');
|
|
return undefined;
|
|
}
|
|
return engine.evaluateExpression(expr, devToolsCore.getState());
|
|
},
|
|
/**
|
|
* 캐시 통계를 조회합니다.
|
|
* @returns CacheStats
|
|
*/
|
|
getCacheStats: () => devToolsCore.getCacheStats(),
|
|
/**
|
|
* 캐시 통계를 초기화합니다.
|
|
*/
|
|
clearStats: () => {
|
|
devToolsCore.resetCacheStats();
|
|
logger.log('캐시 통계 초기화됨');
|
|
},
|
|
},
|
|
|
|
// === 자동 진단 ===
|
|
diagnose: {
|
|
/**
|
|
* 증상을 기반으로 문제를 진단합니다.
|
|
* @param symptoms 증상 배열
|
|
* @returns DiagnosisResult[]
|
|
*/
|
|
analyze: (symptoms: string[]) => diagnosticEngine.analyze(symptoms),
|
|
/**
|
|
* 진단 결과에 대한 수정 제안을 반환합니다.
|
|
* @param diagnosis 진단 결과
|
|
* @returns FixSuggestion
|
|
*/
|
|
suggestFix: (diagnosis: any) => diagnosticEngine.suggestFix(diagnosis),
|
|
/**
|
|
* 자주 발생하는 문제 목록을 반환합니다.
|
|
* @returns CommonIssue[]
|
|
*/
|
|
getCommonIssues: () => diagnosticEngine.getCommonIssues(),
|
|
/**
|
|
* 카테고리별 진단 규칙을 조회합니다.
|
|
* @param category 카테고리
|
|
* @returns DiagnosticRule[]
|
|
*/
|
|
getRulesByCategory: (category: DiagnosticCategory) => diagnosticEngine.getRulesByCategory(category),
|
|
},
|
|
|
|
// === 서버 통합 ===
|
|
server: {
|
|
/**
|
|
* 현재 상태를 서버로 덤프합니다.
|
|
* @param saveHistory 이력 파일도 저장할지 여부
|
|
* @returns 저장된 파일 경로
|
|
*/
|
|
dumpState: (saveHistory?: boolean) => serverConnector.dumpState(saveHistory),
|
|
/**
|
|
* 디버그 로그를 서버로 전송합니다.
|
|
* @param data 로그 데이터
|
|
*/
|
|
sendLog: (data: any) => serverConnector.sendLog(data),
|
|
/**
|
|
* 에러 정보를 서버로 전송합니다.
|
|
* @param error 에러 객체
|
|
* @param context 추가 컨텍스트
|
|
*/
|
|
sendError: (error: Error, context?: Record<string, any>) =>
|
|
serverConnector.sendError(error, context),
|
|
/**
|
|
* 서버 연결 상태를 확인합니다.
|
|
* @returns 연결 상태
|
|
*/
|
|
isConnected: () => serverConnector.isConnected(),
|
|
/**
|
|
* 서버 연결을 테스트합니다.
|
|
* @returns 연결 성공 여부
|
|
*/
|
|
testConnection: () => serverConnector.testConnection(),
|
|
},
|
|
|
|
// === 설정 ===
|
|
config: {
|
|
/**
|
|
* 디버그 모드 상태를 확인합니다.
|
|
* @returns 디버그 모드 활성화 여부
|
|
*/
|
|
isDebugMode: () => devToolsCore.isEnabled(),
|
|
/**
|
|
* 로그 레벨을 설정합니다.
|
|
* @param level 로그 레벨
|
|
*/
|
|
setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') =>
|
|
devToolsCore.setLogLevel(level),
|
|
/**
|
|
* 최대 이력 개수를 설정합니다.
|
|
* @param count 최대 이력 개수
|
|
*/
|
|
setMaxHistory: (count: number) => devToolsCore.setMaxHistory(count),
|
|
},
|
|
|
|
// === 라이프사이클 ===
|
|
lifecycle: {
|
|
/**
|
|
* 마운트된 컴포넌트 목록을 조회합니다.
|
|
* @returns ComponentInfo[]
|
|
*/
|
|
getMountedComponents: () => devToolsCore.getLifecycleInfo().mountedComponents,
|
|
/**
|
|
* 정리되지 않은 이벤트 리스너를 조회합니다.
|
|
* @returns ListenerInfo[]
|
|
*/
|
|
getOrphanedListeners: () => devToolsCore.getLifecycleInfo().orphanedListeners,
|
|
},
|
|
|
|
// === 성능 ===
|
|
performance: {
|
|
/**
|
|
* 렌더링 횟수를 조회합니다.
|
|
* @returns Map<string, number>
|
|
*/
|
|
getRenderCount: () => devToolsCore.getPerformanceInfo().renderCounts,
|
|
/**
|
|
* 바인딩 평가 횟수를 조회합니다.
|
|
* @returns number
|
|
*/
|
|
getBindingEvalCount: () => devToolsCore.getPerformanceInfo().bindingEvalCount,
|
|
/**
|
|
* 메모리 경고를 조회합니다.
|
|
* @returns MemoryWarning[]
|
|
*/
|
|
getMemoryWarnings: () => devToolsCore.getPerformanceInfo().memoryWarnings,
|
|
/**
|
|
* 프로파일링을 시작합니다.
|
|
*/
|
|
startProfiling: () => devToolsCore.startProfiling(),
|
|
/**
|
|
* 프로파일링을 중지하고 결과를 반환합니다.
|
|
* @returns ProfileReport
|
|
*/
|
|
stopProfiling: () => devToolsCore.stopProfiling(),
|
|
},
|
|
|
|
// === 네트워크 ===
|
|
network: {
|
|
/**
|
|
* 진행 중인 요청을 조회합니다.
|
|
* @returns RequestInfo[]
|
|
*/
|
|
getActiveRequests: () => devToolsCore.getNetworkInfo().activeRequests,
|
|
/**
|
|
* 요청 이력을 조회합니다.
|
|
* @returns RequestLog[]
|
|
*/
|
|
getRequestHistory: () => devToolsCore.getNetworkInfo().requestHistory,
|
|
/**
|
|
* 대기 중인 데이터소스를 조회합니다.
|
|
* @returns string[]
|
|
*/
|
|
getPendingDataSources: () => devToolsCore.getNetworkInfo().pendingDataSources,
|
|
},
|
|
|
|
// === 조건부 렌더링 ===
|
|
conditional: {
|
|
/**
|
|
* if 조건 목록을 조회합니다.
|
|
* @returns ConditionInfo[]
|
|
*/
|
|
getIfConditions: () => devToolsCore.getConditionalInfo().ifConditions,
|
|
/**
|
|
* iteration 목록을 조회합니다.
|
|
* @returns IterationInfo[]
|
|
*/
|
|
getIterations: () => devToolsCore.getConditionalInfo().iterations,
|
|
},
|
|
|
|
// === Form ===
|
|
form: {
|
|
/**
|
|
* 추적된 Form 목록을 조회합니다.
|
|
* @returns FormInfo[]
|
|
*/
|
|
getForms: () => devToolsCore.getFormInfo(),
|
|
},
|
|
|
|
// === WebSocket ===
|
|
websocket: {
|
|
/**
|
|
* WebSocket 정보를 조회합니다.
|
|
* @returns WebSocketInfo
|
|
*/
|
|
getInfo: () => devToolsCore.getWebSocketInfo(),
|
|
},
|
|
};
|
|
|
|
// 전역 노출
|
|
(window as any).G7DevTools = G7DevTools;
|
|
|
|
// G7Core에도 DevTools 참조 추가
|
|
G7Core.devtools = G7DevTools;
|
|
|
|
logger.log('G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)');
|
|
|
|
// DevToolsPanel UI 렌더링
|
|
renderDevToolsPanel();
|
|
}
|
|
|
|
/**
|
|
* DevToolsPanel UI를 DOM에 렌더링합니다.
|
|
*
|
|
* 별도의 DOM 컨테이너를 생성하여 메인 앱과 독립적으로 렌더링합니다.
|
|
* 디버그 모드가 활성화된 경우에만 렌더링됩니다.
|
|
*/
|
|
function renderDevToolsPanel(): void {
|
|
// 이미 렌더링되어 있으면 스킵
|
|
if (document.getElementById('g7-devtools-root')) {
|
|
logger.log('DevToolsPanel 이미 렌더링됨');
|
|
return;
|
|
}
|
|
|
|
// DOM 컨테이너 생성
|
|
const container = document.createElement('div');
|
|
container.id = 'g7-devtools-root';
|
|
document.body.appendChild(container);
|
|
|
|
// React root 생성 및 DevToolsPanel 렌더링
|
|
try {
|
|
const root = ReactDOM.createRoot(container);
|
|
root.render(React.createElement(DevToolsPanel));
|
|
logger.log('DevToolsPanel UI 렌더링 완료');
|
|
} catch (error) {
|
|
logger.error('DevToolsPanel UI 렌더링 실패:', error);
|
|
}
|
|
}
|