/** * NOTE: @since versions (engine-v1.x.x) refer to internal template engine * development iterations, not G7 platform release versions. */ /** * ActionDispatcher.ts * * 그누보드7 템플릿 엔진의 이벤트 핸들러 관리 및 액션 실행 엔진 * * 주요 기능: * - 이벤트 핸들러 바인딩 (onClick, onChange 등) * - 액션 타입별 실행 (navigate, apiCall, setState 등) * - 파라미터 바인딩 및 {{}} 변수 치환 * - React Router 및 상태 관리 통합 * * @module ActionDispatcher */ import { DataBindingEngine } from './DataBindingEngine'; import { resolveExpressionString } from './helpers/RenderHelpers'; import { TranslationEngine, TranslationContext } from './TranslationEngine'; import { AuthManager, AuthType } from '../auth/AuthManager'; import { getApiClient } from '../api/ApiClient'; import { getErrorHandlingResolver } from '../error'; import type { ErrorHandlerConfig, ErrorContext } from '../types/ErrorHandling'; import { createLogger } from '../utils/Logger'; import type { G7DevToolsInterface } from './G7CoreGlobals'; import { evaluateConditionBranches } from './helpers/ConditionEvaluator'; import { triggerModalParentUpdate } from './ParentContextProvider'; import type { GlobalHeaderRule } from './LayoutLoader'; import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor'; const logger = createLogger('ActionDispatcher'); /** * 프리뷰 모드에서 억제되는 핸들러 목록 * * 프리뷰 페이지 이탈 또는 현재 세션 변경을 유발하는 핸들러를 정의합니다. * 이 목록에 포함된 핸들러는 프리뷰 모드에서 실행되지 않고 로그만 남깁니다. * * @since engine-v1.26.1 */ const PREVIEW_SUPPRESSED_HANDLERS: ReadonlySet = new Set([ // 직접 네비게이션 — 현재 페이지를 떠남 'navigate', 'navigateBack', 'navigateForward', 'replaceUrl', // 간접 네비게이션 — 페이지 리로드 또는 리다이렉트 유발 'refresh', // window.location.reload() 'logout', // 로그아웃 후 로그인 페이지로 리다이렉트 ]); /** * 프리뷰 모드에서 억제되는 레이아웃 기능 목록 * * 레이아웃 JSON의 최상위 기능 중 프리뷰 모드에서 비활성화할 대상입니다. * redirect 속성 등 라우트 레벨에서 페이지 이탈을 유발하는 기능을 정의합니다. * * @since engine-v1.26.1 */ const PREVIEW_SUPPRESSED_LAYOUT_FEATURES: ReadonlySet = new Set([ 'redirect', // 라우트 리다이렉트 속성 ]); /** * G7Core.devTools 인터페이스 가져오기 * * G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. * DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환 */ function getDevTools(): G7DevToolsInterface | undefined { try { const G7Core = (window as any).G7Core; return G7Core?.devTools; } catch { return undefined; } } // ============================================================================ // 타입 정의 // ============================================================================ /** * 액션 타입 */ export type ActionType = | 'navigate' // 페이지 이동 | 'navigateBack' // 브라우저 뒤로가기 | 'navigateForward' // 브라우저 앞으로가기 | 'replaceUrl' // URL만 변경 (데이터소스 refetch 없음) | 'apiCall' // API 호출 | 'login' // 로그인 (토큰 저장 포함) | 'logout' // 로그아웃 | 'setState' // 상태 변경 | 'setError' // 에러 상태 설정 | 'openModal' // 모달 열기 | 'closeModal' // 모달 닫기 | 'showAlert' // 알림 표시 | 'toast' // 토스트 알림 표시 | 'switch' // 조건 분기 처리 | 'conditions' // 조건 분기 처리 (AND/OR 그룹, if-else 체인 지원) | 'sequence' // 순차 액션 실행 | 'parallel' // 병렬 액션 실행 | 'showErrorPage' // 에러 페이지 표시 | 'loadScript' // 외부 스크립트 동적 로드 | 'callExternal' // 외부 스크립트 생성자/메서드 호출 | 'callExternalEmbed' // 외부 스크립트를 레이어 모드로 임베드 | 'saveToLocalStorage' // 로컬스토리지에 저장 | 'loadFromLocalStorage' // 로컬스토리지에서 불러오기 | 'scrollIntoView' // 특정 요소로 스크롤 | 'ensureIdentityVerified' // 본인인증 선제 보장 (IDV 정책 프론트 가드) | 'resolveIdentityChallenge' // 본인인증 모달 → IdentityGuardInterceptor deferred resolver 통보 | 'startInterval' // 주기적으로 액션 실행 (id 기반 관리, stopInterval 로 중단) | 'stopInterval' // startInterval 로 등록한 interval 중단 | 'custom'; // 사용자 정의 액션 /** * 이벤트 타입 */ export type EventType = | 'click' | 'change' | 'input' | 'submit' | 'focus' | 'blur' | 'keydown' | 'keyup' | 'keypress' | 'mousedown' | 'mouseup' | 'mouseenter' | 'mouseleave' | 'scroll' // 드래그 앤 드롭 이벤트 | 'dragstart' | 'drag' | 'dragend' | 'dragenter' | 'dragover' | 'dragleave' | 'drop' // sortable 이벤트 (@dnd-kit 기반) | 'onSortStart' | 'onSortEnd' | 'onSortOver'; /** * 액션 정의 */ export interface ActionDefinition { /** 이벤트 타입 */ type: EventType; /** 액션 핸들러 이름 */ handler: ActionType | string; /** 커스텀 이벤트 핸들러 이름 (예: "onNavigate") - 지정 시 type 대신 사용됨 */ event?: string; /** 액션 타겟 (URL, API 엔드포인트 등) */ target?: string; /** 액션 파라미터 */ params?: Record; /** 액션 성공 시 실행할 후속 액션 (단일 또는 배열) */ onSuccess?: ActionDefinition | ActionDefinition[]; /** 액션 실패 시 실행할 후속 액션 (단일 또는 배열) - errorHandling에 매칭되지 않을 때 실행 */ onError?: ActionDefinition | ActionDefinition[]; /** * HTTP 에러 코드별 핸들러 매핑 * * onError보다 우선순위가 높습니다. * - errorHandling[코드] → errorHandling[default] → onError 순으로 확인 * * @example * ```json * { * "errorHandling": { * "403": { "handler": "showErrorPage", "params": { "target": "content" } }, * "422": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } }, * "default": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } } * } * } * ``` */ errorHandling?: import('../types/ErrorHandling').ErrorHandlingMap; /** 확인 메시지 표시 여부 */ confirm?: string; /** 키보드 이벤트에서 특정 키 필터링 (예: "Enter", "Escape") */ key?: string; /** switch 핸들러용 케이스 정의 - $args[0] 값을 기준으로 분기 */ cases?: Record; /** sequence 핸들러용 순차 실행 액션 배열 */ actions?: ActionDefinition[]; /** 인증 필요 여부 (apiCall 핸들러에서 Bearer 토큰 포함 여부) - auth_mode 사용 권장 */ auth_required?: boolean; /** * 인증 모드 (apiCall 핸들러) * - 'none': 토큰 미포함 (기본값) * - 'required': 토큰 필수 (없으면 에러) * - 'optional': 토큰이 있으면 포함, 없으면 미포함 */ auth_mode?: 'none' | 'required' | 'optional'; /** * 본인인증(IDV) 대상 — apiCall 핸들러 전용 선언적 메타 속성. * * 이 apiCall 이 HTTP 428(identity_verification_required)을 받으면, IdentityGuardInterceptor 가 * 인증 challenge 를 시작할 때 사용할 인증 대상(이메일·전화번호)을 흐름이 직접 선언한다. * 비로그인(게스트) 흐름에서 서버는 사용자가 방금 화면에 입력한 값을 알 수 없으므로, * 레이아웃이 이 속성에 표현식으로 대상을 명시하면 launcher 가 흐름 무지식으로 그 값을 받는다. * 로그인 사용자는 서버가 세션에서 자동 도출하므로 빈 값이어도 무방하다. * * email / phone 둘 중 하나만 있어도 충분하며, 우선순위는 표현식 자체가 결정한다 * (예: 주문자 정보 우선 → 수취인 정보 폴백). * * @example * ```json * { * "handler": "apiCall", * "target": "/api/.../orders", * "identity_target": { * "email": "{{_local.orderer?.email || ''}}", * "phone": "{{_local.orderer?.phone || _local.shipping?.recipient_phone || ''}}" * }, * "params": { "method": "POST", "body": { } } * } * ``` * * @since engine-v1.50.0 */ identity_target?: { email?: string; phone?: string }; /** 조건부 실행 - 표현식이 true일 때만 액션 실행 */ if?: string; /** * conditions 핸들러용 조건 브랜치 배열 * * if/else if/else 체인을 정의합니다. 첫 번째 매칭되는 브랜치의 then 액션이 실행됩니다. * * @example * ```json * { * "handler": "conditions", * "conditions": [ * { * "if": "{{$args[0] === 'edit'}}", * "then": { "handler": "navigate", "params": { "path": "/edit/{{row.id}}" } } * }, * { * "if": "{{$args[0] === 'delete'}}", * "then": [ * { "handler": "setState", "params": { "target": "_local", "deleteTargetId": "{{row.id}}" } }, * { "handler": "openModal", "params": { "id": "delete_confirm_modal" } } * ] * }, * { * "then": { "handler": "toast", "params": { "message": "알 수 없는 액션" } } * } * ] * } * ``` * * @since engine-v1.10.0 */ conditions?: import('./helpers/ConditionEvaluator').ConditionBranch[]; /** * loadScript 핸들러 - 스크립트 로드 완료 후 실행할 액션 * * @example * ```json * { * "handler": "loadScript", * "params": { * "src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js", * "id": "daum_postcode_script" * }, * "onLoad": { "handler": "setState", "params": { "daumPostcodeLoaded": true } } * } * ``` */ onLoad?: ActionDefinition; /** * callExternal 핸들러 - 외부 스크립트 콜백 이벤트명 * * oncomplete 등의 콜백이 호출되면 이 이벤트를 발생시킵니다. * G7Core.componentEvent.on()으로 구독할 수 있습니다. * * @example * ```json * { * "handler": "callExternal", * "params": { * "constructor": "daum.Postcode", * "args": { "oncomplete": true }, * "callbackEvent": "postcode:complete", * "method": "open" * } * } * ``` */ callbackEvent?: string; /** * 핸들러 실행 결과를 상태에 저장 * * 핸들러가 반환하는 값을 지정된 상태 키에 저장합니다. * 동적 컬럼 생성, 계산 결과 저장 등에 활용됩니다. * * @example * ```json * { * "handler": "sirsoft-ecommerce.buildProductColumns", * "params": { * "baseColumns": [...], * "currencies": "{{ecommerceSettings?.language_currency?.currencies}}" * }, * "resultTo": { * "target": "_local", * "key": "productColumns" * } * } * ``` */ resultTo?: { /** 저장 대상: "_local" (로컬 상태), "_global" (전역 상태), "_isolated" (격리된 상태) */ target: '_local' | '_global' | '_isolated'; /** 상태 키 (dot notation 지원, 예: "calculatedPrices.{{option.id}}") */ key: string; /** 병합 모드: "replace" | "shallow" | "deep" (기본값: "deep") */ merge?: 'replace' | 'shallow' | 'deep'; }; /** * 액션 실행 지연 (debounce) * * 연속 호출 시 마지막 호출만 실행합니다. * 숫자: delay ms (기본 설정 적용) * 객체: 상세 설정 * * @example * ```json * // 간단한 형태 (300ms 지연) * { "type": "change", "handler": "updateField", "debounce": 300 } * * // 상세 설정 * { * "type": "change", * "handler": "updateField", * "debounce": { * "delay": 500, * "leading": false, * "trailing": true * } * } * ``` */ debounce?: | number | { /** 지연 시간 (ms) */ delay: number; /** 첫 호출 즉시 실행 여부 (기본: false) */ leading?: boolean; /** 마지막 호출 후 실행 여부 (기본: true) */ trailing?: boolean; }; /** * named_actions에 정의된 액션을 참조 * * named_actions의 키를 지정하면 해당 액션 정의(handler, params 등)를 가져옵니다. * type, key, event 등 이벤트 바인딩 속성은 개별 지정합니다. * actionRef와 인라인 handler/params가 동시에 있으면 인라인이 우선합니다. * * @example * ```json * { * "type": "keypress", * "key": "Enter", * "actionRef": "searchProducts" * } * ``` * * @since engine-v1.19.0 */ actionRef?: string; } /** * Navigate 옵션 (React Router NavigateOptions 호환) */ export interface NavigateOptions { /** true이면 히스토리를 교체 (뒤로가기 불가) */ replace?: boolean; /** 전달할 상태 데이터 */ state?: any; } /** * 격리된 상태 컨텍스트 인터페이스 * * isolatedState 속성이 있는 컴포넌트에서 IsolatedStateProvider를 통해 제공됩니다. */ export interface IsolatedContextValue { /** 현재 격리된 상태 */ state: Record; /** 특정 경로의 상태를 설정 */ setState: (path: string, value: any) => void; /** 특정 경로의 상태를 조회 */ getState: (path: string) => any; /** 상태를 병합 모드에 따라 업데이트 */ mergeState: (updates: Record, mergeMode?: 'replace' | 'shallow' | 'deep') => void; } /** * 액션 컨텍스트 */ export interface ActionContext { /** 데이터 컨텍스트 */ data?: any; /** 이벤트 객체 */ event?: Event; /** 컴포넌트 props */ props?: Record; /** 현재 상태 */ state?: any; /** 상태 업데이트 함수 */ setState?: (updates: any) => void; /** React Router navigate 함수 */ navigate?: (path: string, options?: NavigateOptions) => void; /** * 격리된 상태 컨텍스트 * * isolatedState 속성이 있는 컴포넌트에서 자동으로 주입됩니다. * target: "isolated"로 setState 핸들러 호출 시 사용됩니다. */ isolatedContext?: IsolatedContextValue | null; } /** * 로딩 상태 맵 타입 * 액션 ID를 키로, 로딩 여부를 값으로 가지는 객체 */ export type LoadingActionsMap = Record; /** * 액션 핸들러 함수 타입 */ export type ActionHandler = ( action: ActionDefinition, context: ActionContext ) => void | Promise; /** * 액션 실행 결과 */ export interface ActionResult { success: boolean; data?: any; error?: Error; } /** * 액션 에러 클래스 */ export class ActionError extends Error { constructor( message: string, public action?: ActionDefinition, public originalError?: Error ) { super(message); this.name = 'ActionError'; } } // ============================================================================ // ActionDispatcher 클래스 // ============================================================================ /** * 이벤트 핸들러 관리 및 액션 실행 엔진 * * @example * const dispatcher = new ActionDispatcher({ navigate, setState }); * const handler = dispatcher.createHandler(actionDef, dataContext); * */ export class ActionDispatcher { /** 데이터 바인딩 엔진 */ private bindingEngine: DataBindingEngine; /** 번역 엔진 */ private translationEngine?: TranslationEngine; /** 번역 컨텍스트 */ private translationContext?: TranslationContext; /** 커스텀 액션 핸들러 맵 */ private customHandlers: Map = new Map(); /** 기본 컨텍스트 */ private defaultContext: Partial; /** 전역 상태 업데이트 함수 (engine-v1.42.0: render 옵션 추가) */ private globalStateUpdater?: (updates: any, options?: { render?: boolean }) => void; /** ErrorHandlingResolver 연동 여부 */ private errorHandlingSetup: boolean = false; /** * Debounce 타이머 저장소 * * 컴포넌트별, 액션별로 debounce 타이머를 관리합니다. * Key: `${componentId}-${handler}-${eventName}` */ private debounceTimers: Map> = new Map(); /** * 대기 중인 debounce 액션의 즉시 실행 함수 맵 * * 비디바운스 액션 실행 전에 대기 중인 debounce 액션을 즉시 실행(flush)하여 * state가 최신 상태가 되도록 합니다. * Key 형식은 debounceTimers와 동일합니다. */ private pendingDebounceFlushers: Map void> = new Map(); /** * 디바운스 대기 중 누적된 객체 값 * * _changedKeys 메타데이터가 포함된 이벤트의 변경분을 누적합니다. * 이를 통해 다국어 입력 등 객체 값의 stale closure로 인한 키 유실을 방지합니다. * Key: debounceKey * @since engine-v1.28.0 */ private debounceAccumulatedValues: Map> = new Map(); /** * 전역 헤더 규칙 (레이아웃에서 설정) * * apiCall 핸들러 호출 시 패턴에 매칭되는 엔드포인트에 자동으로 헤더를 추가합니다. * * @since engine-v1.16.0 */ private globalHeaders: GlobalHeaderRule[] = []; /** * 명명된 액션 정의 (레이아웃에서 설정) * * 레이아웃 JSON의 named_actions 섹션에서 정의된 재사용 가능한 액션입니다. * 컴포넌트의 actionRef 속성으로 참조할 수 있습니다. * * @since engine-v1.19.0 */ private namedActions: Record = {}; /** * 프리뷰 모드 여부 * * 프리뷰 모드에서는 PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러가 * 실행되지 않고 로그만 남깁니다. * * @since engine-v1.26.1 */ private previewMode: boolean = false; /** * startInterval 핸들러로 등록된 타이머 맵. * * key = 레이아웃이 지정한 interval id, value = setInterval 반환 timerId. * 레이아웃은 stopInterval 로 id 기반 중단을 수행할 수 있으며, * 페이지 전환 시에는 `stopAllIntervals()` 로 일괄 정리된다. * * @since engine-v1.45.0 */ private intervals: Map> = new Map(); /** * ActionDispatcher 생성자 */ constructor( defaultContext: Partial = {}, translationEngine?: TranslationEngine, translationContext?: TranslationContext ) { this.bindingEngine = new DataBindingEngine(); this.translationEngine = translationEngine; this.translationContext = translationContext; this.defaultContext = defaultContext; // 기본 핸들러 등록 this.registerDefaultHandlers(); // ErrorHandlingResolver에 액션 실행기 등록 this.setupErrorHandling(); } /** * 전역 헤더 규칙을 설정합니다. * * 레이아웃 로드 시 호출되어 globalHeaders를 설정합니다. * 이후 apiCall 핸들러에서 패턴 매칭을 통해 자동으로 헤더가 적용됩니다. * * @param headers 전역 헤더 규칙 배열 * @since engine-v1.16.0 */ public setGlobalHeaders(headers: GlobalHeaderRule[]): void { this.globalHeaders = headers || []; } /** * 명명된 액션 정의를 설정합니다. * * 레이아웃 로드 시 호출되어 namedActions를 설정합니다. * 이후 컴포넌트의 actionRef 속성으로 참조할 수 있습니다. * * @param actions 명명된 액션 정의 맵 * @since engine-v1.19.0 */ public setNamedActions(actions: Record): void { this.namedActions = actions || {}; // DevTools: named_actions 정의 등록 const devTools = getDevTools(); if (devTools?.isEnabled?.()) { devTools.setNamedActionDefinitions?.(this.namedActions); } logger.log('[setNamedActions] registered:', Object.keys(this.namedActions)); } /** * 현재 등록된 named_actions 정의를 반환합니다. * * @returns 명명된 액션 정의 맵 * @since engine-v1.19.0 */ public getNamedActions(): Record { return this.namedActions; } /** * 프리뷰 모드를 설정합니다. * * 프리뷰 모드에서는 PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러가 * 실행되지 않고 경고 로그만 남깁니다. * * @param enabled 프리뷰 모드 활성화 여부 * @since engine-v1.26.1 */ public setPreviewMode(enabled: boolean): void { this.previewMode = enabled; logger.log('Preview mode:', enabled ? 'enabled' : 'disabled'); } /** * 현재 프리뷰 모드 여부를 반환합니다. * * @returns 프리뷰 모드 활성화 여부 * @since engine-v1.26.1 */ public isPreviewMode(): boolean { return this.previewMode; } /** * 프리뷰 모드에서 억제되는 핸들러 목록을 반환합니다. * * @returns 억제 대상 핸들러 이름의 ReadonlySet * @since engine-v1.26.1 */ public static getPreviewSuppressedHandlers(): ReadonlySet { return PREVIEW_SUPPRESSED_HANDLERS; } /** * 프리뷰 모드에서 억제되는 레이아웃 기능 목록을 반환합니다. * * @returns 억제 대상 레이아웃 기능의 ReadonlySet * @since engine-v1.26.1 */ public static getPreviewSuppressedLayoutFeatures(): ReadonlySet { return PREVIEW_SUPPRESSED_LAYOUT_FEATURES; } /** * actionRef를 해석하여 완전한 ActionDefinition을 반환합니다. * * actionRef가 있으면 named_actions에서 해당 정의를 가져와 이벤트 속성과 병합합니다. * 인라인 handler/params가 있으면 인라인이 우선합니다 (override). * * @param action 원본 액션 정의 (actionRef 포함 가능) * @returns 해석된 액션 정의 * @since engine-v1.19.0 */ resolveActionRef(action: ActionDefinition): ActionDefinition { if (!action.actionRef) { return action; } const namedAction = this.namedActions[action.actionRef]; if (!namedAction) { logger.warn(`[resolveActionRef] named action not found: "${action.actionRef}"`); return action; } // DevTools: actionRef 해석 이력 기록 const devTools = getDevTools(); if (devTools?.isEnabled?.()) { devTools.trackNamedActionRef?.({ actionRefName: action.actionRef, resolvedHandler: namedAction.handler, timestamp: Date.now(), }); } // 이벤트 바인딩 속성(type, key, event, if, debounce)은 원본에서 유지 // handler, params, onSuccess, onError 등은 namedAction에서 가져오되, 인라인이 있으면 override const { actionRef, type, key, event, ...inlineOverrides } = action; const resolved: ActionDefinition = { ...namedAction, ...Object.fromEntries( Object.entries(inlineOverrides).filter(([, v]) => v !== undefined) ), type: type ?? namedAction.type, }; // key, event는 원본에서만 가져옴 (namedAction에는 이벤트 바인딩이 없음) if (key !== undefined) resolved.key = key; if (event !== undefined) resolved.event = event; return resolved; } /** * 엔드포인트가 패턴에 매칭되는지 확인합니다. * * @param endpoint API 엔드포인트 경로 * @param pattern glob 스타일 패턴 ("*", "/api/shop/*" 등) * @returns 매칭 여부 * @since engine-v1.16.0 */ private matchesPattern(endpoint: string, pattern: string): boolean { if (pattern === '*') return true; // glob 패턴을 정규식으로 변환 (* → .*) const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // 특수문자 이스케이프 .replace(/\*/g, '.*'); // * → .* return new RegExp(`^${regexPattern}$`).test(endpoint); } /** * 엔드포인트에 매칭되는 전역 헤더를 추출합니다. * * globalHeaders 배열을 순회하며 패턴이 매칭되는 모든 헤더를 병합합니다. * 나중에 정의된 규칙의 헤더가 먼저 정의된 규칙의 헤더를 덮어씁니다. * * @param endpoint API 엔드포인트 경로 * @param context 표현식 평가를 위한 컨텍스트 * @returns 병합된 헤더 객체 * @since engine-v1.16.0 */ private getMatchingGlobalHeaders( endpoint: string, context: Record, ): Record { const result: Record = {}; for (const rule of this.globalHeaders) { if (this.matchesPattern(endpoint, rule.pattern)) { Object.entries(rule.headers).forEach(([key, value]) => { // 표현식 평가 ({{_global.xxx}} 등) - resolveExpressionString으로 문자열 보간 지원 const resolved = resolveExpressionString(value, context, { skipCache: true }); // null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가 if (resolved != null && resolved !== '') { result[key] = String(resolved); } }); } } return result; } /** * ErrorHandlingResolver와 연동을 설정합니다. * * ErrorHandlingResolver가 에러 핸들러를 실행할 때 * ActionDispatcher.dispatchAction을 사용하도록 설정합니다. */ private setupErrorHandling(): void { if (this.errorHandlingSetup) { return; } const resolver = getErrorHandlingResolver(); // ErrorHandlingResolver에서 핸들러 실행 시 dispatchAction 사용 resolver.setActionExecutor(async (handler: ErrorHandlerConfig, errorContext: { error: ErrorContext }) => { // ErrorHandlerConfig를 ActionDefinition으로 변환 const action: ActionDefinition = { type: 'click', // 이벤트 타입은 의미 없음 (dispatchAction에서 사용하지 않음) handler: handler.handler as ActionType, target: handler.target, params: handler.params, actions: handler.actions, // sequence 핸들러용 actions 배열 전달 }; // 에러 컨텍스트를 data에 포함 const context: Partial = { data: { ...this.defaultContext.data, error: errorContext.error, }, }; return await this.dispatchAction(action, context); }); this.errorHandlingSetup = true; } /** * 기본 핸들러를 등록합니다. * * 템플릿 엔진에서 기본적으로 제공하는 핸들러들을 등록합니다. */ private registerDefaultHandlers(): void { // refetchDataSource: 데이터 소스 다시 fetch // sync 옵션: true면 startTransition 없이 즉시 렌더링 (드래그 앤 드롭 후 순서 변경 등) // globalStateOverride: sequence 내에서 setState 후 refetchDataSource 호출 시 // React 상태가 비동기로 업데이트되어 getState()가 이전 값을 반환하는 문제 해결 // localStateOverride: sequence 내에서 setState local 후 refetchDataSource 호출 시 // params에서 {{_local.xxx}} 참조 지원 // isolatedStateOverride: isolated 상태 override (isolatedContext가 있는 경우) this.registerHandler('refetchDataSource', async (action: ActionDefinition, context: ActionContext) => { const dataSourceId = action.params?.dataSourceId; const sync = action.params?.sync; if (!dataSourceId) { logger.warn('refetchDataSource: dataSourceId is required'); return; } // G7Core.dataSource.refetch() 호출 (sync, globalStateOverride, localStateOverride, isolatedStateOverride 전달) // G7Core.state.get()으로 최신 전역 상태를 직접 가져옴 (React 상태 비동기 업데이트 문제 해결) // context.state._global은 action 실행 시점의 snapshot이라 initCartKey 등에서 설정한 값이 반영 안됨 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.refetch) { // 최신 전역 상태 직접 가져오기 (G7Core.state.set()으로 설정한 값 즉시 반영) const currentGlobalState = (window as any).G7Core?.state?.get?.() || {}; const globalStateOverride = { ...context.state?._global, ...currentGlobalState, // 최신 상태로 덮어쓰기 }; // engine-v1.17.0: 커스텀 핸들러에서 setLocal 후 dispatch 호출 시 최신 로컬 상태 참조 // G7Core.state.getLocal()은 __g7PendingLocalState를 우선 확인하여 최신 값 반환 // // engine-v1.19.0: sequence 내 setState 후 refetchDataSource 호출 시 // React 18의 마이크로태스크 기반 배칭이 await 경계에서 렌더를 플러시할 수 있음 // 렌더 시 useLayoutEffect가 __g7PendingLocalState를 null로 클리어하므로 // getLocal()이 stale한 globalLocal(initLocal 시점 값)을 반환하는 문제 발생 // context.data._local은 sequence 핸들러가 누적한 최신 _local 상태이므로 우선 병합 // // context 구조별 _local 접근: // - 컴포넌트 컨텍스트: context.data._local = extendedDataContext._local // - sequence 컨텍스트: context.data._local = currentState (누적 상태) // - dispatch fallback: context.data._local = globalState._local const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || {}; const contextLocalState = context.data?._local || context.state || {}; const localStateOverride = { ...currentLocalState, ...contextLocalState }; const isolatedStateOverride = context.isolatedContext?.state; logger.log('[refetchDataSource] localStateOverride:', localStateOverride); await (window as any).G7Core.dataSource.refetch(dataSourceId, { ...(sync ? { sync: true } : {}), ...(globalStateOverride ? { globalStateOverride } : {}), ...(localStateOverride ? { localStateOverride } : {}), ...(isolatedStateOverride ? { isolatedStateOverride } : {}), }); } else { logger.warn('refetchDataSource: G7Core.dataSource.refetch is not available'); } }); // appendDataSource: 데이터 소스에 새 데이터를 병합 (무한 스크롤용) // 참고: executeAction에서 resolveParams가 이미 호출되어 action.params는 해석된 상태로 전달됨 this.registerHandler('appendDataSource', async (action: ActionDefinition, _context: ActionContext) => { const { dataSourceId, dataPath, newData } = action.params || {}; if (!dataSourceId) { logger.warn('appendDataSource: dataSourceId is required'); return; } if (newData === undefined) { logger.warn('appendDataSource: newData is required'); return; } // G7Core.dataSource.updateData() 호출 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.updateData) { // resolveParams에서 이미 표현식이 해석되었으므로 직접 사용 // dataPath와 newData는 이미 해석된 값임 if (!Array.isArray(newData)) { logger.warn('appendDataSource: newData must be an array, got:', typeof newData, newData); return; } await (window as any).G7Core.dataSource.updateData(dataSourceId, dataPath || null, newData, 'append'); } else { logger.warn('appendDataSource: G7Core.dataSource.updateData is not available'); } }); // updateDataSource: API 응답 데이터로 데이터 소스 전체 교체 (refetch 없이 직접 업데이트) // 장바구니 수량 변경 등 API가 전체 데이터를 반환하는 경우, refetch 대신 사용하여 네트워크 왕복 감소 // params.dataSourceId: 업데이트할 데이터소스 ID // params.data: 새 데이터 (API 응답에서 바인딩 표현식으로 가져옴, 예: {{response.data}}) // params.merge: true면 기존 데이터와 병합 (기본: false) this.registerHandler('updateDataSource', async (action: ActionDefinition, _context: ActionContext) => { const { dataSourceId, data, merge = false } = action.params || {}; if (!dataSourceId) { logger.warn('updateDataSource: dataSourceId is required'); return; } if (data === undefined) { logger.warn('updateDataSource: data is required'); return; } // G7Core.dataSource.set() 호출 if (typeof window !== 'undefined' && (window as any).G7Core?.dataSource?.set) { logger.log(`[updateDataSource] Updating dataSource '${dataSourceId}' with:`, data); (window as any).G7Core.dataSource.set(dataSourceId, data, { merge }); } else { logger.warn('updateDataSource: G7Core.dataSource.set is not available'); } }); // scrollIntoView: 특정 요소로 스크롤 // params.selector: CSS 선택자 (예: "#loading_indicator", "[data-id='item-1']") // params.behavior: 'smooth' | 'instant' | 'auto' (기본: 'smooth') // params.block: 'start' | 'center' | 'end' | 'nearest' (기본: 'nearest') // params.inline: 'start' | 'center' | 'end' | 'nearest' (기본: 'nearest') // params.waitForElement: true면 MutationObserver로 요소가 DOM에 추가될 때까지 대기 (기본: false) // params.timeout: waitForElement 사용 시 최대 대기 시간(ms) (기본: 2000) // params.delay: 스크롤 전 대기 시간(ms) - DOM 렌더링 대기용 (기본: 0, waitForElement=true면 무시) // params.retryCount: 요소를 찾지 못했을 때 재시도 횟수 (기본: 0, waitForElement=true면 무시) // params.retryInterval: 재시도 간격(ms) (기본: 50, waitForElement=true면 무시) // params.scrollContainer: 스크롤할 컨테이너 selector (지정 시 해당 컨테이너만 스크롤, 브라우저 스크롤 영향 없음) this.registerHandler('scrollIntoView', async (action: ActionDefinition, _context: ActionContext) => { const { selector, behavior = 'smooth', block = 'nearest', inline = 'nearest', waitForElement = false, timeout = 2000, delay = 0, retryCount = 0, retryInterval = 50, scrollContainer, } = action.params || {}; if (!selector) { logger.warn('scrollIntoView: selector is required'); return; } if (typeof window === 'undefined' || typeof document === 'undefined') { logger.warn('scrollIntoView: window/document is not available'); return; } let element: Element | null = null; if (waitForElement) { // MutationObserver를 사용하여 요소가 DOM에 추가될 때까지 대기 element = await new Promise((resolve) => { // 이미 요소가 존재하면 바로 반환 const existingElement = document.querySelector(selector); if (existingElement) { resolve(existingElement); return; } const timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); const observer = new MutationObserver(() => { const foundElement = document.querySelector(selector); if (foundElement) { clearTimeout(timeoutId); observer.disconnect(); resolve(foundElement); } }); observer.observe(document.body, { childList: true, subtree: true, }); }); if (!element) { logger.warn(`scrollIntoView: element not found for selector "${selector}" after ${timeout}ms timeout`); return; } } else { // 기존 방식: delay + retry if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } element = document.querySelector(selector); let attempts = 0; while (!element && attempts < retryCount) { await new Promise((resolve) => setTimeout(resolve, retryInterval)); element = document.querySelector(selector); attempts++; } if (!element) { logger.warn(`scrollIntoView: element not found for selector "${selector}" after ${attempts + 1} attempts`); return; } } // scrollContainer가 지정된 경우 해당 컨테이너 내에서만 스크롤 if (scrollContainer) { const container = document.querySelector(scrollContainer) as HTMLElement | null; if (!container) { logger.warn(`scrollIntoView: container not found for selector "${scrollContainer}"`); return; } const elementRect = element.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // 컨테이너 기준 상대 위치 계산 const relativeTop = elementRect.top - containerRect.top + container.scrollTop; const relativeBottom = relativeTop + elementRect.height; let targetScrollTop: number; switch (block) { case 'start': targetScrollTop = relativeTop; break; case 'center': targetScrollTop = relativeTop - (container.clientHeight / 2) + (elementRect.height / 2); break; case 'end': targetScrollTop = relativeBottom - container.clientHeight; break; case 'nearest': default: // 요소가 이미 보이면 스크롤하지 않음 const isAbove = elementRect.top < containerRect.top; const isBelow = elementRect.bottom > containerRect.bottom; if (isAbove) { targetScrollTop = relativeTop; } else if (isBelow) { targetScrollTop = relativeBottom - container.clientHeight; } else { // 이미 보임 - 스크롤 불필요 return; } break; } container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: behavior as ScrollBehavior, }); return; } // 기본: 네이티브 scrollIntoView 사용 element.scrollIntoView({ behavior: behavior as ScrollBehavior, block: block as ScrollLogicalPosition, inline: inline as ScrollLogicalPosition, }); }); // reloadExtensions: 확장 상태(routes/translations/layouts) 원자적 재동기화 // // 모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 onSuccess 에서 호출합니다. // 내부적으로 TemplateApp.reloadExtensionState() 를 호출하여 최신 cache_version 으로 // routes/translations/layout 캐시를 일괄 갱신합니다. // // 선택적 파라미터 `{ moduleInfo, action }` 전달 시 모듈 에셋(JS/CSS) 동적 로드/제거도 // reloadModuleHandlers 와 동일한 방식으로 수행합니다 (플러그인도 동일 파라미터로 처리). // // @since engine-v1.19.0 this.registerHandler('reloadExtensions', async (action: ActionDefinition, context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadExtensions: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadExtensions: TemplateApp not initialized'); return; } // 1. 확장 상태 일괄 재동기화 if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); } catch (error) { logger.error('reloadExtensions: reloadExtensionState failed', error); throw error; } } else { logger.warn('reloadExtensions: TemplateApp.reloadExtensionState unavailable'); } // 2. 선택적으로 모듈/플러그인 에셋 동적 로드/제거 const { moduleInfo, pluginInfo } = action.params || {}; if (moduleInfo) { try { await this.executeAction( { handler: 'reloadModuleHandlers', params: action.params, } as ActionDefinition, context ); } catch (error) { logger.error('reloadExtensions: reloadModuleHandlers failed', error); } } if (pluginInfo) { try { await this.executeAction( { handler: 'reloadPluginHandlers', params: action.params, } as ActionDefinition, context ); } catch (error) { logger.error('reloadExtensions: reloadPluginHandlers failed', error); } } logger.log('reloadExtensions: done'); }); // reloadRoutes: 라우트 다시 로드 (하위 호환) // // @deprecated engine-v1.19.0 이후 `reloadExtensions` 사용 권장. 본 핸들러는 // `reloadExtensionState()` 로 위임하여 버전 기반 캐시 갱신을 보장합니다. this.registerHandler('reloadRoutes', async (_action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadRoutes: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadRoutes: TemplateApp not initialized'); return; } if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); logger.log('reloadRoutes: delegated to reloadExtensionState'); } catch (error) { logger.error('reloadRoutes: Failed to reload routes', error); throw error; } } else { // 최소 호환: Router 직접 호출 (캐시 버전 없이) const router = templateApp.getRouter?.(); if (router) { await router.loadRoutes(); logger.log('reloadRoutes: Routes reloaded (legacy fallback)'); } } }); // refresh: 현재 페이지 새로고침 // params.delayMs (number, 기본 0) — 선행 토스트/모달 닫힘 애니메이션이 보이도록 지연 후 reload. // 토스트(_global.toasts) 메시지가 사용자에게 인지되기 전에 reload 가 일어나는 것을 방지. this.registerHandler('refresh', async (action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('refresh: window is not available'); return; } const delayMs = Number(action.params?.delayMs ?? 0); if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)); } window.location.reload(); }); // remount: 컴포넌트 ID 기반으로 리마운트 트리거 // _remountKeys 객체에 컴포넌트 ID별 카운터를 저장하여 key prop으로 사용 this.registerHandler('remount', async (action: ActionDefinition, _context: ActionContext) => { const { componentId } = action.params || {}; if (!componentId) { logger.warn('remount: componentId parameter is required'); return; } if (!this.globalStateUpdater) { logger.warn('remount: globalStateUpdater is not set'); return; } // G7Core.state에서 현재 _global._remountKeys 가져오기 // dataContext._global에 저장되어 있으므로 _global을 통해 접근 const G7Core = (window as any).G7Core; const currentState = G7Core?.state?.get() || {}; const remountKeys = currentState._global?._remountKeys || {}; const currentValue = remountKeys[componentId] || 0; // 해당 컴포넌트의 리마운트 키 증가 // globalStateUpdater는 TemplateApp.setGlobalState이며, 최상위 레벨 속성을 기대함 // setGlobalState 내부에서 updateTemplateData({ _global: { ...this.globalState } })로 감싸므로 // _global 없이 직접 _remountKeys를 전달해야 함 this.globalStateUpdater({ _remountKeys: { ...remountKeys, [componentId]: currentValue + 1, }, }); logger.log(`remount: ${componentId} key incremented to ${currentValue + 1}`); }); // reloadTranslations: 다국어 파일 다시 로드 (하위 호환) // // @deprecated engine-v1.19.0 이후 `reloadExtensions` 사용 권장. 본 핸들러는 // `reloadExtensionState()` 로 위임하여 다국어 외 routes/layouts 도 함께 갱신됩니다. this.registerHandler('reloadTranslations', async (_action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadTranslations: window is not available'); return; } const templateApp = (window as any).__templateApp; if (!templateApp) { logger.warn('reloadTranslations: TemplateApp not initialized'); return; } if (typeof templateApp.reloadExtensionState === 'function') { try { await templateApp.reloadExtensionState(); logger.log('reloadTranslations: delegated to reloadExtensionState'); } catch (error) { logger.error('reloadTranslations: Failed to reload translations', error); throw error; } } }); // reloadModuleHandlers: 모듈 핸들러 동적 로드/제거 // 모듈 활성화/비활성화 시 window.G7Config.moduleAssets 병합/제거 및 JS 로드 this.registerHandler('reloadModuleHandlers', async (action: ActionDefinition, _context: ActionContext) => { if (typeof window === 'undefined') { logger.warn('reloadModuleHandlers: window is not available'); return; } const { moduleInfo, action: actionType } = action.params || {}; if (!moduleInfo) { logger.warn('reloadModuleHandlers: moduleInfo is required'); return; } // API 응답의 data 래퍼 처리 (ResponseHelper 응답 구조 호환) // 응답 구조: { success, message, data: { identifier, assets, ... } } const moduleData = moduleInfo.data || moduleInfo; if (!moduleData.identifier) { logger.warn('reloadModuleHandlers: moduleInfo with identifier is required'); return; } if (!actionType || (actionType !== 'add' && actionType !== 'remove')) { logger.warn('reloadModuleHandlers: action must be "add" or "remove"'); return; } const G7Config = (window as any).G7Config; if (!G7Config) { logger.warn('reloadModuleHandlers: G7Config not available'); return; } const identifier = moduleData.identifier; try { if (actionType === 'add') { // 활성화: moduleAssets에 병합 if (moduleData.assets) { G7Config.moduleAssets = G7Config.moduleAssets || {}; G7Config.moduleAssets[identifier] = moduleData.assets; logger.log(`reloadModuleHandlers: Added assets for ${identifier}`); // JS 파일이 있으면 동적 로드 if (moduleData.assets.js) { const scriptUrl = moduleData.assets.js; const scriptId = `module-${identifier}`; // 이미 로드된 스크립트인지 확인 if (document.getElementById(scriptId)) { logger.warn(`reloadModuleHandlers: Script ${scriptId} already loaded`); return; } //