저장소 B(_global._local)를 동기화하는 두 지점이 저장소 A 계열 스냅샷을 그대로
넘겨 왔다. setGlobalState 는 _local 을 얕게 병합하므로 그 쓰기는 patch 가 아니라
통째 교체이며, A 가 아직 받지 못한 값 — CKEditor 가 setLocal({render:false,
selfManaged:true}) 로 B 에만 쓴 본문 — 이 예외도 로그도 없이 사라진다.
handleSetState COMPONENT 분기의 B 쓰기와 sequence 반환값을 함께 live B 기반으로
바꾼다. 요청 body 는 B 가 아니라 그 반환값에서 오므로, B 쓰기만 고치면 저장은
여전히 422 가 난다. 같은 형태의 setParentLocal 도 함께 정정했다.
트리거는 브레이크포인트 교차가 아니라 19px 폭 변경이다 — 모바일 URL바 숨김·
가상키보드·화면 회전이 모두 해당한다. 작성 화면은 422 로, 수정 화면은 성공
토스트와 함께 조용한 데이터 손실로 나타났다.
함께 담은 것:
- ckeditor5: 미리보기 입력이 설정으로 저장되던 문제, 본문만 고치면 저장 버튼이
비활성으로 남던 문제
- board: 일괄 적용 확인 창의 항목 목록이 통째로 렌더되지 않던 문제(표현식
평가기가 콜백 안 멤버 대입을 거부), 「금지어」 라벨 누락
- 확장 문서의 Playwright 명령이 코어 config 를 잡아 "No tests found" 로 조용히
0건이 지나가던 문제
7622 lines
295 KiB
TypeScript
7622 lines
295 KiB
TypeScript
/**
|
||
* 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 { extractSingleBinding } from './BindingShape';
|
||
import { hasPipes } from './PipeRegistry';
|
||
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 { addMissingLeafKeys } from './helpers/StateMerge';
|
||
import { triggerModalParentUpdate } from './ParentContextProvider';
|
||
import type { GlobalHeaderRule } from './LayoutLoader';
|
||
import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor';
|
||
import { isAbortError, isNetworkFailure } from './networkResilience';
|
||
|
||
const logger = createLogger('ActionDispatcher');
|
||
|
||
/**
|
||
* 액션 실패 시 화면에 띄울 문구를 결정합니다.
|
||
*
|
||
* 우선순위는 (1) 서버가 준 메시지 → (2) 네트워크 실패 안내 → (3) 내부 식별 문구다.
|
||
*
|
||
* 네트워크 실패(`TypeError: Failed to fetch`)와 요청 취소(AbortError)는 **응답 자체가 없어**
|
||
* 서버 메시지가 존재하지 않는다. 그때 내부 식별 문구(`Failed to execute action: apiCall`)를
|
||
* 그대로 토스트에 띄우면 운영자는 무슨 일이 일어났는지 알 수 없고 다국어도 적용되지 않는다.
|
||
*
|
||
* @param originalError 원래 발생한 에러
|
||
* @param handler 실패한 핸들러명
|
||
* @param serverMessage 서버 응답이 준 메시지 (없으면 undefined)
|
||
* @param fallbackMessage 위 둘이 없을 때 쓸 문구 (기본: 내부 식별 문구).
|
||
* `ActionError` 가 이미 고유 메시지를 들고 있으면 그것을 넘겨 보존한다.
|
||
* @return string 화면에 띄울 문구 ($t: 구문이면 호출부가 번역한다)
|
||
* @since engine-v1.54.6
|
||
*/
|
||
export function resolveActionFailureMessage(
|
||
originalError: unknown,
|
||
handler: string,
|
||
serverMessage?: string,
|
||
fallbackMessage?: string
|
||
): string {
|
||
if (serverMessage) {
|
||
return serverMessage;
|
||
}
|
||
|
||
if (isNetworkFailure(originalError) || isAbortError(originalError)) {
|
||
return '$t:core.errors.network_request_failed';
|
||
}
|
||
|
||
return fallbackMessage || `Failed to execute action: ${handler}`;
|
||
}
|
||
|
||
/**
|
||
* 프리뷰 모드에서 억제되는 핸들러 목록
|
||
*
|
||
* 프리뷰 페이지 이탈 또는 현재 세션 변경을 유발하는 핸들러를 정의합니다.
|
||
* 이 목록에 포함된 핸들러는 프리뷰 모드에서 실행되지 않고 로그만 남깁니다.
|
||
*
|
||
* @since engine-v1.26.1
|
||
*/
|
||
const PREVIEW_SUPPRESSED_HANDLERS: ReadonlySet<string> = new Set([
|
||
// 직접 네비게이션 — 현재 페이지를 떠남
|
||
'navigate',
|
||
'navigateBack',
|
||
'navigateForward',
|
||
'replaceUrl',
|
||
|
||
// 간접 네비게이션 — 페이지 리로드 또는 리다이렉트 유발
|
||
'refresh', // window.location.reload()
|
||
'logout', // 로그아웃 후 로그인 페이지로 리다이렉트
|
||
]);
|
||
|
||
/**
|
||
* 프리뷰 모드에서 억제되는 레이아웃 기능 목록
|
||
*
|
||
* 레이아웃 JSON의 최상위 기능 중 프리뷰 모드에서 비활성화할 대상입니다.
|
||
* redirect 속성 등 라우트 레벨에서 페이지 이탈을 유발하는 기능을 정의합니다.
|
||
*
|
||
* @since engine-v1.26.1
|
||
*/
|
||
const PREVIEW_SUPPRESSED_LAYOUT_FEATURES: ReadonlySet<string> = 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<string, any>;
|
||
/** 액션 성공 시 실행할 후속 액션 (단일 또는 배열) */
|
||
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<string, ActionDefinition>;
|
||
/** 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<string, any>;
|
||
/** 특정 경로의 상태를 설정 */
|
||
setState: (path: string, value: any) => void;
|
||
/** 특정 경로의 상태를 조회 */
|
||
getState: (path: string) => any;
|
||
/** 상태를 병합 모드에 따라 업데이트 */
|
||
mergeState: (updates: Record<string, any>, mergeMode?: 'replace' | 'shallow' | 'deep') => void;
|
||
}
|
||
|
||
/**
|
||
* 액션 컨텍스트
|
||
*/
|
||
export interface ActionContext {
|
||
/** 데이터 컨텍스트 */
|
||
data?: any;
|
||
/** 이벤트 객체 */
|
||
event?: Event;
|
||
/** 컴포넌트 props */
|
||
props?: Record<string, any>;
|
||
/** 현재 상태 */
|
||
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<string, boolean>;
|
||
|
||
/**
|
||
* 액션 핸들러 함수 타입
|
||
*/
|
||
export type ActionHandler = (
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
) => void | Promise<void>;
|
||
|
||
/**
|
||
* 액션 실행 결과
|
||
*/
|
||
export interface ActionResult {
|
||
success: boolean;
|
||
data?: any;
|
||
error?: Error;
|
||
}
|
||
|
||
/**
|
||
* 액션 에러 클래스
|
||
*/
|
||
export class ActionError extends Error {
|
||
/**
|
||
* 미등록 핸들러로 인한 에러 여부
|
||
*
|
||
* 확장 번들이 로드되지 않아 그 확장 소유 핸들러가 등록되지 않은 경우가 대표적이다.
|
||
* 사용자가 조치할 수 있는 일이 아니고 내부 식별자를 노출하게 되므로, 표시 계층은
|
||
* 이 플래그를 보고 errorHandling 정책(토스트 등)을 태우지 않는다.
|
||
*
|
||
* @since engine-v1.53.0
|
||
*/
|
||
public unknownHandler = false;
|
||
|
||
constructor(
|
||
message: string,
|
||
public action?: ActionDefinition,
|
||
public originalError?: Error
|
||
) {
|
||
super(message);
|
||
this.name = 'ActionError';
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// ActionDispatcher 클래스
|
||
// ============================================================================
|
||
|
||
/**
|
||
* DOM 이벤트 이름 → React prop 이름 매핑 (camelCase)
|
||
*
|
||
* 액션의 `type` 과 `event` 두 경로가 **같은 표**를 써야 한다. 한쪽만 매핑하면
|
||
* 같은 이벤트를 어떤 키로 적었는지에 따라 핸들러가 붙기도 하고 안 붙기도 한다.
|
||
*/
|
||
const DOM_EVENT_PROP_MAP: Record<string, string> = {
|
||
click: 'onClick',
|
||
change: 'onChange',
|
||
input: 'onInput',
|
||
submit: 'onSubmit',
|
||
focus: 'onFocus',
|
||
blur: 'onBlur',
|
||
keydown: 'onKeyDown',
|
||
keyup: 'onKeyUp',
|
||
keypress: 'onKeyPress',
|
||
mousedown: 'onMouseDown',
|
||
mouseup: 'onMouseUp',
|
||
mouseenter: 'onMouseEnter',
|
||
mouseleave: 'onMouseLeave',
|
||
scroll: 'onScroll',
|
||
// 드래그 앤 드롭 이벤트
|
||
dragstart: 'onDragStart',
|
||
drag: 'onDrag',
|
||
dragend: 'onDragEnd',
|
||
dragenter: 'onDragEnter',
|
||
dragover: 'onDragOver',
|
||
dragleave: 'onDragLeave',
|
||
drop: 'onDrop',
|
||
};
|
||
|
||
/**
|
||
* 이벤트 핸들러 관리 및 액션 실행 엔진
|
||
*
|
||
* @example
|
||
* const dispatcher = new ActionDispatcher({ navigate, setState });
|
||
* const handler = dispatcher.createHandler(actionDef, dataContext);
|
||
* <button onClick={handler}>클릭</button>
|
||
*/
|
||
export class ActionDispatcher {
|
||
/** 데이터 바인딩 엔진 */
|
||
private bindingEngine: DataBindingEngine;
|
||
|
||
/** 번역 엔진 */
|
||
private translationEngine?: TranslationEngine;
|
||
|
||
/** 번역 컨텍스트 */
|
||
private translationContext?: TranslationContext;
|
||
|
||
/** 커스텀 액션 핸들러 맵 */
|
||
private customHandlers: Map<string, ActionHandler> = new Map();
|
||
|
||
/** 기본 컨텍스트 */
|
||
private defaultContext: Partial<ActionContext>;
|
||
|
||
/** 전역 상태 업데이트 함수 (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<string, ReturnType<typeof setTimeout>> = new Map();
|
||
|
||
/**
|
||
* 대기 중인 debounce 액션의 즉시 실행 함수 맵
|
||
*
|
||
* 비디바운스 액션 실행 전에 대기 중인 debounce 액션을 즉시 실행(flush)하여
|
||
* state가 최신 상태가 되도록 합니다.
|
||
* Key 형식은 debounceTimers와 동일합니다.
|
||
*/
|
||
private pendingDebounceFlushers: Map<string, () => void> = new Map();
|
||
|
||
/**
|
||
* 디바운스 대기 중 누적된 객체 값
|
||
*
|
||
* _changedKeys 메타데이터가 포함된 이벤트의 변경분을 누적합니다.
|
||
* 이를 통해 다국어 입력 등 객체 값의 stale closure로 인한 키 유실을 방지합니다.
|
||
* Key: debounceKey
|
||
* @since engine-v1.28.0
|
||
*/
|
||
private debounceAccumulatedValues: Map<string, Record<string, any>> = new Map();
|
||
|
||
/**
|
||
* 전역 헤더 규칙 (레이아웃에서 설정)
|
||
*
|
||
* apiCall 핸들러 호출 시 패턴에 매칭되는 엔드포인트에 자동으로 헤더를 추가합니다.
|
||
*
|
||
* @since engine-v1.16.0
|
||
*/
|
||
private globalHeaders: GlobalHeaderRule[] = [];
|
||
|
||
/**
|
||
* 명명된 액션 정의 (레이아웃에서 설정)
|
||
*
|
||
* 레이아웃 JSON의 named_actions 섹션에서 정의된 재사용 가능한 액션입니다.
|
||
* 컴포넌트의 actionRef 속성으로 참조할 수 있습니다.
|
||
*
|
||
* @since engine-v1.19.0
|
||
*/
|
||
private namedActions: Record<string, ActionDefinition> = {};
|
||
|
||
/**
|
||
* 프리뷰 모드 여부
|
||
*
|
||
* 프리뷰 모드에서는 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<string, ReturnType<typeof setInterval>> = new Map();
|
||
|
||
/**
|
||
* ActionDispatcher 생성자
|
||
*/
|
||
constructor(
|
||
defaultContext: Partial<ActionContext> = {},
|
||
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<string, ActionDefinition>): 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<string, ActionDefinition> {
|
||
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<string> {
|
||
return PREVIEW_SUPPRESSED_HANDLERS;
|
||
}
|
||
|
||
/**
|
||
* 프리뷰 모드에서 억제되는 레이아웃 기능 목록을 반환합니다.
|
||
*
|
||
* @returns 억제 대상 레이아웃 기능의 ReadonlySet
|
||
* @since engine-v1.26.1
|
||
*/
|
||
public static getPreviewSuppressedLayoutFeatures(): ReadonlySet<string> {
|
||
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<string, any>,
|
||
): Record<string, string> {
|
||
const result: Record<string, string> = {};
|
||
|
||
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<ActionContext> = {
|
||
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<Element | null>((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;
|
||
}
|
||
|
||
// <script> 태그 동적 생성
|
||
const script = document.createElement('script');
|
||
script.id = scriptId;
|
||
script.src = scriptUrl;
|
||
script.async = true;
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
script.onload = () => {
|
||
logger.log(`reloadModuleHandlers: Script loaded successfully for ${identifier}`);
|
||
resolve();
|
||
};
|
||
script.onerror = () => {
|
||
logger.error(`reloadModuleHandlers: Failed to load script for ${identifier}`);
|
||
reject(new Error(`Failed to load module script: ${scriptUrl}`));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
|
||
// CSS 파일이 있으면 동적 로드
|
||
if (moduleData.assets.css) {
|
||
const cssUrl = moduleData.assets.css;
|
||
const linkId = `module-css-${identifier}`;
|
||
|
||
if (!document.getElementById(linkId)) {
|
||
const link = document.createElement('link');
|
||
link.id = linkId;
|
||
link.rel = 'stylesheet';
|
||
link.href = cssUrl;
|
||
document.head.appendChild(link);
|
||
logger.log(`reloadModuleHandlers: CSS loaded for ${identifier}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (actionType === 'remove') {
|
||
// 비활성화: moduleAssets에서 제거
|
||
if (G7Config.moduleAssets && G7Config.moduleAssets[identifier]) {
|
||
delete G7Config.moduleAssets[identifier];
|
||
logger.log(`reloadModuleHandlers: Removed assets for ${identifier}`);
|
||
}
|
||
|
||
// 로드된 스크립트 제거
|
||
const scriptId = `module-${identifier}`;
|
||
const script = document.getElementById(scriptId);
|
||
if (script) {
|
||
script.remove();
|
||
logger.log(`reloadModuleHandlers: Removed script for ${identifier}`);
|
||
}
|
||
|
||
// CSS 제거
|
||
const linkId = `module-css-${identifier}`;
|
||
const link = document.getElementById(linkId);
|
||
if (link) {
|
||
link.remove();
|
||
logger.log(`reloadModuleHandlers: Removed CSS for ${identifier}`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`reloadModuleHandlers: Failed to ${actionType} module assets`, error);
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
// reloadPluginHandlers: 플러그인 핸들러 동적 로드/제거
|
||
// 플러그인 활성화/비활성화 시 window.G7Config.pluginAssets 병합/제거 및 JS 로드
|
||
this.registerHandler('reloadPluginHandlers', async (action: ActionDefinition, _context: ActionContext) => {
|
||
if (typeof window === 'undefined') {
|
||
logger.warn('reloadPluginHandlers: window is not available');
|
||
return;
|
||
}
|
||
|
||
const { pluginInfo, action: actionType } = action.params || {};
|
||
|
||
if (!pluginInfo) {
|
||
logger.warn('reloadPluginHandlers: pluginInfo is required');
|
||
return;
|
||
}
|
||
|
||
// API 응답의 data 래퍼 처리 (ResponseHelper 응답 구조 호환)
|
||
// 응답 구조: { success, message, data: { identifier, assets, ... } }
|
||
const pluginData = pluginInfo.data || pluginInfo;
|
||
|
||
if (!pluginData.identifier) {
|
||
logger.warn('reloadPluginHandlers: pluginInfo with identifier is required');
|
||
return;
|
||
}
|
||
|
||
if (!actionType || (actionType !== 'add' && actionType !== 'remove')) {
|
||
logger.warn('reloadPluginHandlers: action must be "add" or "remove"');
|
||
return;
|
||
}
|
||
|
||
const G7Config = (window as any).G7Config;
|
||
if (!G7Config) {
|
||
logger.warn('reloadPluginHandlers: G7Config not available');
|
||
return;
|
||
}
|
||
|
||
const identifier = pluginData.identifier;
|
||
|
||
try {
|
||
if (actionType === 'add') {
|
||
// 활성화: pluginAssets에 병합
|
||
if (pluginData.assets) {
|
||
G7Config.pluginAssets = G7Config.pluginAssets || {};
|
||
G7Config.pluginAssets[identifier] = pluginData.assets;
|
||
|
||
logger.log(`reloadPluginHandlers: Added assets for ${identifier}`);
|
||
|
||
// JS 파일이 있으면 동적 로드
|
||
if (pluginData.assets.js) {
|
||
const scriptUrl = pluginData.assets.js;
|
||
const scriptId = `plugin-${identifier}`;
|
||
|
||
// 이미 로드된 스크립트인지 확인
|
||
if (document.getElementById(scriptId)) {
|
||
logger.warn(`reloadPluginHandlers: Script ${scriptId} already loaded`);
|
||
return;
|
||
}
|
||
|
||
// <script> 태그 동적 생성
|
||
const script = document.createElement('script');
|
||
script.id = scriptId;
|
||
script.src = scriptUrl;
|
||
script.async = true;
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
script.onload = () => {
|
||
logger.log(`reloadPluginHandlers: Script loaded successfully for ${identifier}`);
|
||
resolve();
|
||
};
|
||
script.onerror = () => {
|
||
logger.error(`reloadPluginHandlers: Failed to load script for ${identifier}`);
|
||
reject(new Error(`Failed to load plugin script: ${scriptUrl}`));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
|
||
// CSS 파일이 있으면 동적 로드
|
||
if (pluginData.assets.css) {
|
||
const cssUrl = pluginData.assets.css;
|
||
const linkId = `plugin-css-${identifier}`;
|
||
|
||
if (!document.getElementById(linkId)) {
|
||
const link = document.createElement('link');
|
||
link.id = linkId;
|
||
link.rel = 'stylesheet';
|
||
link.href = cssUrl;
|
||
document.head.appendChild(link);
|
||
logger.log(`reloadPluginHandlers: CSS loaded for ${identifier}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (actionType === 'remove') {
|
||
// 비활성화: pluginAssets에서 제거
|
||
if (G7Config.pluginAssets && G7Config.pluginAssets[identifier]) {
|
||
delete G7Config.pluginAssets[identifier];
|
||
logger.log(`reloadPluginHandlers: Removed assets for ${identifier}`);
|
||
}
|
||
|
||
// 로드된 스크립트 제거
|
||
const scriptId = `plugin-${identifier}`;
|
||
const script = document.getElementById(scriptId);
|
||
if (script) {
|
||
script.remove();
|
||
logger.log(`reloadPluginHandlers: Removed script for ${identifier}`);
|
||
}
|
||
|
||
// CSS 제거
|
||
const linkId = `plugin-css-${identifier}`;
|
||
const link = document.getElementById(linkId);
|
||
if (link) {
|
||
link.remove();
|
||
logger.log(`reloadPluginHandlers: Removed CSS for ${identifier}`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`reloadPluginHandlers: Failed to ${actionType} plugin assets`, error);
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
// showErrorPage: 에러 페이지 표시
|
||
// ErrorPageHandler를 통해 에러 코드에 맞는 레이아웃을 로드하고 렌더링합니다.
|
||
let _showErrorPageActive = false;
|
||
this.registerHandler('showErrorPage', async (action: ActionDefinition, _context: ActionContext) => {
|
||
// 중복 호출 방지: 여러 데이터소스가 동시에 에러를 반환할 때
|
||
// 각각이 독립적으로 errorHandling을 트리거하여 showErrorPage가 복수 호출됨
|
||
// 첫 번째 호출만 처리하고 나머지는 무시
|
||
if (_showErrorPageActive) {
|
||
logger.log('showErrorPage: Already active, skipping duplicate call');
|
||
return;
|
||
}
|
||
_showErrorPageActive = true;
|
||
|
||
if (typeof window === 'undefined') {
|
||
_showErrorPageActive = false;
|
||
logger.warn('showErrorPage: window is not available');
|
||
return;
|
||
}
|
||
|
||
const templateApp = (window as any).__templateApp;
|
||
if (!templateApp) {
|
||
_showErrorPageActive = false;
|
||
logger.warn('showErrorPage: TemplateApp not initialized');
|
||
return;
|
||
}
|
||
|
||
const errorPageHandler = templateApp.getErrorPageHandler?.();
|
||
if (!errorPageHandler) {
|
||
_showErrorPageActive = false;
|
||
logger.warn('showErrorPage: ErrorPageHandler not available');
|
||
return;
|
||
}
|
||
|
||
// params에서 에러 코드 및 옵션 추출
|
||
const errorCode = action.params?.errorCode || 500;
|
||
const target = action.params?.target || 'content';
|
||
let containerId = action.params?.containerId;
|
||
|
||
// containerId가 지정되지 않은 경우 target에 따라 결정
|
||
if (!containerId) {
|
||
containerId = target === 'full' ? 'app' : 'main_content';
|
||
}
|
||
|
||
// 레이아웃 경로가 지정된 경우 (향후 확장용)
|
||
// const layout = action.params?.layout;
|
||
|
||
logger.log('showErrorPage:', {
|
||
errorCode,
|
||
target,
|
||
containerId,
|
||
});
|
||
|
||
/**
|
||
* 컨테이너가 DOM에 존재하는지 확인하고, 없으면 MutationObserver로 대기
|
||
* progressive 로딩 시 컨테이너가 아직 렌더링되지 않았을 수 있음
|
||
*/
|
||
const waitForContainer = (id: string, timeout: number = 60000): Promise<Element> => {
|
||
return new Promise((resolve, reject) => {
|
||
// 이미 존재하면 즉시 반환
|
||
const existing = document.getElementById(id);
|
||
if (existing) {
|
||
resolve(existing);
|
||
return;
|
||
}
|
||
|
||
logger.log(`showErrorPage: Waiting for container #${id}...`);
|
||
|
||
// MutationObserver로 DOM 변화 감지
|
||
let timeoutId: ReturnType<typeof setTimeout>;
|
||
const observer = new MutationObserver((_mutations, obs) => {
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
logger.log(`showErrorPage: Container #${id} found`);
|
||
obs.disconnect();
|
||
clearTimeout(timeoutId);
|
||
resolve(element);
|
||
}
|
||
});
|
||
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true,
|
||
});
|
||
|
||
// 타임아웃 설정
|
||
timeoutId = setTimeout(() => {
|
||
observer.disconnect();
|
||
reject(new Error(`Container #${id} not found within ${timeout}ms`));
|
||
}, timeout);
|
||
});
|
||
};
|
||
|
||
try {
|
||
// 컨테이너가 준비될 때까지 대기
|
||
await waitForContainer(containerId);
|
||
|
||
// 에러 페이지 렌더링 전 레이아웃 errorHandling 임시 해제
|
||
// 에러 페이지가 _user_base를 extends할 경우, 데이터소스 에러(예: user 401)가
|
||
// ErrorHandlingResolver 싱글톤의 레이아웃 errorHandling을 재트리거하여 무한 루프 발생 방지
|
||
const resolver = getErrorHandlingResolver();
|
||
const savedLayoutConfig = (resolver as any).layoutErrorHandling;
|
||
resolver.clearLayoutConfig();
|
||
|
||
try {
|
||
const success = await errorPageHandler.renderError(errorCode, containerId);
|
||
if (!success) {
|
||
logger.warn(`showErrorPage: Failed to render error page for code ${errorCode}`);
|
||
}
|
||
} finally {
|
||
// 렌더링 완료 후 레이아웃 errorHandling 복원
|
||
resolver.setLayoutConfig(savedLayoutConfig);
|
||
}
|
||
} catch (error) {
|
||
logger.error('showErrorPage: Error rendering error page:', error);
|
||
throw error;
|
||
} finally {
|
||
_showErrorPageActive = false;
|
||
}
|
||
});
|
||
|
||
// emitEvent: 컴포넌트 이벤트 발생
|
||
// G7Core.componentEvent.emit()을 통해 이벤트를 브로드캐스트하고 모든 리스너의 응답을 기다립니다.
|
||
// 파일 업로드, 폼 검증, 컴포넌트 간 통신 등 다양한 용도로 사용할 수 있습니다.
|
||
//
|
||
// @example 레이아웃 JSON에서 사용:
|
||
// {
|
||
// "handler": "emitEvent",
|
||
// "params": {
|
||
// "event": "upload:site_logo", // 이벤트명 (컴포넌트에서 구독)
|
||
// "data": { "collection": "site_logo" } // 전달할 데이터 (선택)
|
||
// }
|
||
// }
|
||
//
|
||
// @example 컴포넌트에서 구독:
|
||
// useEffect(() => {
|
||
// const unsubscribe = G7Core.componentEvent.on('upload:site_logo', async (data) => {
|
||
// const result = await uploadFiles();
|
||
// return result; // emitEvent 호출자에게 반환
|
||
// });
|
||
// return () => unsubscribe();
|
||
// }, []);
|
||
//
|
||
// 리스너의 결과는 _local._eventResult에 저장되어 후속 액션에서 접근 가능합니다.
|
||
this.registerHandler('emitEvent', async (action: ActionDefinition, context: ActionContext) => {
|
||
const eventName = action.params?.event;
|
||
const eventData = action.params?.data;
|
||
|
||
if (!eventName) {
|
||
logger.warn('emitEvent: event parameter is required');
|
||
return;
|
||
}
|
||
|
||
if (typeof window === 'undefined') {
|
||
logger.warn('emitEvent: window is not available');
|
||
return;
|
||
}
|
||
|
||
const G7Core = (window as any).G7Core;
|
||
if (!G7Core?.componentEvent?.emit) {
|
||
logger.warn('emitEvent: G7Core.componentEvent is not available');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
logger.log(`emitEvent: Emitting "${eventName}"`, eventData);
|
||
|
||
// 이벤트 데이터에 컨텍스트 정보 병합
|
||
const mergedData = {
|
||
...eventData,
|
||
_context: {
|
||
data: context.data,
|
||
state: context.state,
|
||
},
|
||
};
|
||
|
||
const results = await G7Core.componentEvent.emit(eventName, mergedData);
|
||
|
||
// 결과가 없으면 (리스너가 없으면) 경고
|
||
if (!results || results.length === 0) {
|
||
logger.warn(`emitEvent: No listeners found for "${eventName}"`);
|
||
} else {
|
||
logger.log(`emitEvent: Event "${eventName}" completed with ${results.length} listener(s)`, results);
|
||
}
|
||
|
||
// 결과를 _local._eventResult에 저장하여 후속 액션에서 접근 가능하게 함
|
||
if (this.globalStateUpdater) {
|
||
const currentGlobalLocal = (G7Core?.state?.get() || {})._local || {};
|
||
const eventResult = {
|
||
event: eventName,
|
||
success: true,
|
||
data: results.length === 1 ? results[0] : results,
|
||
listeners: results.length,
|
||
};
|
||
// 글로벌 _local 갱신 (기존 동작 유지: 글로벌 _local 기준 머지)
|
||
this.globalStateUpdater({
|
||
_local: { ...currentGlobalLocal, _eventResult: eventResult },
|
||
});
|
||
|
||
// engine-v1.50.0: sequence 내 emitEvent 결과를 후속 액션이 참조할 수 있도록
|
||
// __g7SequenceLocalSync 에 병합 스냅샷을 실는다.
|
||
// 배경: handleSequence 의 currentState 는 setState 핸들러와 setLocal() 이 설정한
|
||
// __g7SequenceLocalSync 로만 갱신된다(트러블슈팅 사례 24). emitEvent 는 globalStateUpdater
|
||
// 로만 _local 을 갱신했기에 같은 sequence 의 다음 액션(apiCall body / setState)이
|
||
// _eventResult 와 리스너가 갱신한 _local(예: form.images)을 보지 못했다.
|
||
// (FileUploader onUploadComplete 가 업로드 직후 form.images 를 갱신해도 저장 PUT body 의
|
||
// _local.form 스냅샷에 미반영 → 백엔드 syncImages 가 방금 올린 이미지를 삭제하는 회귀.)
|
||
//
|
||
// base 는 시퀀스가 추적 중인 _local(context.state) 우선 — sequence 내 in-flight
|
||
// setState 변경(예: isSaving)을 보존한다. 시퀀스 밖(standalone emitEvent)에서는
|
||
// context.state 가 없거나 부분적일 수 있어 글로벌 _local 로 폴백한다.
|
||
const syncBase = (context.state && typeof context.state === 'object' && !Array.isArray(context.state))
|
||
? context.state
|
||
: currentGlobalLocal;
|
||
(window as any).__g7SequenceLocalSync = { ...syncBase, _eventResult: eventResult };
|
||
}
|
||
} catch (error) {
|
||
logger.error(`emitEvent: Event "${eventName}" failed`, error);
|
||
|
||
// 에러도 _local._eventResult에 저장
|
||
if (this.globalStateUpdater) {
|
||
const currentState = G7Core?.state?.get() || {};
|
||
this.globalStateUpdater({
|
||
_local: {
|
||
...currentState._local,
|
||
_eventResult: {
|
||
event: eventName,
|
||
success: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
// updateProductField: 상품 목록에서 개별 필드 인라인 수정
|
||
// products 데이터 소스의 로컬 데이터를 업데이트하고, 변경된 상품 ID를 _local.modifiedProductIds에 추적합니다.
|
||
// 일괄 변경 버튼 클릭 시 해당 목록의 상품들만 API로 전송합니다.
|
||
//
|
||
// @example 레이아웃 JSON에서 사용:
|
||
// {
|
||
// "handler": "updateProductField",
|
||
// "params": {
|
||
// "productId": "{{row.id}}",
|
||
// "field": "stock_quantity",
|
||
// "value": "{{$event.target.value}}"
|
||
// }
|
||
// }
|
||
this.registerHandler('updateProductField', async (action: ActionDefinition, _context: ActionContext) => {
|
||
const { productId, field, value } = action.params || {};
|
||
|
||
if (!productId || !field) {
|
||
logger.warn('updateProductField: productId and field are required');
|
||
return;
|
||
}
|
||
|
||
if (typeof window === 'undefined') {
|
||
logger.warn('updateProductField: window is not available');
|
||
return;
|
||
}
|
||
|
||
const G7Core = (window as any).G7Core;
|
||
if (!G7Core?.state?.get || !this.globalStateUpdater) {
|
||
logger.warn('updateProductField: G7Core.state or globalStateUpdater is not available');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const currentState = G7Core.state.get() || {};
|
||
const productsData = currentState.products?.data?.data || [];
|
||
|
||
// 상품 목록에서 해당 상품 찾아서 필드 업데이트
|
||
const updatedProducts = productsData.map((product: any) => {
|
||
if (product.id === productId) {
|
||
return {
|
||
...product,
|
||
[field]: value,
|
||
_modified: true, // 수정됨 표시
|
||
};
|
||
}
|
||
return product;
|
||
});
|
||
|
||
// 변경된 상품 ID 추적
|
||
const modifiedProductIds = new Set(currentState._local?.modifiedProductIds || []);
|
||
modifiedProductIds.add(productId);
|
||
|
||
// 상태 업데이트 (products 데이터 + 수정 추적)
|
||
this.globalStateUpdater({
|
||
products: {
|
||
...currentState.products,
|
||
data: {
|
||
...currentState.products?.data,
|
||
data: updatedProducts,
|
||
},
|
||
},
|
||
_local: {
|
||
...currentState._local,
|
||
modifiedProductIds: Array.from(modifiedProductIds),
|
||
},
|
||
});
|
||
|
||
logger.log(`updateProductField: Updated product ${productId}, field: ${field}, value:`, value);
|
||
} catch (error) {
|
||
logger.error('updateProductField: Error updating product field', error);
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
// updateOptionField: 상품 옵션 필드 인라인 수정
|
||
// 상품 옵션의 필드를 수정하고 변경 추적합니다.
|
||
this.registerHandler('updateOptionField', async (action: ActionDefinition, _context: ActionContext) => {
|
||
const { productId, optionId, field, value } = action.params || {};
|
||
|
||
if (!productId || !optionId || !field) {
|
||
logger.warn('updateOptionField: productId, optionId and field are required');
|
||
return;
|
||
}
|
||
|
||
if (typeof window === 'undefined') {
|
||
logger.warn('updateOptionField: window is not available');
|
||
return;
|
||
}
|
||
|
||
const G7Core = (window as any).G7Core;
|
||
if (!G7Core?.state?.get || !this.globalStateUpdater) {
|
||
logger.warn('updateOptionField: G7Core.state or globalStateUpdater is not available');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const currentState = G7Core.state.get() || {};
|
||
const productsData = currentState.products?.data?.data || [];
|
||
|
||
// 상품 목록에서 해당 상품과 옵션 찾아서 필드 업데이트
|
||
const updatedProducts = productsData.map((product: any) => {
|
||
if (product.id === productId && product.options) {
|
||
const updatedOptions = product.options.map((option: any) => {
|
||
if (option.id === optionId) {
|
||
return {
|
||
...option,
|
||
[field]: value,
|
||
_modified: true,
|
||
};
|
||
}
|
||
return option;
|
||
});
|
||
return {
|
||
...product,
|
||
options: updatedOptions,
|
||
_modified: true,
|
||
};
|
||
}
|
||
return product;
|
||
});
|
||
|
||
// 변경된 상품 ID 추적
|
||
const modifiedProductIds = new Set(currentState._local?.modifiedProductIds || []);
|
||
modifiedProductIds.add(productId);
|
||
|
||
// 상태 업데이트
|
||
this.globalStateUpdater({
|
||
products: {
|
||
...currentState.products,
|
||
data: {
|
||
...currentState.products?.data,
|
||
data: updatedProducts,
|
||
},
|
||
},
|
||
_local: {
|
||
...currentState._local,
|
||
modifiedProductIds: Array.from(modifiedProductIds),
|
||
},
|
||
});
|
||
|
||
logger.log(`updateOptionField: Updated product ${productId} option ${optionId}, field: ${field}, value:`, value);
|
||
} catch (error) {
|
||
logger.error('updateOptionField: Error updating option field', error);
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
// setLocale: 언어 변경 핸들러
|
||
// TemplateApp.changeLocale()을 호출하여 언어 변경, DB 저장, UI 리렌더링을 수행합니다.
|
||
this.registerHandler('setLocale', async (action: ActionDefinition, _context: ActionContext) => {
|
||
const locale = action.target;
|
||
|
||
if (!locale || typeof locale !== 'string') {
|
||
logger.warn('setLocale: Invalid locale:', locale);
|
||
return;
|
||
}
|
||
|
||
// TemplateApp 인스턴스를 통해 언어 변경
|
||
const templateApp = (window as any).__templateApp;
|
||
if (templateApp && typeof templateApp.changeLocale === 'function') {
|
||
try {
|
||
await templateApp.changeLocale(locale);
|
||
logger.log('setLocale: Locale changed to', locale);
|
||
} catch (error) {
|
||
logger.error('setLocale: Failed to change locale:', error);
|
||
// 폴백: 페이지 새로고침
|
||
window.location.reload();
|
||
}
|
||
} else {
|
||
// TemplateApp이 없으면 localStorage에 저장 후 새로고침
|
||
logger.warn('setLocale: TemplateApp not found, falling back to page reload');
|
||
try {
|
||
localStorage.setItem('g7_locale', locale);
|
||
} catch {
|
||
// ignore storage errors
|
||
}
|
||
window.location.reload();
|
||
}
|
||
});
|
||
|
||
// suppress: 에러 전파를 의도적으로 방지하는 no-op 핸들러
|
||
// 데이터소스/apiCall의 errorHandling에서 특정 에러 코드를 상위 레벨로 전파하지 않을 때 사용
|
||
// 예: 비회원의 /api/auth/user 401은 정상 동작이므로 레이아웃 errorHandling으로 전파 방지
|
||
// @since engine-v1.21.0
|
||
this.registerHandler('suppress', async () => {
|
||
logger.log('suppress: Error intentionally suppressed');
|
||
});
|
||
|
||
// DevTools에 빌트인 핸들러 메타데이터 일괄 등록
|
||
this.registerBuiltInHandlerMetadata();
|
||
}
|
||
|
||
/**
|
||
* 빌트인 핸들러 메타데이터를 DevTools에 등록합니다.
|
||
*/
|
||
private registerBuiltInHandlerMetadata(): void {
|
||
const devTools = getDevTools();
|
||
if (!devTools?.isEnabled()) return;
|
||
|
||
const builtInHandlers: Array<{ name: string; description: string }> = [
|
||
{ name: 'refetchDataSource', description: '데이터 소스를 다시 fetch합니다' },
|
||
{ name: 'appendDataSource', description: '데이터 소스에 새 데이터를 병합합니다 (무한 스크롤용)' },
|
||
{ name: 'updateDataSource', description: 'API 응답으로 데이터 소스를 직접 업데이트합니다 (refetch 대체)' },
|
||
{ name: 'reloadRoutes', description: '라우트를 다시 로드합니다' },
|
||
{ name: 'refresh', description: '현재 페이지를 새로고침합니다' },
|
||
{ name: 'remount', description: '컴포넌트를 리마운트합니다' },
|
||
{ name: 'reloadTranslations', description: '다국어 파일을 다시 로드합니다' },
|
||
{ name: 'showErrorPage', description: '에러 페이지를 표시합니다' },
|
||
{ name: 'emitEvent', description: '이벤트를 발생시킵니다' },
|
||
{ name: 'updateProductField', description: '상품 필드를 인라인 수정합니다' },
|
||
{ name: 'updateOptionField', description: '상품 옵션 필드를 인라인 수정합니다' },
|
||
{ name: 'setLocale', description: '언어를 변경합니다 (DB 저장 + UI 리렌더링)' },
|
||
];
|
||
|
||
for (const handler of builtInHandlers) {
|
||
devTools.trackHandlerRegistration(handler.name, 'built-in', handler.description);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 이벤트 핸들러를 생성합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param dataContext 데이터 컨텍스트
|
||
* @param componentContext 컴포넌트 컨텍스트 (state, setState)
|
||
*/
|
||
createHandler(
|
||
action: ActionDefinition,
|
||
dataContext?: any,
|
||
componentContext?: { state?: any; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null }
|
||
): (event: Event) => void {
|
||
logger.log('createHandler called for:', action.handler, action.type);
|
||
|
||
// DevTools: 핸들러 생성 시점 상태 캡처 (stale closure 감지용)
|
||
const devTools = (window as any).__g7DevTools;
|
||
const handlerCreatedAt = Date.now();
|
||
const capturedLocalState = componentContext?.state ? { ...componentContext.state } : null;
|
||
const handlerId = `handler_${action.handler}_${handlerCreatedAt}`;
|
||
|
||
return async (event: Event) => {
|
||
logger.log('Handler invoked for:', action.handler, 'event:', event.type);
|
||
|
||
// DevTools: 핸들러 실행 시점에 stale closure 감지
|
||
if (devTools?.isEnabled?.() && capturedLocalState) {
|
||
const G7Core = (window as any).G7Core;
|
||
const currentLocalState = G7Core?.state?.getLocal?.() ?? componentContext?.state ?? {};
|
||
const timeDiff = Date.now() - handlerCreatedAt;
|
||
|
||
// 캡처된 상태와 현재 상태 비교
|
||
for (const key of Object.keys(capturedLocalState)) {
|
||
const capturedValue = capturedLocalState[key];
|
||
const currentValue = currentLocalState[key];
|
||
|
||
// 값이 다르고 100ms 이상 경과했으면 stale closure 경고
|
||
if (capturedValue !== currentValue && timeDiff > 100) {
|
||
devTools.trackStaleClosureWarning?.({
|
||
type: 'event-handler-stale',
|
||
location: `createHandler(${action.handler})`,
|
||
capturedPath: `_local.${key}`,
|
||
capturedValue,
|
||
capturedAt: handlerCreatedAt,
|
||
currentValue,
|
||
actionId: handlerId,
|
||
stackTrace: new Error().stack,
|
||
});
|
||
logger.warn(
|
||
`[Stale Closure] _local.${key} changed after handler creation:`,
|
||
`captured="${capturedValue}" → current="${currentValue}" (${timeDiff}ms ago)`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 기본 이벤트 동작 방지 (폼 제출 시 페이지 리로드 방지)
|
||
// 단, change 이벤트에서는 preventDefault()를 호출하면 안됨
|
||
// 체크박스/라디오/input의 change 이벤트에서 preventDefault() 호출 시
|
||
// 네이티브 상태 변경이 취소되어 React 상태와 불일치 발생
|
||
if (event.type !== 'change') {
|
||
event.preventDefault();
|
||
}
|
||
|
||
// 폼 데이터 추출 (submit 이벤트인 경우)
|
||
let formData: Record<string, any> = {};
|
||
if (
|
||
event.type === 'submit' &&
|
||
event.target instanceof HTMLFormElement
|
||
) {
|
||
const form = event.target as HTMLFormElement;
|
||
const formDataObj = new FormData(form);
|
||
|
||
// FormData를 객체로 변환
|
||
formDataObj.forEach((value, key) => {
|
||
formData[key] = value;
|
||
});
|
||
}
|
||
|
||
// 컨텍스트 병합 (폼 데이터 + 컴포넌트 컨텍스트 포함)
|
||
const context: ActionContext = {
|
||
...this.defaultContext,
|
||
data: {
|
||
...dataContext,
|
||
form: formData, // 폼 데이터를 form 객체로 추가
|
||
// _local: 레이아웃 수준의 로컬 상태 (componentContext.state)
|
||
_local: componentContext?.state || {},
|
||
// $event: 이벤트 객체를 data에 추가 ({{$event.target.value}} 바인딩 지원)
|
||
$event: event,
|
||
},
|
||
event,
|
||
// 컴포넌트 컨텍스트 병합
|
||
...(componentContext && {
|
||
state: componentContext.state,
|
||
setState: componentContext.setState,
|
||
}),
|
||
// 격리된 상태 컨텍스트 (isolatedState 속성이 있는 컴포넌트에서 제공)
|
||
isolatedContext: componentContext?.isolatedContext,
|
||
};
|
||
|
||
// 확인 메시지 표시
|
||
if (action.confirm) {
|
||
let message = this.resolveValue(action.confirm, context.data);
|
||
|
||
// $t: 다국어 구문 처리
|
||
if (this.translationEngine && this.translationContext && message.startsWith('$t:')) {
|
||
message = this.translationEngine.resolveTranslations(
|
||
message,
|
||
this.translationContext,
|
||
context.data
|
||
);
|
||
}
|
||
|
||
if (!confirm(message)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// G7Core.state.setLocal(), G7Core.modal.open() 등에서 사용할 수 있도록 현재 컨텍스트 저장
|
||
// engine-v1.16.0: componentContext + context.data를 함께 저장하여 $parent 바인딩 지원
|
||
const previousActionContext = (window as any).__g7ActionContext;
|
||
(window as any).__g7ActionContext = {
|
||
...componentContext,
|
||
// context.data에는 _global, _local, _computed 등 전체 데이터 컨텍스트가 포함됨
|
||
// G7Core.dispatch()에서 openModal 등 호출 시 이 데이터가 필요함
|
||
data: context.data,
|
||
};
|
||
|
||
try {
|
||
// 액션 실행
|
||
await this.executeAction(action, context);
|
||
} finally {
|
||
// 액션 완료 후 이전 컨텍스트 복원
|
||
(window as any).__g7ActionContext = previousActionContext;
|
||
}
|
||
} catch (error) {
|
||
logger.error('Action execution failed:', error);
|
||
|
||
// 에러 액션 실행 (컴포넌트 컨텍스트 포함)
|
||
if (action.onError) {
|
||
// 에러 객체를 data에 포함시켜 바인딩 가능하게 함
|
||
const errorData = error instanceof ActionError && error.originalError
|
||
? error.originalError
|
||
: error;
|
||
|
||
const errorContext = {
|
||
...this.defaultContext,
|
||
data: {
|
||
...dataContext,
|
||
error: errorData, // 에러 객체 추가
|
||
// _local: 레이아웃 수준의 로컬 상태 (componentContext.state)
|
||
_local: componentContext?.state || {},
|
||
// $event: 이벤트 객체를 data에 추가
|
||
$event: event,
|
||
},
|
||
event,
|
||
// 컴포넌트 컨텍스트 병합 (setState를 통해 에러 상태 업데이트 가능)
|
||
...(componentContext && {
|
||
state: componentContext.state,
|
||
setState: componentContext.setState,
|
||
}),
|
||
};
|
||
|
||
// onError가 배열인 경우 순차 실행
|
||
const errorActions = Array.isArray(action.onError) ? action.onError : [action.onError];
|
||
for (const errorAction of errorActions) {
|
||
await this.executeAction(errorAction, errorContext);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 액션 ID를 생성합니다.
|
||
*
|
||
* 핸들러 타입, 타겟, 타임스탬프를 조합하여 고유한 액션 ID를 생성합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param target 해석된 타겟
|
||
*/
|
||
private generateActionId(action: ActionDefinition, target?: string): string {
|
||
const handlerPart = action.handler;
|
||
const targetPart = target || 'no-target';
|
||
const timestamp = Date.now();
|
||
const random = Math.random().toString(36).substring(2, 9);
|
||
|
||
return `${handlerPart}_${targetPart.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}_${random}`;
|
||
}
|
||
|
||
/**
|
||
* 액션을 실행합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async executeAction(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<ActionResult> {
|
||
// actionRef 해석 - named_actions 참조를 실제 액션 정의로 변환
|
||
action = this.resolveActionRef(action);
|
||
|
||
// 동적 핸들러 이름 해석 — handler 가 `{{...}}` 바인딩이면 컨텍스트로 먼저 해석한다.
|
||
// 백엔드 응답이 호출할 핸들러 풀네임을 내려주는 provider-agnostic 디스패치
|
||
// (예: 결제 진입 `handler: "{{response.data.pg_payment_handler}}"`)를 지원한다.
|
||
// 빌트인 26종은 camelCase 리터럴이라 `{{` 미포함 → 해석 분기 미진입(무영향).
|
||
// resolveActionRef 직후·프리뷰 억제 체크·switch 보다 앞에 두어 (1) 빌트인 라우팅이
|
||
// 해석된 이름으로 매칭되고 (2) PREVIEW_SUPPRESSED_HANDLERS 판정도 해석된 이름으로
|
||
// 이뤄지게 한다(편집기 프리뷰 정합). nested(conditions/sequence)도 동일 executeAction
|
||
// 경유라 자동 적용. @since engine-v1.50.0
|
||
if (typeof action.handler === 'string' && action.handler.includes('{{')) {
|
||
const resolvedHandler = this.evaluateExpression(action.handler, context.data);
|
||
action = { ...action, handler: resolvedHandler == null ? '' : String(resolvedHandler) };
|
||
}
|
||
|
||
// DevTools 액션 로깅 시작
|
||
const devTools = getDevTools();
|
||
const devToolsActionId = devTools?.isEnabled() ? `action_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` : undefined;
|
||
const startTime = performance.now();
|
||
|
||
// DevTools: 해석된 params를 추적하기 위해 별도 저장
|
||
let devToolsResolvedParams: Record<string, any> | undefined;
|
||
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
params: this.sanitizeForDevTools(action.params),
|
||
context: this.sanitizeForDevTools({
|
||
hasState: !!context.state,
|
||
hasSetState: !!context.setState,
|
||
dataKeys: context.data ? Object.keys(context.data) : [],
|
||
}),
|
||
startTime,
|
||
status: 'started',
|
||
});
|
||
}
|
||
|
||
// if 조건 확인 - 조건이 false이면 액션 건너뜀
|
||
if (action.if !== undefined) {
|
||
try {
|
||
const conditionResult: any = this.resolveValue(action.if, context.data);
|
||
// resolveValue가 문자열 "false"/"true"를 반환할 수 있으므로 명시적 변환
|
||
const isTruthy = conditionResult === true || conditionResult === 'true' ||
|
||
(conditionResult && conditionResult !== 'false' && conditionResult !== '0' && conditionResult !== false);
|
||
logger.log('[executeAction] if condition result:', action.handler, isTruthy);
|
||
if (!isTruthy) {
|
||
// DevTools: 조건 불충족으로 스킵됨
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
startTime,
|
||
endTime: performance.now(),
|
||
duration: performance.now() - startTime,
|
||
status: 'success',
|
||
result: { skipped: true, reason: 'if condition false' },
|
||
});
|
||
}
|
||
return { success: true, data: undefined };
|
||
}
|
||
} catch (error) {
|
||
logger.error('[executeAction] if condition error:', action.handler, error);
|
||
// DevTools: 조건 평가 에러
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
startTime,
|
||
endTime: performance.now(),
|
||
duration: performance.now() - startTime,
|
||
status: 'error',
|
||
error: {
|
||
name: 'ConditionEvaluationError',
|
||
message: error instanceof Error ? error.message : String(error),
|
||
stack: error instanceof Error ? error.stack : undefined,
|
||
},
|
||
});
|
||
}
|
||
return { success: true, data: undefined };
|
||
}
|
||
}
|
||
|
||
// 액션 ID 생성 (API 호출인 경우에만)
|
||
let actionId: string | undefined;
|
||
|
||
try {
|
||
// 파라미터 바인딩
|
||
const resolvedParams = this.resolveParams(action.params, context.data);
|
||
|
||
// DevTools: 해석된 params 저장
|
||
if (devToolsActionId) {
|
||
devToolsResolvedParams = this.sanitizeForDevTools(resolvedParams);
|
||
}
|
||
|
||
// 타겟 바인딩
|
||
const resolvedTarget = action.target
|
||
? this.resolveValue(action.target, context.data)
|
||
: undefined;
|
||
|
||
// API 호출 시 로딩 상태 시작 (컴포넌트별 개별 관리)
|
||
if (action.handler === 'apiCall' && context.setState) {
|
||
actionId = this.generateActionId(action, resolvedTarget);
|
||
|
||
// 기존 loadingActions 상태 유지하면서 새 액션 추가
|
||
const currentLoadingActions = context.state?.loadingActions || {};
|
||
context.setState({
|
||
loadingActions: {
|
||
...currentLoadingActions,
|
||
[actionId]: true
|
||
}
|
||
});
|
||
}
|
||
|
||
// 프리뷰 모드 억제 체크 (switch 진입 전)
|
||
// PREVIEW_SUPPRESSED_HANDLERS에 정의된 핸들러는 프리뷰 모드에서 실행하지 않음
|
||
// @since engine-v1.26.1
|
||
if (this.previewMode && PREVIEW_SUPPRESSED_HANDLERS.has(action.handler)) {
|
||
logger.warn(`Preview mode: "${action.handler}" suppressed`, resolvedTarget || resolvedParams);
|
||
|
||
// DevTools: 억제 로그
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
startTime,
|
||
endTime: performance.now(),
|
||
duration: performance.now() - startTime,
|
||
status: 'skipped',
|
||
metadata: { reason: 'preview_mode_suppressed' },
|
||
});
|
||
}
|
||
|
||
return { success: true, data: undefined };
|
||
}
|
||
|
||
// 액션 핸들러 실행
|
||
let result: any;
|
||
|
||
switch (action.handler) {
|
||
case 'ensureIdentityVerified':
|
||
result = await this.handleEnsureIdentityVerified(resolvedParams);
|
||
break;
|
||
|
||
case 'resolveIdentityChallenge':
|
||
result = this.handleResolveIdentityChallenge(resolvedParams);
|
||
break;
|
||
|
||
case 'navigate':
|
||
// navigate 액션의 경우 params.path를 우선적으로 사용 (동적 바인딩 지원)
|
||
const navigatePath = resolvedParams.path || resolvedTarget;
|
||
result = await this.handleNavigate(navigatePath!, resolvedParams, context);
|
||
break;
|
||
|
||
case 'navigateBack':
|
||
result = await this.handleNavigateBack();
|
||
break;
|
||
|
||
case 'navigateForward':
|
||
result = await this.handleNavigateForward();
|
||
break;
|
||
|
||
case 'openWindow': {
|
||
const openWindowPath = resolvedParams.path || resolvedTarget;
|
||
result = await this.handleOpenWindow(openWindowPath!, resolvedParams);
|
||
break;
|
||
}
|
||
|
||
case 'replaceUrl': {
|
||
const replaceUrlPath = resolvedParams.path || resolvedTarget || window.location.pathname;
|
||
result = await this.handleReplaceUrl(replaceUrlPath, resolvedParams);
|
||
break;
|
||
}
|
||
|
||
case 'apiCall':
|
||
// DevTools: Stale Closure 감지를 위한 상태 캡처 (onSuccess가 있는 경우만)
|
||
if (action.onSuccess && devTools?.isEnabled() && devToolsActionId) {
|
||
const G7Core = (window as any).G7Core;
|
||
const captureState: Record<string, any> = {};
|
||
// _global과 _local 상태 캡처
|
||
if (G7Core?.state?.get) {
|
||
const globalState = G7Core.state.get();
|
||
captureState['_global'] = globalState;
|
||
}
|
||
if (context.state) {
|
||
captureState['_local'] = context.state;
|
||
}
|
||
devTools.registerStateCaptureForHandler?.(devToolsActionId, ['_global', '_local'], captureState);
|
||
}
|
||
|
||
// auth_mode 우선, auth_required는 하위 호환
|
||
const authMode = action.auth_mode ?? (action.auth_required ? 'required' : 'none');
|
||
// identity_target: IDV 428 인터셉트 시 사용할 인증 대상(이메일·전화). 표현식 평가 후 전달.
|
||
const resolvedIdentityTarget = action.identity_target
|
||
? (this.resolveParams(action.identity_target, context.data) as {
|
||
email?: string;
|
||
phone?: string;
|
||
})
|
||
: undefined;
|
||
result = await this.handleApiCall(
|
||
resolvedTarget!,
|
||
resolvedParams,
|
||
context,
|
||
authMode,
|
||
resolvedIdentityTarget
|
||
);
|
||
break;
|
||
|
||
case 'login':
|
||
result = await this.handleLogin(
|
||
resolvedTarget!,
|
||
resolvedParams,
|
||
context
|
||
);
|
||
break;
|
||
|
||
case 'logout':
|
||
result = await this.handleLogout(resolvedTarget!, context);
|
||
break;
|
||
|
||
case 'setState':
|
||
logger.log('[executeAction] setState resolvedParams:', resolvedParams);
|
||
// engine-v1.42.0: action.render 옵션을 __render 메타데이터로 전달
|
||
// handleSetState는 resolvedParams를 받으므로 action 레벨 속성에 직접 접근 불가
|
||
result = await this.handleSetState(
|
||
action.render !== undefined ? { ...resolvedParams, __render: action.render } : resolvedParams,
|
||
context
|
||
);
|
||
break;
|
||
|
||
case 'setError':
|
||
result = await this.handleSetError(resolvedTarget!, resolvedParams, context);
|
||
break;
|
||
|
||
case 'openModal':
|
||
result = await this.handleOpenModal(resolvedTarget!, context);
|
||
break;
|
||
|
||
case 'closeModal':
|
||
result = await this.handleCloseModal(context);
|
||
break;
|
||
|
||
case 'showAlert':
|
||
result = await this.handleShowAlert(resolvedTarget!, context);
|
||
break;
|
||
|
||
case 'toast':
|
||
result = await this.handleToast(resolvedParams, context);
|
||
break;
|
||
|
||
case 'switch':
|
||
result = await this.handleSwitch(action, context);
|
||
break;
|
||
|
||
case 'conditions':
|
||
result = await this.handleConditions(action, context);
|
||
break;
|
||
|
||
case 'sequence':
|
||
result = await this.handleSequence(action, context);
|
||
break;
|
||
|
||
case 'parallel':
|
||
result = await this.handleParallel(action, context);
|
||
break;
|
||
|
||
case 'startInterval':
|
||
result = this.handleStartInterval(resolvedParams, context);
|
||
break;
|
||
|
||
case 'stopInterval':
|
||
result = this.handleStopInterval(resolvedParams);
|
||
break;
|
||
|
||
case 'loadScript':
|
||
result = await this.handleLoadScript(resolvedParams, action, context);
|
||
break;
|
||
|
||
case 'callExternal':
|
||
result = await this.handleCallExternal(resolvedParams, action, context);
|
||
break;
|
||
|
||
case 'callExternalEmbed':
|
||
result = await this.handleCallExternalEmbed(resolvedParams, action, context);
|
||
break;
|
||
|
||
case 'saveToLocalStorage':
|
||
result = await this.handleSaveToLocalStorage(resolvedParams, context);
|
||
break;
|
||
|
||
case 'loadFromLocalStorage':
|
||
result = await this.handleLoadFromLocalStorage(resolvedParams, context);
|
||
break;
|
||
|
||
default:
|
||
// 커스텀 핸들러 실행 (바인딩된 target과 params 전달)
|
||
result = await this.handleCustomAction(
|
||
{
|
||
...action,
|
||
target: resolvedTarget,
|
||
params: resolvedParams,
|
||
},
|
||
context
|
||
);
|
||
break;
|
||
}
|
||
|
||
// resultTo 처리: 핸들러 실행 결과를 상태에 저장
|
||
if (action.resultTo && result !== undefined) {
|
||
const { target, key, merge: resultToMerge } = action.resultTo;
|
||
// key에서 {{}} 바인딩 해석
|
||
const resolvedKey = this.resolveValue(key, context.data);
|
||
const resultToMergeMode: 'replace' | 'shallow' | 'deep' = resultToMerge === 'replace' ? 'replace' : resultToMerge === 'shallow' ? 'shallow' : 'deep';
|
||
|
||
if (target === '_local' && context.setState) {
|
||
// 로컬 상태에 저장 (dot notation 지원)
|
||
const update = this.buildNestedUpdate(resolvedKey, result);
|
||
// merge 모드에 따라 __mergeMode 메타데이터 추가
|
||
const updateWithMode = resultToMergeMode !== 'deep'
|
||
? { ...update, __mergeMode: resultToMergeMode }
|
||
: update;
|
||
context.setState(updateWithMode);
|
||
logger.log(`[resultTo] Saved to _local.${resolvedKey} (merge=${resultToMergeMode}):`, result);
|
||
} else if (target === '_local' && this.globalStateUpdater) {
|
||
// init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트
|
||
// globalState._local에 저장하여 렌더링 시 DynamicRenderer에서 사용
|
||
const G7Core = (window as any).G7Core;
|
||
const currentState = G7Core?.state?.get() || {};
|
||
const currentLocal = currentState._local || {};
|
||
const newValue = this.buildNestedUpdate(resolvedKey, result);
|
||
let mergedLocal: Record<string, any>;
|
||
if (resultToMergeMode === 'replace') {
|
||
mergedLocal = newValue;
|
||
} else if (resultToMergeMode === 'shallow') {
|
||
mergedLocal = { ...currentLocal, ...newValue };
|
||
} else {
|
||
mergedLocal = this.deepMergeWithState(newValue, currentLocal);
|
||
}
|
||
this.globalStateUpdater({ _local: mergedLocal });
|
||
|
||
logger.log(`[resultTo] Saved to _local.${resolvedKey} via globalStateUpdater (merge=${resultToMergeMode}):`, result);
|
||
} else if (target === '_global' && this.globalStateUpdater) {
|
||
// 전역 상태에 저장
|
||
const update = this.buildNestedUpdate(resolvedKey, result);
|
||
this.globalStateUpdater(update);
|
||
|
||
logger.log(`[resultTo] Saved to _global.${resolvedKey}:`, result);
|
||
} else if (target === '_isolated' && context.isolatedContext) {
|
||
// 격리된 상태에 저장
|
||
const update = this.buildNestedUpdate(resolvedKey, result);
|
||
context.isolatedContext.mergeState(update, resultToMergeMode);
|
||
logger.log(`[resultTo] Saved to _isolated.${resolvedKey} (merge=${resultToMergeMode}):`, result);
|
||
} else {
|
||
logger.warn(`[resultTo] Cannot save result: target=${target}, setState=${!!context.setState}, globalStateUpdater=${!!this.globalStateUpdater}, isolatedContext=${!!context.isolatedContext}`);
|
||
}
|
||
}
|
||
|
||
// 성공 액션 실행 (단일 또는 배열 지원)
|
||
// 여러 액션이 있는 경우 sequence로 처리하여 상태 동기화 보장
|
||
if (action.onSuccess) {
|
||
// DevTools: Stale Closure 감지 (apiCall 완료 후)
|
||
if (devTools?.isEnabled() && devToolsActionId && action.handler === 'apiCall') {
|
||
const G7Core = (window as any).G7Core;
|
||
const currentState: Record<string, any> = {};
|
||
if (G7Core?.state?.get) {
|
||
currentState['_global'] = G7Core.state.get();
|
||
}
|
||
if (context.state) {
|
||
currentState['_local'] = context.state;
|
||
}
|
||
// Stale Closure 감지 - 캡처된 상태와 현재 상태 비교
|
||
devTools.detectStaleClosure?.(
|
||
devToolsActionId,
|
||
`${action.handler} → onSuccess`,
|
||
currentState,
|
||
'callback-state-capture',
|
||
devToolsActionId
|
||
);
|
||
}
|
||
|
||
// result와 response 모두 사용 가능하도록 컨텍스트 구성
|
||
// - result: 기존 호환성 유지
|
||
// - response: API 응답임을 명확히 표현 (권장)
|
||
const successContext = {
|
||
...context,
|
||
data: { ...context.data, result, response: result },
|
||
};
|
||
const successActions = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
|
||
|
||
if (successActions.length > 1) {
|
||
// 여러 액션을 sequence로 감싸서 처리 - handleSequence의 상태 동기화 로직 활용
|
||
await this.handleSequence(
|
||
{ handler: 'sequence', type: 'click', actions: successActions },
|
||
successContext
|
||
);
|
||
} else if (successActions.length === 1) {
|
||
await this.executeAction(successActions[0], successContext);
|
||
}
|
||
}
|
||
|
||
// DevTools: 성공 완료 로깅 (resolvedParams 포함)
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
params: this.sanitizeForDevTools(action.params),
|
||
resolvedParams: devToolsResolvedParams,
|
||
startTime,
|
||
endTime: performance.now(),
|
||
duration: performance.now() - startTime,
|
||
status: 'success',
|
||
result: this.sanitizeForDevTools(result),
|
||
});
|
||
}
|
||
|
||
return { success: true, data: result };
|
||
} catch (error) {
|
||
const actionError =
|
||
error instanceof ActionError
|
||
? error
|
||
: new ActionError(
|
||
`Failed to execute action: ${action.handler}`,
|
||
action,
|
||
error instanceof Error ? error : undefined
|
||
);
|
||
|
||
// API 응답에서 에러 정보 추출
|
||
const apiResponse = (actionError.originalError as any)?.response || {};
|
||
const responseData = apiResponse.data || {};
|
||
const errorStatus = (actionError.originalError as any)?.status || apiResponse.status || 500;
|
||
|
||
// 에러 컨텍스트 생성 (ErrorHandlingResolver와 호환)
|
||
// API 응답의 message를 우선 사용하고, 응답이 아예 없었던 네트워크 실패에는
|
||
// 내부 식별 문구 대신 다국어 안내를 쓴다 (engine-v1.54.6)
|
||
let errorMessage = resolveActionFailureMessage(
|
||
actionError.originalError ?? error,
|
||
action.handler,
|
||
responseData.message,
|
||
actionError.message
|
||
);
|
||
|
||
// 상태에 실려 텍스트로 그대로 렌더되는 경로(`{{error.message}}` → `_global.*Error`)가 있으므로
|
||
// 여기서 번역해 둔다. 키 문자열이 화면에 노출되면 안 된다.
|
||
if (this.translationEngine && this.translationContext && errorMessage.startsWith('$t:')) {
|
||
errorMessage = this.translationEngine.resolveTranslations(
|
||
errorMessage,
|
||
this.translationContext
|
||
);
|
||
}
|
||
const errorContextData: ErrorContext = {
|
||
status: errorStatus,
|
||
message: errorMessage,
|
||
errors: responseData.errors || apiResponse.errors,
|
||
data: responseData,
|
||
statusText: (actionError.originalError as any)?.statusText,
|
||
// API 응답의 error_code (예: 428 'identity_verification_required') 를 노출 —
|
||
// 코어 toast 핸들러가 IDV 가드 토스트를 코드로 식별해 중복 억제하는 데 사용.
|
||
error_code: responseData.error_code ?? apiResponse.error_code,
|
||
};
|
||
|
||
// 미등록 핸들러는 표시 계층으로 내보내지 않는다.
|
||
//
|
||
// 확장 번들이 로드되지 않으면 그 확장 소유 핸들러(예: sirsoft-ecommerce.initPreferredCurrency)
|
||
// 가 등록되지 않는다. 이때 errorHandling 정책을 태우면 `Unknown action handler: {내부식별자}`
|
||
// 라는 raw 영문 문구가 토스트로 사용자에게 노출된다. 사용자가 조치할 수 있는 일이 아니며
|
||
// 내부 식별자 노출 자체가 결함이다. 확장 부재는 조용한 기능 열화로 끝내고 warn 만 남긴다.
|
||
// (throw 는 그대로 유지되어 호출부의 기존 흐름은 바뀌지 않는다.)
|
||
// @since engine-v1.53.0
|
||
if (actionError.unknownHandler) {
|
||
logger.warn(
|
||
`Unknown action handler "${action.handler}" — skipped (extension not loaded?). ` +
|
||
'Not surfaced to the user.'
|
||
);
|
||
throw actionError;
|
||
}
|
||
|
||
// 에러 핸들링 우선순위:
|
||
// 1. action.errorHandling[코드] → action.errorHandling[default]
|
||
// 2. action.onError
|
||
// 3. 레이아웃/템플릿/시스템 기본값 (ErrorHandlingResolver)
|
||
|
||
// ErrorHandlingResolver를 통해 핸들러 결정
|
||
const resolver = getErrorHandlingResolver();
|
||
const result = resolver.resolve(errorStatus, {
|
||
errorHandling: action.errorHandling,
|
||
onError: action.onError as any, // ActionDefinition[] → ErrorHandlerConfig[] 변환
|
||
});
|
||
|
||
if (result.handler) {
|
||
// DevTools: Stale Closure 감지 (apiCall 에러 발생 후)
|
||
if (devTools?.isEnabled() && devToolsActionId && action.handler === 'apiCall') {
|
||
const G7Core = (window as any).G7Core;
|
||
const currentState: Record<string, any> = {};
|
||
if (G7Core?.state?.get) {
|
||
currentState['_global'] = G7Core.state.get();
|
||
}
|
||
if (context.state) {
|
||
currentState['_local'] = context.state;
|
||
}
|
||
// Stale Closure 감지 - 캡처된 상태와 현재 상태 비교
|
||
devTools.detectStaleClosure?.(
|
||
devToolsActionId,
|
||
`${action.handler} → onError`,
|
||
currentState,
|
||
'callback-state-capture',
|
||
devToolsActionId
|
||
);
|
||
}
|
||
|
||
// 핸들러가 결정되면 실행
|
||
const errorContext = {
|
||
...context,
|
||
data: {
|
||
...context.data,
|
||
error: errorContextData,
|
||
},
|
||
};
|
||
|
||
try {
|
||
// ErrorHandlerConfig를 ActionDefinition으로 변환하여 실행
|
||
const handlerAction: ActionDefinition = {
|
||
type: 'click',
|
||
handler: result.handler.handler as ActionType,
|
||
target: result.handler.target,
|
||
params: result.handler.params,
|
||
actions: result.handler.actions as ActionDefinition[],
|
||
};
|
||
|
||
await this.executeAction(handlerAction, errorContext);
|
||
return { success: false, error: actionError };
|
||
} catch (handlerError) {
|
||
logger.error('Error executing error handler:', handlerError);
|
||
return { success: false, error: actionError };
|
||
}
|
||
}
|
||
|
||
// DevTools: 에러 로깅 (resolvedParams 포함)
|
||
if (devToolsActionId && devTools) {
|
||
devTools.logAction({
|
||
id: devToolsActionId,
|
||
type: action.handler,
|
||
params: this.sanitizeForDevTools(action.params),
|
||
resolvedParams: devToolsResolvedParams,
|
||
startTime,
|
||
endTime: performance.now(),
|
||
duration: performance.now() - startTime,
|
||
status: 'error',
|
||
error: {
|
||
name: actionError.name,
|
||
message: actionError.message,
|
||
stack: actionError.stack,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 핸들러를 찾지 못한 경우 에러를 throw
|
||
throw actionError;
|
||
} finally {
|
||
// API 호출 후 로딩 상태 해제 (컴포넌트별 개별 관리)
|
||
if (action.handler === 'apiCall' && context.setState && actionId) {
|
||
const currentLoadingActions = context.state?.loadingActions || {};
|
||
const { [actionId]: _, ...remainingLoadingActions } = currentLoadingActions;
|
||
|
||
context.setState({
|
||
loadingActions: remainingLoadingActions
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* navigate 액션을 처리합니다.
|
||
*
|
||
* @param target 이동할 경로 (기본 경로)
|
||
* @param params 파라미터 (mergeQuery, query, replace 등)
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleNavigate(
|
||
target: string,
|
||
params: Record<string, any>,
|
||
context: ActionContext
|
||
): Promise<void> {
|
||
let finalPath = target;
|
||
|
||
// query 파라미터 처리
|
||
// mergeQuery: true 는 query 키 없이도 병합을 수행한다 (@since engine-v1.54.2).
|
||
// 이전에는 `if (params.query)` 게이트에 걸려 `mergeQuery: true` 만 적은 액션이 병합
|
||
// 자체를 건너뛰고 쿼리를 통째로 잃었다 — 작성자 관점에서 가장 자연스러운 형태가
|
||
// 정반대로 동작하던 함정이라 게이트를 넓힌다.
|
||
if (params.query || params.mergeQuery === true) {
|
||
if (params.mergeQuery === true) {
|
||
// mergeQuery가 true이면 기존 쿼리스트링과 병합
|
||
finalPath = this.buildMergedQueryPath(target, params.query ?? {});
|
||
} else {
|
||
// mergeQuery가 false이거나 없으면 새 쿼리스트링으로 대체
|
||
const queryString = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(params.query)) {
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
// 배열인 경우 각 요소를 개별 파라미터로 추가 (예: sales_status[]=a&sales_status[]=b)
|
||
// 키에 []가 없으면 추가하여 Laravel이 배열로 인식하도록 함
|
||
if (Array.isArray(value)) {
|
||
const arrayKey = key.endsWith('[]') ? key : `${key}[]`;
|
||
for (const item of value) {
|
||
if (item !== undefined && item !== null && item !== '') {
|
||
queryString.append(arrayKey, String(item));
|
||
}
|
||
}
|
||
} else {
|
||
queryString.set(key, String(value));
|
||
}
|
||
}
|
||
}
|
||
const qs = queryString.toString();
|
||
if (qs) {
|
||
finalPath = `${target}?${qs}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// DEBUG: navigate 호출 전 로깅
|
||
logger.log('handleNavigate:', {
|
||
target,
|
||
params,
|
||
finalPath,
|
||
replace: params.replace,
|
||
windowLocationSearch: window.location.search,
|
||
});
|
||
|
||
// replace: true인 경우 - URL만 교체하고 데이터 소스 refetch (컴포넌트 리마운트 없음)
|
||
// 같은 페이지에서 검색/필터 변경 시 사용
|
||
// params.transition_overlay_target 으로 transition_overlay.target 동적 override 지원 (@since engine-v1.36.0)
|
||
// 명명 주의: `overlay_target` 같은 광범위 키를 피하고 `transition_overlay` 스키마와 1:1 매핑되는 명시적 이름 사용.
|
||
// 미래 다른 overlay 시스템(modal/drawer/tooltip 등)은 각자 독립 키 이름 사용 권장.
|
||
if (params.replace === true) {
|
||
const G7Core = (window as any).G7Core;
|
||
if (G7Core?.updateQueryParams) {
|
||
const transitionOverlayTarget = typeof (params as any).transition_overlay_target === 'string'
|
||
? (params as any).transition_overlay_target
|
||
: undefined;
|
||
await G7Core.updateQueryParams(
|
||
finalPath,
|
||
transitionOverlayTarget ? { transitionOverlayTarget } : undefined
|
||
);
|
||
logger.log(
|
||
'handleNavigate: Used updateQueryParams for replace mode',
|
||
transitionOverlayTarget ? { transitionOverlayTarget } : undefined
|
||
);
|
||
// 이동 후 스크롤 위치 적용 (기본: 상단)
|
||
this.applyScrollOption(params.scroll, params.scrollBehavior, 'top');
|
||
return;
|
||
}
|
||
// G7Core.updateQueryParams가 없으면 fallback으로 React Router 사용
|
||
logger.warn('handleNavigate: G7Core.updateQueryParams not available, falling back to React Router');
|
||
}
|
||
|
||
// 미등록 라우트 fallback — 기본 openWindow, fallback: false 또는 커스텀 지정 가능
|
||
// @since engine-v1.40.0: 관리자 ↔ 사용자 경로 교차 이동 시 404 대신 새 창 열기
|
||
// 중요: 동기 검사로 fallback 대상 여부만 판단 — 일치/부재 시 즉시 빠져나가 기존 동기 실행 경로 유지
|
||
const fallbackAction = this.resolveNavigateFallbackAction(finalPath, params);
|
||
if (fallbackAction) {
|
||
logger.warn(
|
||
`handleNavigate: No route matched, falling back to "${fallbackAction.handler}"`
|
||
);
|
||
await this.dispatchAction(
|
||
{ type: 'click', handler: fallbackAction.handler, params: fallbackAction.params } as ActionDefinition,
|
||
context
|
||
);
|
||
this.applyScrollOption(params.scroll, params.scrollBehavior, 'top');
|
||
return;
|
||
}
|
||
|
||
// navigate 함수 확인
|
||
if (!context.navigate) {
|
||
throw new ActionError(
|
||
'Navigate function is not provided in context'
|
||
);
|
||
}
|
||
|
||
// 일반 navigate (페이지 전환) - React Router 사용
|
||
context.navigate(finalPath, { replace: params.replace === true });
|
||
|
||
// 이동 후 스크롤 위치 적용 (기본: 상단)
|
||
this.applyScrollOption(params.scroll, params.scrollBehavior, 'top');
|
||
}
|
||
|
||
/**
|
||
* navigate 대상 경로가 routes.json에 없는 경우 실행할 fallback 액션을 결정합니다.
|
||
* 매칭되거나 fallback이 비활성화된 경우 null을 반환합니다.
|
||
*
|
||
* 동기 검사로 동작 — 기존 navigate 동기 실행 경로를 보존하기 위해 await을 피함.
|
||
*
|
||
* 동작:
|
||
* - `params.fallback === false` → null (fallback 비활성화)
|
||
* - `params.fallback` 미지정 → 기본 openWindow
|
||
* - `params.fallback: string` → 해당 핸들러명
|
||
* - `params.fallback: { handler, params }` → 상세 지정
|
||
*
|
||
* @param finalPath query 병합이 완료된 최종 경로
|
||
* @param params navigate params
|
||
* @returns fallback 액션 정의 또는 null (정상 navigate 진행)
|
||
* @since engine-v1.40.0
|
||
*/
|
||
private resolveNavigateFallbackAction(
|
||
finalPath: string,
|
||
params: Record<string, any>
|
||
): { handler: string; params: Record<string, any> } | null {
|
||
const fallbackOption = params.fallback;
|
||
|
||
// 명시적 비활성화
|
||
if (fallbackOption === false) {
|
||
return null;
|
||
}
|
||
|
||
// replace: true는 쿼리 갱신 전용으로 실제 경로 이동이 아니므로 fallback 대상 아님
|
||
if (params.replace === true) {
|
||
return null;
|
||
}
|
||
|
||
const templateApp = (window as any).__templateApp;
|
||
const router = templateApp?.getRouter?.();
|
||
if (!router || typeof router.match !== 'function') {
|
||
return null;
|
||
}
|
||
|
||
// 라우트가 아직 로드되지 않은 경우 fallback 미적용 — 초기 부팅 단계 또는 테스트 환경 보호
|
||
if (typeof router.getRoutes === 'function' && router.getRoutes().length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const pathname = finalPath.split('?')[0];
|
||
if (router.match(pathname)) {
|
||
// 현재 템플릿에 등록된 경로 — 정상 navigate
|
||
return null;
|
||
}
|
||
|
||
// 미등록 경로 — fallback 핸들러 결정
|
||
return this.resolveNavigateFallback(fallbackOption, finalPath, params);
|
||
}
|
||
|
||
/**
|
||
* navigate fallback 옵션을 정규화하여 { handler, params } 구조로 변환합니다.
|
||
*
|
||
* @param fallbackOption params.fallback 원본 값 (undefined | string | object)
|
||
* @param finalPath query 병합 완료된 경로
|
||
* @param originalParams 원래 navigate params
|
||
* @returns fallback 핸들러 정의
|
||
* @since engine-v1.40.0
|
||
*/
|
||
private resolveNavigateFallback(
|
||
fallbackOption: any,
|
||
finalPath: string,
|
||
_originalParams: Record<string, any>
|
||
): { handler: string; params: Record<string, any> } {
|
||
// 기본값: openWindow with target '_self' — 같은 탭에서 이동.
|
||
// 이전 기본값(`'_blank'`)은 admin↔user 교차 이동 시 새 탭이 열려 사용자 흐름이
|
||
// 끊기는 결함을 유발. 명시적으로 새 탭이
|
||
// 필요한 호출처는 `params.fallback: { handler: 'openWindow', params: { target: '_blank' } }`
|
||
// 로 지정.
|
||
if (fallbackOption == null) {
|
||
return {
|
||
handler: 'openWindow',
|
||
params: { path: finalPath, target: '_self' },
|
||
};
|
||
}
|
||
|
||
// 문자열: 핸들러명만 지정
|
||
if (typeof fallbackOption === 'string') {
|
||
return {
|
||
handler: fallbackOption,
|
||
params: { path: finalPath },
|
||
};
|
||
}
|
||
|
||
// 객체: 상세 지정 { handler, params }
|
||
if (typeof fallbackOption === 'object' && typeof fallbackOption.handler === 'string') {
|
||
return {
|
||
handler: fallbackOption.handler,
|
||
params: {
|
||
path: finalPath,
|
||
...(fallbackOption.params || {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
// 알 수 없는 형태 — 기본값으로 폴백
|
||
logger.warn('resolveNavigateFallback: unrecognized fallback option, using openWindow', fallbackOption);
|
||
return {
|
||
handler: 'openWindow',
|
||
params: { path: finalPath, target: '_blank' },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 이동 후 스크롤 위치를 적용합니다. (@since engine-v1.37.0)
|
||
*
|
||
* **단축 문법**:
|
||
* - `"top"` (기본) → `#app` 내부의 모든 스크롤 컨테이너 + window 를 상단으로 리셋
|
||
* - `"preserve"` → 스크롤 위치 유지 (no-op)
|
||
* - `number` → window 를 (0, n) 으로
|
||
* - `{ x, y }` → window 를 (x, y) 로
|
||
* - `"#id"` / `".class"` → 해당 엘리먼트로 `scrollIntoView`
|
||
*
|
||
* **확장 객체 문법** (@since engine-v1.37.0):
|
||
* ```ts
|
||
* {
|
||
* container?: string; // 스크롤 컨테이너 선택자 (생략 시 window)
|
||
* to?: string | number | { x?, y? } | 'top'; // 이동 대상 (생략 시 'top')
|
||
* block?: 'start' | 'center' | 'end' | 'nearest'; // scrollIntoView block (기본 'start')
|
||
* offset?: number; // sticky 헤더 보정 (px, 양수 = 위쪽 여유)
|
||
* }
|
||
* ```
|
||
*
|
||
* 새 레이아웃이 DOM에 반영된 뒤 스크롤되도록 requestAnimationFrame으로 다음 tick에 실행합니다.
|
||
*
|
||
* @param scroll 스크롤 옵션
|
||
* @param scrollBehavior 스크롤 애니메이션 ('instant' | 'smooth')
|
||
* @param defaultValue 옵션 미지정 시 기본 동작 ('top' | 'preserve')
|
||
*/
|
||
private applyScrollOption(
|
||
scroll: unknown,
|
||
scrollBehavior: unknown,
|
||
defaultValue: 'top' | 'preserve'
|
||
): void {
|
||
const effective = scroll === undefined ? defaultValue : scroll;
|
||
if (effective === 'preserve') {
|
||
return;
|
||
}
|
||
|
||
// 기본값 'instant' — CSS scroll-behavior: smooth 가 전역 적용된 환경에서도
|
||
// 페이지 전환 시 즉시 스크롤되도록 'auto' 대신 'instant' 사용.
|
||
// 'smooth' 명시 시에만 부드러운 스크롤 적용.
|
||
const behavior = (
|
||
scrollBehavior === 'smooth' ? 'smooth' : 'instant'
|
||
) as ScrollBehavior;
|
||
|
||
// 확장 객체 형태 감지: container/to/block/offset 중 하나라도 있으면 확장 형태
|
||
const isExtendedForm = (v: unknown): boolean => {
|
||
if (typeof v !== 'object' || v === null) return false;
|
||
const obj = v as Record<string, unknown>;
|
||
return 'container' in obj || 'to' in obj || 'block' in obj || 'offset' in obj;
|
||
};
|
||
|
||
// 컨테이너에 특정 Y 좌표로 스크롤
|
||
const scrollTargetTo = (
|
||
target: HTMLElement | Window,
|
||
y: number,
|
||
x: number = 0
|
||
) => {
|
||
if (target === window) {
|
||
window.scrollTo({ top: y, left: x, behavior });
|
||
} else {
|
||
(target as HTMLElement).scrollTo({ top: y, left: x, behavior });
|
||
}
|
||
};
|
||
|
||
// 엘리먼트를 지정된 컨테이너 안으로 스크롤 (block/offset 적용)
|
||
const scrollElementIntoTarget = (
|
||
el: HTMLElement,
|
||
container: HTMLElement | Window,
|
||
block: ScrollLogicalPosition,
|
||
offset: number
|
||
) => {
|
||
if (container === window) {
|
||
// window 컨텍스트: 네이티브 scrollIntoView 사용 후 offset 보정
|
||
el.scrollIntoView({ behavior, block });
|
||
if (offset) {
|
||
window.scrollBy({ top: -offset, left: 0, behavior });
|
||
}
|
||
return;
|
||
}
|
||
const c = container as HTMLElement;
|
||
const elRect = el.getBoundingClientRect();
|
||
const cRect = c.getBoundingClientRect();
|
||
const relativeTop = c.scrollTop + (elRect.top - cRect.top);
|
||
let top: number;
|
||
if (block === 'center') {
|
||
top = relativeTop - (c.clientHeight - el.clientHeight) / 2;
|
||
} else if (block === 'end') {
|
||
top = relativeTop - (c.clientHeight - el.clientHeight);
|
||
} else {
|
||
// 'start' | 'nearest' → 기본 시작
|
||
top = relativeTop;
|
||
}
|
||
top -= offset;
|
||
c.scrollTo({ top: Math.max(0, top), left: 0, behavior });
|
||
};
|
||
|
||
// #app 내부의 모든 스크롤 컨테이너를 상단으로 리셋
|
||
const resetAllScrollContainers = () => {
|
||
window.scrollTo({ top: 0, left: 0, behavior });
|
||
const root = document.getElementById('app') ?? document.body;
|
||
if (!root) return;
|
||
const candidates = root.querySelectorAll<HTMLElement>('*');
|
||
candidates.forEach((el) => {
|
||
if (el.scrollTop === 0 && el.scrollLeft === 0) return;
|
||
const style = window.getComputedStyle(el);
|
||
const oy = style.overflowY;
|
||
const ox = style.overflowX;
|
||
const scrollable =
|
||
oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll';
|
||
if (scrollable) {
|
||
el.scrollTo({ top: 0, left: 0, behavior });
|
||
}
|
||
});
|
||
};
|
||
|
||
const run = () => {
|
||
try {
|
||
// --- 확장 객체 문법 ---
|
||
if (isExtendedForm(effective)) {
|
||
const ext = effective as {
|
||
container?: string;
|
||
to?: unknown;
|
||
block?: ScrollLogicalPosition;
|
||
offset?: number;
|
||
};
|
||
const block: ScrollLogicalPosition = ext.block ?? 'start';
|
||
const offset: number = typeof ext.offset === 'number' ? ext.offset : 0;
|
||
|
||
// 컨테이너 해석: 지정되면 해당 엘리먼트, 아니면 window
|
||
let container: HTMLElement | Window = window;
|
||
if (typeof ext.container === 'string' && ext.container.length > 0) {
|
||
const el = document.querySelector(ext.container) as HTMLElement | null;
|
||
if (!el) {
|
||
logger.warn(
|
||
`applyScrollOption: container not found: ${ext.container}`
|
||
);
|
||
return;
|
||
}
|
||
container = el;
|
||
}
|
||
|
||
const to = ext.to ?? 'top';
|
||
|
||
// to: 'top' → 컨테이너(또는 window)를 상단으로
|
||
if (to === 'top') {
|
||
if (container === window) {
|
||
resetAllScrollContainers();
|
||
} else {
|
||
scrollTargetTo(container, 0);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// to: number → Y 좌표
|
||
if (typeof to === 'number') {
|
||
scrollTargetTo(container, to - offset);
|
||
return;
|
||
}
|
||
|
||
// to: { x, y } → 좌표
|
||
if (
|
||
typeof to === 'object' &&
|
||
to !== null &&
|
||
('x' in (to as object) || 'y' in (to as object))
|
||
) {
|
||
const { x = 0, y = 0 } = to as { x?: number; y?: number };
|
||
scrollTargetTo(container, y - offset, x);
|
||
return;
|
||
}
|
||
|
||
// to: '#id' / '.class' → 엘리먼트로 스크롤
|
||
if (
|
||
typeof to === 'string' &&
|
||
(to.startsWith('#') || to.startsWith('.'))
|
||
) {
|
||
const el = document.querySelector(to) as HTMLElement | null;
|
||
if (el) {
|
||
scrollElementIntoTarget(el, container, block, offset);
|
||
} else {
|
||
logger.warn(`applyScrollOption: target element not found: ${to}`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
logger.warn('applyScrollOption: invalid "to" value in extended form', to);
|
||
return;
|
||
}
|
||
|
||
// --- 단축 문법 ---
|
||
if (effective === 'top') {
|
||
resetAllScrollContainers();
|
||
return;
|
||
}
|
||
if (typeof effective === 'number') {
|
||
window.scrollTo({ top: effective, left: 0, behavior });
|
||
return;
|
||
}
|
||
if (
|
||
typeof effective === 'object' &&
|
||
effective !== null &&
|
||
('x' in (effective as object) || 'y' in (effective as object))
|
||
) {
|
||
const { x = 0, y = 0 } = effective as { x?: number; y?: number };
|
||
window.scrollTo({ top: y, left: x, behavior });
|
||
return;
|
||
}
|
||
if (
|
||
typeof effective === 'string' &&
|
||
(effective.startsWith('#') || effective.startsWith('.'))
|
||
) {
|
||
const el = document.querySelector(effective);
|
||
if (el) {
|
||
(el as HTMLElement).scrollIntoView({ behavior, block: 'start' });
|
||
}
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
logger.warn('applyScrollOption: failed to apply scroll', err);
|
||
}
|
||
};
|
||
|
||
if (typeof window.requestAnimationFrame === 'function') {
|
||
window.requestAnimationFrame(run);
|
||
} else {
|
||
run();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* openWindow 액션을 처리합니다.
|
||
*
|
||
* 지정된 경로로 이동합니다. `params.target` 으로 창 동작을 제어:
|
||
* - `'_blank'`(기본): 새 탭/창 열기 (`window.open(path, '_blank')`)
|
||
* - `'_self'`: 같은 탭에서 이동 (`window.location.assign(path)`)
|
||
*
|
||
* navigate fallback 으로 호출되는 경우(현재 템플릿 라우트에 없는 경로 →
|
||
* openWindow) 기본값이 `_blank` 라 의도치 않게 새 탭이 열리는 결함이 있다.
|
||
* fallback 호출 측에서 `params.target: '_self'` 를 명시하거나, 사용자 액션
|
||
* 정의에서 같은 탭 이동을 원하면 `target: '_self'` 명시.
|
||
*
|
||
* @param target 열 경로 (action target)
|
||
* @param params 파라미터 (query, target 등)
|
||
*/
|
||
private async handleOpenWindow(
|
||
target: string,
|
||
params: Record<string, any>
|
||
): Promise<void> {
|
||
let finalPath = target;
|
||
|
||
// query 파라미터 처리
|
||
if (params.query) {
|
||
const queryString = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(params.query)) {
|
||
if (value !== null && value !== undefined && value !== '') {
|
||
queryString.set(key, String(value));
|
||
}
|
||
}
|
||
const qs = queryString.toString();
|
||
if (qs) {
|
||
finalPath = `${target}?${qs}`;
|
||
}
|
||
}
|
||
|
||
const windowTarget = params.target === '_self' ? '_self' : '_blank';
|
||
logger.log('handleOpenWindow:', { target, params, finalPath, windowTarget });
|
||
|
||
if (windowTarget === '_self') {
|
||
window.location.assign(finalPath);
|
||
} else {
|
||
window.open(finalPath, '_blank');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* navigateBack 액션을 처리합니다.
|
||
*
|
||
* 브라우저 히스토리에서 뒤로 이동합니다.
|
||
*/
|
||
private async handleNavigateBack(): Promise<void> {
|
||
logger.log('handleNavigateBack');
|
||
window.history.back();
|
||
}
|
||
|
||
/**
|
||
* navigateForward 액션을 처리합니다.
|
||
*
|
||
* 브라우저 히스토리에서 앞으로 이동합니다.
|
||
*/
|
||
private async handleNavigateForward(): Promise<void> {
|
||
logger.log('handleNavigateForward');
|
||
window.history.forward();
|
||
}
|
||
|
||
/**
|
||
* replaceUrl 액션을 처리합니다.
|
||
*
|
||
* URL만 변경하고 데이터소스 refetch나 컴포넌트 리마운트를 수행하지 않습니다.
|
||
* 리스트 항목 선택 시 URL에 상태를 반영할 때 사용합니다.
|
||
*
|
||
* @param target 변경할 경로
|
||
* @param params 파라미터 (query, mergeQuery)
|
||
*/
|
||
private async handleReplaceUrl(
|
||
target: string,
|
||
params: Record<string, any>
|
||
): Promise<void> {
|
||
let finalPath = target;
|
||
|
||
// query 파라미터 처리 (navigate와 동일 로직 — 게이트도 동일하게 유지, @since engine-v1.54.2)
|
||
if (params.query || params.mergeQuery === true) {
|
||
if (params.mergeQuery === true) {
|
||
finalPath = this.buildMergedQueryPath(target, params.query ?? {});
|
||
} else {
|
||
const queryString = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(params.query)) {
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
if (Array.isArray(value)) {
|
||
const arrayKey = key.endsWith('[]') ? key : `${key}[]`;
|
||
for (const item of value) {
|
||
if (item !== undefined && item !== null && item !== '') {
|
||
queryString.append(arrayKey, String(item));
|
||
}
|
||
}
|
||
} else {
|
||
queryString.set(key, String(value));
|
||
}
|
||
}
|
||
}
|
||
const qs = queryString.toString();
|
||
if (qs) {
|
||
finalPath = `${target}?${qs}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.log('handleReplaceUrl:', { target, params, finalPath });
|
||
|
||
// URL만 변경 (데이터소스 refetch 없음, 컴포넌트 리마운트 없음)
|
||
window.history.replaceState(null, '', finalPath);
|
||
|
||
// 이동 후 스크롤 위치 적용 (기본: preserve — URL만 교체하는 용도이므로 유지)
|
||
this.applyScrollOption(params.scroll, params.scrollBehavior, 'preserve');
|
||
}
|
||
|
||
/**
|
||
* 기존 URL의 쿼리 파라미터와 새 파라미터를 병합합니다.
|
||
*
|
||
* @param basePath 기본 경로 (쿼리스트링 없이)
|
||
* @param newParams 병합할 새 파라미터
|
||
*/
|
||
private buildMergedQueryPath(
|
||
basePath: string,
|
||
newParams: Record<string, any>
|
||
): string {
|
||
// 현재 URL의 쿼리 파라미터 가져오기
|
||
const currentParams = new URLSearchParams(window.location.search);
|
||
|
||
// 새 파라미터 병합 (기존 값 덮어쓰기)
|
||
for (const [key, value] of Object.entries(newParams)) {
|
||
if (value === null || value === undefined || value === '') {
|
||
// 빈 값이면 파라미터 제거
|
||
currentParams.delete(key);
|
||
} else if (Array.isArray(value)) {
|
||
// 배열인 경우 기존 값 제거 후 각 요소를 개별 파라미터로 추가
|
||
// 키에 []가 없으면 추가하여 Laravel이 배열로 인식하도록 함
|
||
const arrayKey = key.endsWith('[]') ? key : `${key}[]`;
|
||
currentParams.delete(key);
|
||
currentParams.delete(arrayKey);
|
||
for (const item of value) {
|
||
if (item !== undefined && item !== null && item !== '') {
|
||
currentParams.append(arrayKey, String(item));
|
||
}
|
||
}
|
||
} else {
|
||
currentParams.set(key, String(value));
|
||
}
|
||
}
|
||
|
||
// 쿼리스트링 생성
|
||
const queryString = currentParams.toString();
|
||
|
||
// basePath에서 기존 쿼리스트링 제거
|
||
const pathWithoutQuery = basePath.split('?')[0];
|
||
|
||
return queryString ? `${pathWithoutQuery}?${queryString}` : pathWithoutQuery;
|
||
}
|
||
|
||
/**
|
||
* CSRF 토큰을 가져옵니다 (Laravel Sanctum).
|
||
*/
|
||
private async ensureCsrfToken(): Promise<void> {
|
||
try {
|
||
await fetch('/sanctum/csrf-cookie', {
|
||
credentials: 'include',
|
||
});
|
||
} catch (error) {
|
||
throw new ActionError(
|
||
'Failed to fetch CSRF token',
|
||
undefined,
|
||
error instanceof Error ? error : undefined
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 쿠키에서 CSRF 토큰을 추출합니다.
|
||
*/
|
||
private getCsrfTokenFromCookie(): string | null {
|
||
const name = 'XSRF-TOKEN';
|
||
const value = `; ${document.cookie}`;
|
||
const parts = value.split(`; ${name}=`);
|
||
if (parts.length === 2) {
|
||
const token = parts.pop()?.split(';').shift();
|
||
return token ? decodeURIComponent(token) : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 객체를 query string으로 변환합니다.
|
||
*
|
||
* 배열은 key[]=value1&key[]=value2 형식으로 직렬화됩니다.
|
||
* null, undefined, 빈 문자열은 제외됩니다.
|
||
*
|
||
* @param obj 변환할 객체
|
||
* @returns query string (? 제외)
|
||
*/
|
||
private buildQueryString(obj: Record<string, any>): string {
|
||
const params = new URLSearchParams();
|
||
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (value === null || value === undefined || value === '') {
|
||
continue;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
// 배열은 key[]=value 형식으로 추가
|
||
for (const item of value) {
|
||
if (item !== null && item !== undefined && item !== '') {
|
||
params.append(`${key}[]`, String(item));
|
||
}
|
||
}
|
||
} else if (typeof value === 'object') {
|
||
// 중첩 객체는 JSON 문자열로 변환
|
||
params.append(key, JSON.stringify(value));
|
||
} else {
|
||
params.append(key, String(value));
|
||
}
|
||
}
|
||
|
||
return params.toString();
|
||
}
|
||
|
||
/**
|
||
* ensureIdentityVerified 액션 — API 호출 전 본인인증을 선제 보장합니다.
|
||
*
|
||
* 레이아웃에서 `{ handler: 'ensureIdentityVerified', params: { purpose: '...' } }` 로 호출.
|
||
* 정책 resolve 는 GET /api/identity/policies/resolve (v1.1) 로 미래 확장 예정.
|
||
* 현 버전은 POST /api/identity/challenges 로 즉시 challenge 를 시작하고,
|
||
* IdentityGuardInterceptor.handle 과 동일한 launcher 플로우를 재사용합니다.
|
||
*
|
||
* @param params 액션 파라미터 (purpose 필수, target 선택 — 비로그인 흐름의 인증 대상)
|
||
* @returns 사용자가 verify 에 성공하면 true, 취소 시 false
|
||
*/
|
||
private async handleEnsureIdentityVerified(params: Record<string, any>): Promise<boolean> {
|
||
const purpose = typeof params.purpose === 'string' ? params.purpose : 'sensitive_action';
|
||
|
||
// params.target: 선제 가드 호출에서 흐름이 직접 인증 대상을 선언 (apiCall 의 identity_target 과 동일 채널).
|
||
const target =
|
||
params.target && typeof params.target === 'object'
|
||
? (params.target as { email?: string; phone?: string })
|
||
: undefined;
|
||
|
||
const verified = await IdentityGuardInterceptor.handle(
|
||
{
|
||
success: false,
|
||
error_code: 'identity_verification_required',
|
||
message: '',
|
||
verification: {
|
||
policy_key: (params.policy_key as string) ?? '',
|
||
purpose,
|
||
provider_id: (params.provider_id as string) ?? null,
|
||
render_hint: (params.render_hint as string) ?? null,
|
||
return_request: null, // pre-emptive 호출은 재실행할 원 요청 없음
|
||
},
|
||
},
|
||
undefined,
|
||
target
|
||
);
|
||
|
||
return verified !== null;
|
||
}
|
||
|
||
/**
|
||
* resolveIdentityChallenge 액션 — 본인인증 모달/풀페이지/외부 SDK callback 이
|
||
* `IdentityGuardInterceptor` 의 deferred resolver 에 verify 결과를 통보합니다.
|
||
*
|
||
* 레이아웃 JSON 호출 예시:
|
||
* - verify 성공: `{ handler: 'resolveIdentityChallenge', params: { result: 'verified', token: '...' } }`
|
||
* - 사용자 취소: `{ handler: 'resolveIdentityChallenge', params: { result: 'cancelled' } }`
|
||
* - verify 실패: `{ handler: 'resolveIdentityChallenge', params: { result: 'failed', failureCode: 'INVALID_CODE' } }`
|
||
*
|
||
* 부작용은 인터셉터의 글로벌 deferred resolver 1회 호출뿐입니다.
|
||
* setState / 렌더 사이클 / 폼 자동바인딩 / localDynamicState 와 무관합니다.
|
||
*
|
||
* @param params 결과 정보 (result 필수)
|
||
* @returns 항상 true — resolver 호출 자체는 실패하지 않으며 실패 정보는 result.status 로 전달됨
|
||
* @since engine-v1.46.0
|
||
*/
|
||
private handleResolveIdentityChallenge(params: Record<string, any>): boolean {
|
||
const rawResult = typeof params.result === 'string' ? params.result : 'cancelled';
|
||
|
||
let result:
|
||
| { status: 'verified'; token: string; providerData?: Record<string, unknown> }
|
||
| { status: 'pending'; pollUrl: string; pollIntervalMs?: number; expiresAt: string }
|
||
| { status: 'cancelled' }
|
||
| { status: 'failed'; failureCode: string; reason?: string };
|
||
|
||
switch (rawResult) {
|
||
case 'verified': {
|
||
const token = typeof params.token === 'string' ? params.token : '';
|
||
if (!token) {
|
||
logger.warn(
|
||
'resolveIdentityChallenge: result=verified 인데 token 이 비어있습니다. failed 로 강등합니다.'
|
||
);
|
||
result = { status: 'failed', failureCode: 'MISSING_TOKEN' };
|
||
break;
|
||
}
|
||
result = {
|
||
status: 'verified',
|
||
token,
|
||
providerData:
|
||
params.providerData && typeof params.providerData === 'object'
|
||
? (params.providerData as Record<string, unknown>)
|
||
: undefined,
|
||
};
|
||
break;
|
||
}
|
||
case 'pending': {
|
||
const pollUrl = typeof params.pollUrl === 'string' ? params.pollUrl : '';
|
||
const expiresAt = typeof params.expiresAt === 'string' ? params.expiresAt : '';
|
||
if (!pollUrl || !expiresAt) {
|
||
logger.warn(
|
||
'resolveIdentityChallenge: result=pending 인데 pollUrl/expiresAt 누락 — failed 로 강등합니다.'
|
||
);
|
||
result = { status: 'failed', failureCode: 'MALFORMED_PENDING' };
|
||
break;
|
||
}
|
||
result = {
|
||
status: 'pending',
|
||
pollUrl,
|
||
pollIntervalMs:
|
||
typeof params.pollIntervalMs === 'number' ? params.pollIntervalMs : undefined,
|
||
expiresAt,
|
||
};
|
||
break;
|
||
}
|
||
case 'failed':
|
||
result = {
|
||
status: 'failed',
|
||
failureCode:
|
||
typeof params.failureCode === 'string' ? params.failureCode : 'UNKNOWN',
|
||
reason: typeof params.reason === 'string' ? params.reason : undefined,
|
||
};
|
||
break;
|
||
case 'cancelled':
|
||
default:
|
||
result = { status: 'cancelled' };
|
||
break;
|
||
}
|
||
|
||
IdentityGuardInterceptor.resolveDeferred(result);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* apiCall 액션을 처리합니다.
|
||
*
|
||
* @param target API 엔드포인트
|
||
* @param params 요청 파라미터
|
||
* @param context 액션 컨텍스트
|
||
* @param authMode 인증 모드 ('none' | 'required' | 'optional')
|
||
* @param identityTarget IDV 428 인터셉트 시 challenge 에 사용할 인증 대상 (이메일·전화)
|
||
*/
|
||
private async handleApiCall(
|
||
target: string,
|
||
params: Record<string, any>,
|
||
_context: ActionContext,
|
||
authMode: 'none' | 'required' | 'optional' = 'none',
|
||
identityTarget?: { email?: string; phone?: string }
|
||
): Promise<any> {
|
||
const { method = 'GET', body, headers, contentType } = params;
|
||
|
||
// CSRF token 가져오기 (POST, PUT, DELETE 등)
|
||
if (method !== 'GET' && method !== 'HEAD') {
|
||
await this.ensureCsrfToken();
|
||
}
|
||
|
||
// CSRF 토큰을 쿠키에서 추출하여 헤더에 추가
|
||
const csrfToken = this.getCsrfTokenFromCookie();
|
||
|
||
// authMode에 따라 Bearer 토큰 추가
|
||
// - 'none': 토큰 미포함
|
||
// - 'required': 토큰 필수 (토큰이 있으면 포함)
|
||
// - 'optional': 토큰이 있으면 포함, 없으면 미포함
|
||
let authHeader: Record<string, string> = {};
|
||
if (authMode === 'required' || authMode === 'optional') {
|
||
const apiClient = getApiClient();
|
||
const token = apiClient.getToken();
|
||
if (token) {
|
||
authHeader = { Authorization: `Bearer ${token}` };
|
||
}
|
||
}
|
||
|
||
// GET 요청 시 query 또는 body를 query string으로 변환
|
||
let finalTarget = target;
|
||
const queryData = params.query || (method === 'GET' ? body : null);
|
||
if (queryData && typeof queryData === 'object') {
|
||
const queryString = this.buildQueryString(queryData);
|
||
if (queryString) {
|
||
// target에 이미 query string이 있는 경우 &로 연결, 없으면 ?로 시작
|
||
const separator = target.includes('?') ? '&' : '?';
|
||
finalTarget = `${target}${separator}${queryString}`;
|
||
}
|
||
}
|
||
|
||
// 전역 헤더 추출 (패턴 매칭)
|
||
// Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회
|
||
// (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용)
|
||
const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || _context.state?._global || {};
|
||
const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || _context.state?._local || {};
|
||
|
||
const expressionContext: Record<string, any> = {
|
||
_global: currentGlobalState,
|
||
_local: currentLocalState,
|
||
};
|
||
const globalHeadersResolved = this.getMatchingGlobalHeaders(target, expressionContext);
|
||
|
||
// g7_locale이 설정되어 있으면 Accept-Language 헤더로 전송
|
||
const localeHeader: Record<string, string> = {};
|
||
if (typeof window !== 'undefined') {
|
||
const locale = localStorage.getItem('g7_locale');
|
||
if (locale) {
|
||
localeHeader['Accept-Language'] = locale;
|
||
}
|
||
}
|
||
|
||
// multipart/form-data 여부 판단 (레이아웃 JSON의 contentType 파라미터 기반)
|
||
const isMultipart = contentType === 'multipart/form-data';
|
||
|
||
const options: RequestInit = {
|
||
method,
|
||
headers: {
|
||
// multipart/form-data는 Content-Type을 설정하지 않음 (브라우저가 boundary 포함하여 자동 설정)
|
||
...(isMultipart ? {} : { 'Content-Type': 'application/json' }),
|
||
Accept: 'application/json',
|
||
...localeHeader, // g7_locale → Accept-Language
|
||
...(csrfToken && { 'X-XSRF-TOKEN': csrfToken }), // CSRF 토큰을 헤더에 포함
|
||
...authHeader, // Bearer 토큰 (auth_required가 true인 경우)
|
||
...globalHeadersResolved, // 전역 헤더 (패턴 매칭)
|
||
...headers, // 개별 헤더가 globalHeaders를 덮어씀
|
||
},
|
||
credentials: 'include', // 쿠키 전송 활성화
|
||
};
|
||
|
||
if (body && method !== 'GET') {
|
||
if (isMultipart) {
|
||
// multipart/form-data: FormData 객체로 변환
|
||
const formData = new FormData();
|
||
for (const [key, value] of Object.entries(body)) {
|
||
if (value instanceof File || value instanceof Blob) {
|
||
formData.append(key, value);
|
||
} else if (value !== null && value !== undefined) {
|
||
formData.append(
|
||
key,
|
||
typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||
);
|
||
}
|
||
}
|
||
options.body = formData;
|
||
} else {
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
}
|
||
|
||
// DevTools 요청 추적 시작
|
||
const devTools = getDevTools();
|
||
let requestId: string | null = null;
|
||
if (devTools?.isEnabled()) {
|
||
requestId = devTools.trackRequest(finalTarget, method);
|
||
}
|
||
|
||
try {
|
||
let response = await fetch(finalTarget, options);
|
||
|
||
// 응답 본문 파싱 (성공/실패 모두)
|
||
let responseData: any;
|
||
try {
|
||
responseData = await response.json();
|
||
} catch {
|
||
responseData = null;
|
||
}
|
||
|
||
// HTTP 428 + identity_verification_required → IDV Guard 인터셉트
|
||
// Challenge 모달 launcher 호출 후 성공 시 원 요청을 재실행합니다.
|
||
if (IdentityGuardInterceptor.isIdentityRequired(response.status, responseData)) {
|
||
// retry fetch 가 원 요청의 body/headers/credentials 를 그대로 재사용해야 백엔드가
|
||
// 빈 body 로 422 를 던지지 않음 (회원가입 등 모든 POST 흐름의 핵심 회귀 방지)
|
||
const replayed = await IdentityGuardInterceptor.handle(
|
||
responseData,
|
||
{
|
||
body: options.body,
|
||
headers: options.headers,
|
||
credentials: options.credentials,
|
||
},
|
||
identityTarget
|
||
);
|
||
if (replayed) {
|
||
response = replayed;
|
||
try {
|
||
responseData = await response.json();
|
||
} catch {
|
||
responseData = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// DevTools 요청 완료 추적 (성공/에러 모두 상태 코드와 응답 기록)
|
||
if (requestId && devTools?.isEnabled()) {
|
||
devTools.completeRequest(requestId, response.status, responseData);
|
||
requestId = null; // 중복 기록 방지
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorData = responseData || {};
|
||
|
||
// API 응답 데이터를 포함한 에러 객체 생성
|
||
const apiError: any = new Error(
|
||
errorData.message || `API call failed: ${response.statusText}`
|
||
);
|
||
apiError.response = errorData; // API 응답 전체를 포함
|
||
apiError.status = response.status;
|
||
apiError.statusText = response.statusText;
|
||
|
||
throw new ActionError(
|
||
errorData.message || `API call failed: ${response.statusText}`,
|
||
undefined,
|
||
apiError
|
||
);
|
||
}
|
||
|
||
return responseData;
|
||
} catch (error) {
|
||
// 네트워크 오류 등으로 fetch 자체가 실패한 경우
|
||
if (requestId && devTools?.isEnabled()) {
|
||
devTools.failRequest(requestId, error instanceof Error ? error.message : String(error));
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* login 액션을 처리합니다.
|
||
*
|
||
* AuthManager를 통해 로그인하고 토큰을 저장합니다.
|
||
*
|
||
* @param target 인증 타입 (admin 또는 user)
|
||
* @param params 요청 파라미터 (body에 email, password 포함)
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleLogin(
|
||
target: string,
|
||
params: Record<string, any>,
|
||
_context: ActionContext
|
||
): Promise<any> {
|
||
const { body } = params;
|
||
|
||
if (!body || !body.email || !body.password) {
|
||
throw new ActionError(
|
||
'Login requires email and password in body params'
|
||
);
|
||
}
|
||
|
||
// target을 인증 타입으로 사용 (admin 또는 user)
|
||
const authType: AuthType = target === 'user' ? 'user' : 'admin';
|
||
|
||
// 로그인 엔드포인트 결정 (globalHeaders 패턴 매칭용)
|
||
// ApiClient는 baseURL이 '/api'이므로 실제 요청 경로에 '/api' prefix 추가
|
||
const loginEndpoint = authType === 'admin'
|
||
? '/api/auth/admin/login'
|
||
: '/api/auth/login';
|
||
|
||
// globalHeaders에서 패턴 매칭되는 헤더 추출
|
||
// Stale Closure 방지: G7Core.state.getGlobal/getLocal()로 최신 상태 조회
|
||
// (cartKey 재발급 등 중간에 상태가 변경된 경우에도 최신 값 사용)
|
||
const currentGlobalState = (window as any).G7Core?.state?.getGlobal?.() || _context.state?._global || {};
|
||
const currentLocalState = (window as any).G7Core?.state?.getLocal?.() || _context.state?._local || {};
|
||
|
||
const expressionContext: Record<string, any> = {
|
||
_global: currentGlobalState,
|
||
_local: currentLocalState,
|
||
};
|
||
const globalHeadersResolved = this.getMatchingGlobalHeaders(loginEndpoint, expressionContext);
|
||
|
||
const authManager = AuthManager.getInstance();
|
||
|
||
try {
|
||
// AuthManager.login()을 통해 로그인 및 토큰 저장
|
||
// globalHeaders가 있으면 options.headers로 전달
|
||
const loginOptions = Object.keys(globalHeadersResolved).length > 0
|
||
? { headers: globalHeadersResolved }
|
||
: undefined;
|
||
|
||
const user = await authManager.login(
|
||
authType,
|
||
{ email: body.email, password: body.password },
|
||
loginOptions
|
||
);
|
||
|
||
return { user };
|
||
} catch (error: any) {
|
||
// AuthManager에서 이미 API 응답 메시지를 추출한 에러를 throw하므로
|
||
// error.message에 실제 서버 응답 메시지가 들어있음
|
||
const errorMessage = error.message || 'Login failed';
|
||
|
||
const apiError: any = new Error(errorMessage);
|
||
apiError.response = error.response?.data || {};
|
||
apiError.status = error.status || error.response?.status || 500;
|
||
|
||
throw new ActionError(
|
||
errorMessage,
|
||
undefined,
|
||
apiError
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* logout 액션을 처리합니다.
|
||
*
|
||
* AuthManager를 통해 로그아웃하고 토큰을 삭제합니다.
|
||
*
|
||
* @param target 사용하지 않음
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleLogout(
|
||
_target: string,
|
||
_context: ActionContext
|
||
): Promise<void> {
|
||
const authManager = AuthManager.getInstance();
|
||
await authManager.logout();
|
||
}
|
||
|
||
/**
|
||
* setState 액션을 처리합니다.
|
||
*
|
||
* target이 'global'이면 전역 상태 업데이트, 그 외에는 컴포넌트 로컬 상태 업데이트
|
||
*
|
||
* 깊은 병합 지원:
|
||
* - { formData: { name: "value" } } → formData.name만 업데이트, 다른 필드 유지
|
||
* - "..." spread 연산자도 여전히 지원 (하위 호환성)
|
||
*
|
||
* Dot notation 경로 지원:
|
||
* - target: "_local.formData.name" → _local.formData.name에 value 설정
|
||
* - 부분 업데이트로 다른 필드는 유지됨
|
||
*
|
||
* @param params 상태 업데이트 파라미터 (target, payload 포함)
|
||
* @param context 액션 컨텍스트
|
||
* @returns 병합된 상태 (sequence 핸들러에서 context.state 동기화에 사용)
|
||
*/
|
||
private async handleSetState(
|
||
params: Record<string, any>,
|
||
context: ActionContext
|
||
): Promise<Record<string, any> | undefined> {
|
||
logger.log('[handleSetState] START, params:', params);
|
||
const { target = 'component', scope, merge, __render, ...payload } = params;
|
||
|
||
// DevTools 추적 시작
|
||
const devTools = getDevTools();
|
||
const G7Core = (window as any).G7Core;
|
||
|
||
// $parent 또는 $root 타겟 처리 (Phase 4: $parent 바인딩 컨텍스트)
|
||
// 예: "$parent._local", "$parent._global", "$parent._local.form.name", "$root._local"
|
||
if (typeof target === 'string' && (target.startsWith('$parent.') || target.startsWith('$root.'))) {
|
||
return this.handleParentScopeSetState(target, payload, merge, context);
|
||
}
|
||
// sequence 내에서 실행될 때는 context.state를 우선 사용
|
||
// 이를 통해 비동기 React 상태 업데이트를 기다리지 않고도
|
||
// 이전 setState의 결과를 올바르게 참조할 수 있음
|
||
const currentState = (context.state && Object.keys(context.state).length > 0)
|
||
? context.state
|
||
: (G7Core?.state?.get() || {});
|
||
|
||
// 상태 경로 결정
|
||
const statePath = target === 'global' ? '_global' : target.startsWith('_local.') ? target : '_local';
|
||
|
||
// 이전 값 캡처
|
||
const oldValue = statePath === '_global'
|
||
? currentState._global
|
||
: statePath.startsWith('_local.')
|
||
? this.getNestedProperty(currentState._local || {}, statePath.slice(7))
|
||
: currentState._local;
|
||
|
||
// DevTools: 상태 변경 시작
|
||
const setStateId = devTools?.isEnabled() ? devTools.startStateChange(
|
||
statePath,
|
||
oldValue,
|
||
payload,
|
||
{
|
||
actionId: context.actionId,
|
||
handlerType: 'setState',
|
||
source: `target=${target}`,
|
||
}
|
||
) : undefined;
|
||
|
||
// params는 이미 resolveParams에서 처리되었으므로 추가 평가 불필요
|
||
// merge 옵션: "replace" | "shallow" | "deep" (기본값)
|
||
// - replace: 기존 상태 완전 무시, 새 값으로 교체
|
||
// - shallow: 최상위 키만 덮어쓰기 (DynamicRenderer에서 처리)
|
||
// - deep: 재귀적 깊은 병합 (ActionDispatcher에서 수행)
|
||
const mergeMode: 'replace' | 'shallow' | 'deep' = merge === 'replace' ? 'replace' : merge === 'shallow' ? 'shallow' : 'deep';
|
||
const resolvedPayload = mergeMode !== 'deep'
|
||
? { ...payload, __mergeMode: mergeMode as 'replace' | 'shallow', ...(setStateId ? { __setStateId: setStateId } : {}) }
|
||
: { ...payload, ...(setStateId ? { __setStateId: setStateId } : {}) };
|
||
|
||
if (target === 'global') {
|
||
// 전역 상태 업데이트
|
||
if (!this.globalStateUpdater) {
|
||
logger.warn('Global state updater is not set');
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
return undefined;
|
||
}
|
||
// 전역 상태는 항상 G7Core.state.get()에서 최신 값을 가져옴
|
||
// G7Core.state.get()은 이미 _global의 내용물을 직접 반환함 (templateApp.getGlobalState())
|
||
// globalStateUpdater 호출 후 즉시 G7Core.state.get()에 반영되므로
|
||
// sequence 내에서도 항상 최신 상태를 참조함 (별도 동기화 불필요)
|
||
// 주의: currentState._global 병합 시 sequence 내 다른 핸들러(closeModal 등)가
|
||
// 변경한 값을 stale 값으로 덮어쓰는 버그 발생 (2026-01-30 수정)
|
||
const currentGlobal = G7Core?.state?.get() || {};
|
||
|
||
// dot notation 키 처리를 위해 deepMergeWithState 적용
|
||
// __setStateId, __mergeMode는 메타데이터이므로 병합에서 제외 후 다시 추가
|
||
const { __setStateId: _setStateId, __mergeMode: _mergeMode, ...payloadWithoutMeta } = resolvedPayload as any;
|
||
const mergedPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(payloadWithoutMeta, currentGlobal)
|
||
: payloadWithoutMeta; // replace, shallow: 병합 없이 payload 그대로
|
||
const finalPayload = {
|
||
...mergedPayload,
|
||
...(setStateId ? { __setStateId: setStateId } : {}),
|
||
...(mergeMode !== 'deep' ? { __mergeMode: mergeMode as 'replace' | 'shallow' } : {}),
|
||
};
|
||
|
||
logger.log('setState global:', finalPayload);
|
||
// engine-v1.42.0: __render 옵션 전파 (executeAction에서 action.render → __render로 변환)
|
||
this.globalStateUpdater(finalPayload, { render: __render });
|
||
|
||
// DevTools: 상태 변경 완료 (global은 즉시 완료)
|
||
// Note: 실제 렌더링은 비동기로 발생하므로 setTimeout으로 완료 처리
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
// 전역 상태 payload를 반환하여 sequence에서 _global 동기화에 사용
|
||
// __mergeMode를 포함하여 sequence에서 올바른 병합 전략을 선택할 수 있도록 함
|
||
return { __target: 'global', ...(mergeMode !== 'deep' ? { __mergeMode: mergeMode } : {}), ...mergedPayload };
|
||
} else if (target === 'isolated') {
|
||
// 격리된 상태 업데이트 (isolatedState 속성으로 생성된 독립 스코프)
|
||
const isolatedContext = context.isolatedContext;
|
||
|
||
if (isolatedContext) {
|
||
// IsolatedStateContext.mergeState를 통해 상태 업데이트
|
||
const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any;
|
||
isolatedContext.mergeState(cleanPayload, mergeMode);
|
||
logger.log('[handleSetState] isolated state updated:', cleanPayload, 'mergeMode:', mergeMode);
|
||
|
||
// DevTools: 상태 변경 완료
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return { __target: 'isolated', ...cleanPayload };
|
||
} else {
|
||
// 폴백: IsolatedStateContext가 없으면 local로 처리
|
||
logger.warn(
|
||
'[handleSetState] isolated target used but no IsolatedStateContext found. ' +
|
||
'Make sure the component has "isolatedState" attribute. Falling back to local state.'
|
||
);
|
||
|
||
// local 상태로 폴백
|
||
if (context.setState) {
|
||
// engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지)
|
||
const pendingLocal = (window as any).__g7PendingLocalState;
|
||
const currentState = pendingLocal || context.state || {};
|
||
const finalPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, currentState)
|
||
: resolvedPayload; // replace, shallow: DynamicRenderer에서 처리
|
||
context.setState(finalPayload);
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return finalPayload;
|
||
} else {
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
throw new ActionError(
|
||
'isolated target used but no IsolatedStateContext found and no setState function in context'
|
||
);
|
||
}
|
||
}
|
||
} else if (target === 'local' || target === 'component' || target === '_local') {
|
||
// 컴포넌트 로컬 상태 업데이트
|
||
// merge: "shallow" 옵션이면 얕은 병합 (DynamicRenderer에서 처리)
|
||
// 기본값은 깊은 병합 (여기서 수행)
|
||
|
||
// scope 옵션: 'parent' 또는 'root'인 경우 레이아웃 컨텍스트 스택에서 타겟 컨텍스트 사용
|
||
// 모달 등 별도 스코프에서 부모 레이아웃의 상태를 업데이트할 때 유용
|
||
if (scope === 'parent' || scope === 'root') {
|
||
const layoutContextStack: Array<{ state: Record<string, any>; setState: (updates: any) => void }> =
|
||
(window as any).__g7LayoutContextStack || [];
|
||
|
||
if (layoutContextStack.length > 0) {
|
||
// parent: 스택의 마지막 (바로 이전 컨텍스트)
|
||
// root: 스택의 첫 번째 (최상위 컨텍스트)
|
||
const targetContext = scope === 'parent'
|
||
? layoutContextStack[layoutContextStack.length - 1]
|
||
: layoutContextStack[0];
|
||
|
||
if (targetContext?.setState) {
|
||
const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any;
|
||
const finalPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(cleanPayload, targetContext.state || {})
|
||
: cleanPayload; // replace, shallow: 병합 없이 payload 그대로
|
||
|
||
logger.log(`[handleSetState] scope=${scope}: 타겟 컨텍스트에 상태 업데이트`, finalPayload);
|
||
targetContext.setState(finalPayload);
|
||
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return finalPayload;
|
||
}
|
||
}
|
||
|
||
// 스택에 컨텍스트가 없으면 경고 후 current로 폴백
|
||
logger.warn(`[handleSetState] scope=${scope}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`);
|
||
}
|
||
|
||
// scope: 'current' (기본값) 또는 폴백
|
||
// engine-v1.17.2: _isDispatchFallbackContext가 true면 컴포넌트 setState가 아닌 전역 폴백(setGlobalState)임
|
||
// 이 경우 context.setState를 호출하면 _global이 업데이트되므로,
|
||
// globalStateUpdater({ _local: ... }) 경로를 사용해야 _local이 올바르게 업데이트됨
|
||
// (비동기 콜백에서 G7Core.dispatch 호출 시 발생하는 문제 해결)
|
||
const isRealComponentContext = context.setState && !(context as any)._isDispatchFallbackContext;
|
||
logger.log('[handleSetState] isRealComponentContext:', isRealComponentContext,
|
||
'context.setState:', !!context.setState,
|
||
'_isDispatchFallbackContext:', (context as any)._isDispatchFallbackContext);
|
||
if (isRealComponentContext) {
|
||
// 컴포넌트 컨텍스트가 있으면 직접 setState 호출
|
||
logger.log('[handleSetState] Using COMPONENT setState path');
|
||
logger.log('[handleSetState] resolvedPayload:', resolvedPayload);
|
||
logger.log('[handleSetState] context.state:', context.state);
|
||
logger.log('[handleSetState] mergeMode:', mergeMode);
|
||
// engine-v1.17.10: Form 자동 바인딩 후 onComplete에서 호출 시,
|
||
// context.state는 아직 이전 값(React setState 비동기).
|
||
// __g7PendingLocalState에 자동 바인딩이 반영된 최신 상태가 있으므로 우선 사용.
|
||
const pendingLocal = (window as any).__g7PendingLocalState;
|
||
const currentState = pendingLocal || context.state || {};
|
||
|
||
// engine-v1.24.6: context.setState에는 변경 필드만 전달 (전체 _local 축적 방지)
|
||
// 이전: deep 모드에서 deepMerge(resolvedPayload, currentState) = 전체 _local 스냅샷을 전달
|
||
// → localDynamicState에 전체 _local 축적 → SPA 이동 후 stale 필드가 dataContext._local override
|
||
// 수정: resolvedPayload(변경 필드)만 전달, handleLocalSetState가 effectivePrev와 병합 처리
|
||
// 주의: deepMergeWithState({}, payload)로 dot notation 변환 수행 (전체 상태 병합은 하지 않음)
|
||
const convertedPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, {})
|
||
: resolvedPayload;
|
||
logger.log('[handleSetState] convertedPayload (변경 필드만, dot notation 변환):', convertedPayload);
|
||
context.setState!(convertedPayload);
|
||
|
||
// __g7PendingLocalState에는 전체 병합 결과 저장 (getLocal() 동기화용)
|
||
// context.setState와 분리: setState는 변경분만, pending은 전체 상태
|
||
const fullMergedState = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, currentState)
|
||
: mergeMode === 'shallow'
|
||
? { ...(context.state || {}), ...resolvedPayload }
|
||
: resolvedPayload; // replace
|
||
const { __mergeMode: _pmm, __setStateId: _pssid, ...pendingExpected } = fullMergedState as any;
|
||
(window as any).__g7PendingLocalState = pendingExpected;
|
||
logger.log('[handleSetState] __g7PendingLocalState updated:', pendingExpected);
|
||
|
||
// engine-v1.50.0: 컴포넌트 setState(target:"_local")를 canonical source(_global._local)에도 동기화.
|
||
//
|
||
// 배경: 이 COMPONENT path 는 context.setState(저장소 A: React localDynamicState)만 갱신하고
|
||
// _global._local(저장소 B)은 갱신하지 않았다. Form 자동바인딩은 이미 양쪽을 동기화하지만
|
||
// (DynamicRenderer.performStateUpdate → setLocal render:false), 명시적 setState target:"_local"
|
||
// (필터 라디오/체크박스 등)은 B를 갱신하지 않아 비대칭이 있었다. 그 결과:
|
||
// - 사용자가 필터 선택 → A만 갱신, B 는 init_actions 기본값 잔존
|
||
// - 검색(navigate replace:true) → updateQueryParams refetch → updateTemplateData 가
|
||
// currentDataContext._local 을 stale B(_global._local)로 되돌림 → 선택한 필터가 풀림
|
||
// - 새로고침은 handleRouteChange 가 query 기반으로 B 를 재구성하므로 정상
|
||
// GLOBAL STATE UPDATER path(아래 else if)는 이미 globalStateUpdater 로 B 를 갱신하므로,
|
||
// COMPONENT path 도 동일하게 맞춰 일관성을 확보한다.
|
||
//
|
||
// render: false 로 호출하여 추가 React 렌더를 유발하지 않는다 (값만 저장, setGlobalState 라인 ~3211).
|
||
// context.setState 가 이미 저장소 A 렌더 1회를 트리거하므로 클릭당 렌더 횟수는 변하지 않는다.
|
||
// scope: 'parent' | 'root' 및 isolated 타깃은 이 분기(scope:'current', isRealComponentContext)에
|
||
// 도달하기 전에 별도 처리되므로 모달 컨텍스트 오염(트러블슈팅 사례 29)에 영향이 없다.
|
||
//
|
||
// [안전성 의존 관계 — 엔진 수정 시 함께 검토할 것]
|
||
// pendingExpected 의 base 는 currentState = (__g7PendingLocalState ?? context.state) 다.
|
||
// 이 동기화는 B(_global._local)를 patch 머지가 아니라 통째 교체한다(setGlobalState 가 _local 키를 얕은 병합).
|
||
// setLocal(G7CoreGlobals)이 fresh globalLocal 을 baseline 으로 addMissingLeafKeys 보호를 쓰는 것과 달리,
|
||
// 여기서는 그 보호가 없으므로 currentState 가 정합한 _local 전체여야 손실이 없다.
|
||
// 이 정합성은 DynamicRenderer 의 _localInit pending 사전설정 useLayoutEffect(engine-v1.27.0/v1.49.2)에 의존한다:
|
||
// context.state(=extendedDataContext._local)가 stale dynamicState overlay 로 오염될 수 있는 구간
|
||
// (_localInit 미반영 시점)에 __g7PendingLocalState 를 fresh B baseline 으로 미리 채워두므로,
|
||
// currentState 가 pending(정상)을 우선 사용하여 stale context.state 를 건너뛴다.
|
||
// 그 사전설정 경로를 변경/제거하면 사례 13/22(stale 배열의 globalLocal 통째 교체 오염) 재발 가능성을 함께 점검해야 한다.
|
||
// engine-v1.63.3 (공개 이슈 #130): B 동기화의 base 를 A 계열 전체 스냅샷에서
|
||
// live B(_global._local) + 변경 키로 바꾼다.
|
||
//
|
||
// 위 [안전성 의존 관계] 주석이 전제한 "currentState 가 정합한 _local 전체" 는 성립하지
|
||
// 않는 구간이 있다. CKEditor 등 selfManaged 플러그인이 setLocal({render:false}) 로
|
||
// B 에만 본문을 쓰면 React 렌더가 일어나지 않아 extendedDataContext useMemo 가
|
||
// 재계산되지 않고(deps 에 __g7ForcedLocalFields 가 없다 — window 전역이라 deps 가 될 수 없다),
|
||
// context.state 는 본문 타이핑 이전 스냅샷으로 고정된다. 그 상태에서 폭 변경 리렌더가
|
||
// __g7PendingLocalState 를 null 로 지우면(DynamicRenderer.tsx:1110) currentState 가
|
||
// stale A 로 떨어지고, 그것을 B 에 통째로 덮어써 본문이 사라진다.
|
||
//
|
||
// 정답 선례는 같은 파일의 dot-notation path(engine-v1.58.2, :4235~)다. 그것이 이미
|
||
// "live B base + 변경 키" 로 같은 문제를 풀었고 주석에서 이 COMPONENT path 를 위험으로
|
||
// 지목했다. 이번 수정은 그 정책을 이 경로에 맞춘다.
|
||
//
|
||
// 제외 조건:
|
||
// - merge:"replace" — 의도적 리셋이므로 live B 를 base 로 삼지 않는다 (사례 17)
|
||
// - 모달 컨텍스트 스택이 있음 — 모달의 setState 가 페이지 _local 을 흡수하는 것을 막는다
|
||
// (사례 29, setLocal v1.24.7 가드와 동형)
|
||
// 한계: 스택은 openModal/closeModal 핸들러에서만 push/pop 되므로(:4836·:4911)
|
||
// setState 플래그로 여는 모달과 G7Core.modal.open() 은 depth 0 으로 보인다. 이는
|
||
// v1.24.7 가드가 이미 가진 사각과 동일하며, 그 경우에도 새 동작은 "B 통째 교체" 가
|
||
// 아니라 "B 에 병합" 이라 사례 29 대비 악화되지 않는다.
|
||
// - __templateApp 부재 — 종전 전체 스냅샷 폴백 (v1.50.4 호환)
|
||
//
|
||
// merge:"shallow" 는 제외하지 않는다. 현행 shallow 는 pending 을 무시하고 context.state
|
||
// 만 base 로 쓰므로(:4110) 리프 컴포넌트의 부분 상태가 base 가 되어 오히려 이 결함에
|
||
// 더 노출돼 있다.
|
||
const modalDepth = ((window as any).__g7LayoutContextStack || []).length;
|
||
let canonicalMerged: Record<string, any> | undefined;
|
||
|
||
if (this.globalStateUpdater) {
|
||
const canonicalLocal = modalDepth === 0
|
||
? (window as any).__templateApp?.getGlobalState?.()?._local
|
||
: undefined;
|
||
const canUseCanonical = mergeMode !== 'replace'
|
||
&& !!canonicalLocal && typeof canonicalLocal === 'object' && !Array.isArray(canonicalLocal);
|
||
|
||
if (canUseCanonical) {
|
||
// convertedPayload 를 쓰면 안 된다 — 그것은 빈 base 위의 변환이라
|
||
// {"form.title":"X"} → {form:{title:"X"}} 가 되고, 얕게 얹으면 B.form 이 통째
|
||
// 교체되어 고치려던 결함을 재생산한다. deepMergeWithState 는 createNestedUpdate 로
|
||
// 형제 키를 유지한 채 leaf 만 바꾼다. result={...currentState}(:4521) 로 시작하고
|
||
// deepMergeInto 가 참조 동일성 가드(:4608)로 매 레벨 방어 복사하므로 live B 를
|
||
// 변이하지 않는다.
|
||
const merged = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, canonicalLocal as Record<string, any>)
|
||
: { ...(canonicalLocal as Record<string, any>), ...resolvedPayload };
|
||
const { __mergeMode: _cmm, __setStateId: _cssid, ...rest } = merged as any;
|
||
canonicalMerged = rest;
|
||
} else if (mergeMode !== 'replace') {
|
||
logger.log('[handleSetState] canonical _local 미사용 → 전체 스냅샷 폴백',
|
||
{ modalDepth, hasTemplateApp: !!(window as any).__templateApp });
|
||
}
|
||
|
||
// B 쓰기에도 A 전용 키를 보충한다 — setLocal 선례(G7CoreGlobals 의
|
||
// addMissingLeafKeys(globalLocal, dynamicLocal))와 대칭.
|
||
// addMissingLeafKeys 는 base 에 이미 있는 값을 절대 덮지 않으므로 사례 13/22 위험이 없다.
|
||
const syncedLocal = canonicalMerged
|
||
? addMissingLeafKeys(canonicalMerged, pendingExpected)
|
||
: pendingExpected;
|
||
this.globalStateUpdater({ _local: syncedLocal }, { render: false });
|
||
logger.log('[handleSetState] _global._local synced (render:false):', syncedLocal);
|
||
}
|
||
|
||
// engine-v1.17.5: dataKey 자동 바인딩이 있는 컴포넌트에서 setState 핸들러 호출 시
|
||
// dynamicState(Form 자동 바인딩)가 stale 값을 가질 수 있음
|
||
// extendedDataContext 병합 순서: dataContext._local → dynamicState → __g7ForcedLocalFields
|
||
// __g7ForcedLocalFields에 업데이트된 필드를 저장하여 최우선으로 적용
|
||
//
|
||
// engine-v1.17.9: resolvedPayload(변경된 필드만) 사용 — finalPayload(전체 상태 스냅샷) 사용 금지
|
||
// deep 모드에서 finalPayload = deepMergeWithState(resolvedPayload, currentState) → 전체 상태 포함
|
||
// 이 전체 스냅샷을 forcedLocalFields에 넣으면, sequence 내 후속 커스텀 핸들러가
|
||
// localDynamicState에 설정한 값을 forcedLocalFields의 stale 값이 덮어쓰는 문제 발생
|
||
// (예: selectedOptionItems: [] 가 [newItem]을 덮어씀)
|
||
const { __mergeMode: _mm, __setStateId: _ssid, ...cleanPayloadForForced } = resolvedPayload as any;
|
||
if (mergeMode === 'replace') {
|
||
// replace 모드: 기존 forcedFields 무시, payload만으로 리셋
|
||
(window as any).__g7ForcedLocalFields = cleanPayloadForForced;
|
||
} else {
|
||
const existingForced = (window as any).__g7ForcedLocalFields || {};
|
||
(window as any).__g7ForcedLocalFields = this.deepMergeWithState(cleanPayloadForForced, existingForced);
|
||
}
|
||
|
||
logger.log('[handleSetState] __g7ForcedLocalFields updated:', cleanPayloadForForced);
|
||
|
||
// DevTools: 렌더링 완료 후 상태 변경 완료 (DynamicRenderer에서 처리되지만 fallback으로 setTimeout 사용)
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
// sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가
|
||
//
|
||
// engine-v1.63.3 (공개 이슈 #130): 반환값도 live B 기반으로 신선화한다.
|
||
// 요청 body 는 저장소 B 를 읽지 않는다 — handleApiCall 의 getLocal() 은
|
||
// getMatchingGlobalHeaders 전용이고, body 는 sequence 의 currentState(= 이 반환값)에서
|
||
// 온다(handleSequence:5342·5379·5428, 트러블슈팅 사례 15 계약). 따라서 B 쓰기만 고치면
|
||
// B 는 지켜지지만 422 는 그대로 난다.
|
||
//
|
||
// base 는 live B(충돌 leaf 는 B 승), extra 는 A 기반 스냅샷에서 B 에 없는 키만 보충
|
||
// — 사례 13(engine-v1.41.0) 정책 재사용. React 전용 키(loadingActions, DataGrid 선택 등)를
|
||
// 잃지 않는다. __mergeMode 재부착은 handleSequence:5431~5438 계약 보존용.
|
||
const returnedState = canonicalMerged
|
||
? addMissingLeafKeys(canonicalMerged, pendingExpected)
|
||
: fullMergedState;
|
||
return {
|
||
__target: 'local',
|
||
...returnedState,
|
||
...(mergeMode !== 'deep' ? { __mergeMode: mergeMode } : {}),
|
||
};
|
||
} else if (this.globalStateUpdater) {
|
||
// init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트
|
||
logger.log('[handleSetState] Using GLOBAL STATE UPDATER path for _local');
|
||
|
||
// engine-v1.17.2: sequence 내 연속 setState 시 이전 setState 결과 참조 지원
|
||
// globalStateUpdater는 비동기이므로 __g7PendingLocalState를 우선 사용
|
||
const pendingLocal = (window as any).__g7PendingLocalState;
|
||
const currentLocal = pendingLocal || currentState._local || {};
|
||
logger.log('[handleSetState] currentLocal (with pending):', currentLocal);
|
||
|
||
const finalLocal = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, currentLocal)
|
||
: resolvedPayload; // replace, shallow: DynamicRenderer에서 처리
|
||
logger.log('[handleSetState] finalLocal (merged):', finalLocal);
|
||
// engine-v1.42.0: __render 옵션 전파
|
||
this.globalStateUpdater({
|
||
_local: finalLocal,
|
||
}, { render: __render });
|
||
|
||
// engine-v1.17.2: 다음 setState에서 최신 _local 참조 가능하도록 pending 상태 업데이트
|
||
(window as any).__g7PendingLocalState = finalLocal;
|
||
logger.log('[handleSetState] __g7PendingLocalState updated for globalStateUpdater path');
|
||
|
||
// DevTools: 상태 변경 완료
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
// sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가
|
||
return { __target: 'local', ...finalLocal };
|
||
} else {
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
throw new ActionError(
|
||
'setState function is not provided in context and globalStateUpdater is not set'
|
||
);
|
||
}
|
||
} else if (target.startsWith('_local.')) {
|
||
// Dot notation 경로 지원: _local.formData.name 형식
|
||
// _local. 제거 후 경로 파싱
|
||
const path = target.slice(7); // "_local." 제거
|
||
// __mergeMode, __setStateId를 제외한 payload에서 value 추출
|
||
const { __mergeMode, __setStateId, ...cleanPayload } = resolvedPayload as any;
|
||
const value = cleanPayload.value !== undefined
|
||
? cleanPayload.value
|
||
: Object.values(cleanPayload)[0];
|
||
|
||
if (context.setState) {
|
||
// 컴포넌트 컨텍스트가 있으면 직접 setState 호출
|
||
// engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지)
|
||
const pendingLocal = (window as any).__g7PendingLocalState;
|
||
const currentState = pendingLocal || context.state || {};
|
||
const update = this.createNestedUpdate(path, value, currentState);
|
||
const mergedState = { ...currentState, ...update };
|
||
context.setState(update);
|
||
|
||
// engine-v1.58.2: dot notation 경로도 canonical source(_global._local)에 동기화한다.
|
||
//
|
||
// 배경: 이 분기는 context.setState(저장소 A: React localDynamicState)만 갱신하고
|
||
// _global._local(저장소 B)은 갱신하지 않았다. 커스텀 핸들러(모듈/플러그인)가 상태를
|
||
// 읽는 유일한 공개 통로인 G7Core.state.getLocal() 은 B 를 읽으므로, dot notation 으로
|
||
// 기록된 값은 핸들러에게 undefined 로 보였다 (체크아웃 간편결제 선택 → PG 플러그인이
|
||
// 선택값을 못 읽어 통합결제창이 열린 결함). target: "local" 형태는 engine-v1.50.0 에서
|
||
// 이미 B 를 동기화하므로, 남아 있던 비대칭을 해소한다.
|
||
//
|
||
// [동기화 기준을 전체 스냅샷이 아니라 "live B + 변경 키"로 잡은 이유]
|
||
// COMPONENT path(target:"local")는 currentState(=pending ?? context.state) 전체 스냅샷을
|
||
// B 에 통째로 넘긴다. context.state 는 클릭된 리프 컴포넌트의 부분 상태일 수 있어
|
||
// B 의 다른 키를 잃을 수 있고(engine-v1.50.0 주석의 안전성 의존 관계), 앞선
|
||
// 커스텀 핸들러 setLocal 결과를 stale base 로 되돌릴 수 있다(트러블슈팅 사례 24).
|
||
// 여기서는 live B 를 base 로 삼고 변경된 최상위 키만 얹으므로 두 위험이 모두 없다.
|
||
// B 를 못 읽는 환경(테스트 등 __templateApp 부재)에서만 전체 스냅샷으로 폴백한다.
|
||
//
|
||
// [__g7PendingLocalState 를 쓰지 않는 이유]
|
||
// pending 에 B 기반 전체 스냅샷을 넣으면 handleLocalSetState 의 effectivePrev 병합에서
|
||
// React 전용 상태(DataGrid expandedRows 등)를 초기값으로 덮어쓴다(트러블슈팅 사례 22).
|
||
// setGlobalState 는 globalState 를 동기 대입하므로 pending 없이도 같은 tick 의
|
||
// getLocal() 이 즉시 최신값을 읽는다 — 오염 경로에 진입할 이유가 없다.
|
||
//
|
||
// scope: 'parent' | 'root' 은 이 분기가 구현하지 않는 타깃이므로 동기화 대상에서 제외한다
|
||
// (모달에서 부모 스코프를 노린 setState 가 페이지 저장소를 오염시키는 것 방지 — 사례 29).
|
||
if (this.globalStateUpdater && scope !== 'parent' && scope !== 'root') {
|
||
const canonicalLocal = (window as any).__templateApp?.getGlobalState?.()?._local;
|
||
const syncedLocal = canonicalLocal && typeof canonicalLocal === 'object'
|
||
? { ...canonicalLocal, ...update }
|
||
: mergedState;
|
||
this.globalStateUpdater({ _local: syncedLocal }, { render: false });
|
||
logger.log('[handleSetState] _global._local synced for dot notation (render:false):', syncedLocal);
|
||
}
|
||
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return mergedState;
|
||
} else {
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
throw new ActionError(
|
||
'setState function is not provided in context'
|
||
);
|
||
}
|
||
} else {
|
||
// 컴포넌트 로컬 상태 업데이트 (기본)
|
||
// merge: "shallow" 옵션이면 얕은 병합, 기본값은 깊은 병합
|
||
if (!context.setState) {
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
throw new ActionError(
|
||
'setState function is not provided in context'
|
||
);
|
||
}
|
||
// engine-v1.17.10: pendingLocal 우선 사용 (Form 자동 바인딩 경합 방지)
|
||
const pendingLocal = (window as any).__g7PendingLocalState;
|
||
const currentState = pendingLocal || context.state || {};
|
||
const finalPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(resolvedPayload, currentState)
|
||
: resolvedPayload; // replace, shallow: DynamicRenderer에서 처리
|
||
context.setState(finalPayload);
|
||
// DevTools: 상태 변경 완료
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return finalPayload;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* $parent 또는 $root 스코프의 상태 업데이트를 처리합니다.
|
||
*
|
||
* 모달 등 자식 레이아웃에서 부모 레이아웃의 상태를 직접 수정할 때 사용합니다.
|
||
* `__g7LayoutContextStack`에서 부모/루트 컨텍스트를 가져와 상태를 업데이트합니다.
|
||
*
|
||
* @param target 타겟 문자열 (예: "$parent._local", "$parent._global", "$parent._local.form.name")
|
||
* @param payload 업데이트할 데이터
|
||
* @param merge 병합 모드 ('shallow' | undefined)
|
||
* @param context 현재 액션 컨텍스트
|
||
* @returns 업데이트된 상태 또는 undefined
|
||
*
|
||
* @example
|
||
* // 부모의 _local 전체 업데이트
|
||
* { "handler": "setState", "params": { "target": "$parent._local", "form": { "name": "value" } } }
|
||
*
|
||
* @example
|
||
* // 부모의 _local 내 특정 경로 업데이트
|
||
* { "handler": "setState", "params": { "target": "$parent._local.form.name", "value": "newValue" } }
|
||
*
|
||
* @example
|
||
* // 부모의 _global 업데이트
|
||
* { "handler": "setState", "params": { "target": "$parent._global", "modalResult": { "confirmed": true } } }
|
||
*
|
||
* @since engine-v1.16.0
|
||
*/
|
||
private async handleParentScopeSetState(
|
||
target: string,
|
||
payload: Record<string, any>,
|
||
merge: string | undefined,
|
||
context: ActionContext
|
||
): Promise<Record<string, any> | undefined> {
|
||
const devTools = getDevTools();
|
||
const G7Core = (window as any).G7Core;
|
||
const mergeMode: 'replace' | 'shallow' | 'deep' = merge === 'replace' ? 'replace' : merge === 'shallow' ? 'shallow' : 'deep';
|
||
|
||
// 타겟 파싱: "$parent._local" → scope='$parent', stateType='_local', path=undefined
|
||
// "$parent._local.form.name" → scope='$parent', stateType='_local', path='form.name'
|
||
const isParent = target.startsWith('$parent.');
|
||
const scopePrefix = isParent ? '$parent.' : '$root.';
|
||
const afterScope = target.slice(scopePrefix.length); // "_local" 또는 "_local.form.name" 또는 "_global"
|
||
|
||
let stateType: '_local' | '_global';
|
||
let nestedPath: string | undefined;
|
||
|
||
if (afterScope === '_local' || afterScope === '_global') {
|
||
stateType = afterScope as '_local' | '_global';
|
||
nestedPath = undefined;
|
||
} else if (afterScope.startsWith('_local.')) {
|
||
stateType = '_local';
|
||
nestedPath = afterScope.slice(7); // "_local." 제거
|
||
} else if (afterScope.startsWith('_global.')) {
|
||
stateType = '_global';
|
||
nestedPath = afterScope.slice(8); // "_global." 제거
|
||
} else {
|
||
logger.warn(`[handleParentScopeSetState] 지원하지 않는 타겟 형식: ${target}. _local 또는 _global으로 시작해야 합니다.`);
|
||
return undefined;
|
||
}
|
||
|
||
// 레이아웃 컨텍스트 스택에서 부모/루트 컨텍스트 가져오기
|
||
const layoutContextStack: Array<{
|
||
state: Record<string, any>;
|
||
setState: (updates: any) => void;
|
||
dataContext?: Record<string, any>;
|
||
}> = (window as any).__g7LayoutContextStack || [];
|
||
|
||
if (layoutContextStack.length === 0) {
|
||
logger.warn(`[handleParentScopeSetState] 레이아웃 컨텍스트 스택이 비어있습니다. target=${target}`);
|
||
return undefined;
|
||
}
|
||
|
||
// parent: 스택의 마지막 (바로 이전 컨텍스트)
|
||
// root: 스택의 첫 번째 (최상위 컨텍스트)
|
||
const targetContext = isParent
|
||
? layoutContextStack[layoutContextStack.length - 1]
|
||
: layoutContextStack[0];
|
||
|
||
if (!targetContext) {
|
||
logger.warn(`[handleParentScopeSetState] 타겟 컨텍스트를 찾을 수 없습니다. target=${target}`);
|
||
return undefined;
|
||
}
|
||
|
||
// DevTools 추적
|
||
const oldValue = stateType === '_global'
|
||
? targetContext.state?._global
|
||
: targetContext.state?._local;
|
||
const setStateId = devTools?.isEnabled() ? devTools.startStateChange(
|
||
target,
|
||
oldValue,
|
||
payload,
|
||
{
|
||
actionId: (context as any).actionId,
|
||
handlerType: 'setState',
|
||
source: `target=${target}`,
|
||
}
|
||
) : undefined;
|
||
|
||
// 메타데이터 제거
|
||
const { __mergeMode, __setStateId, ...cleanPayload } = payload as any;
|
||
|
||
if (stateType === '_global') {
|
||
// $parent._global 또는 $root._global 업데이트
|
||
// 전역 상태는 globalStateUpdater를 통해 업데이트
|
||
if (!this.globalStateUpdater) {
|
||
logger.warn('[handleParentScopeSetState] globalStateUpdater가 설정되지 않았습니다.');
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
return undefined;
|
||
}
|
||
|
||
const currentGlobal = G7Core?.state?.get() || {};
|
||
|
||
if (nestedPath) {
|
||
// $parent._global.path.to.field 형식 - 중첩 경로 업데이트
|
||
const value = cleanPayload.value !== undefined ? cleanPayload.value : Object.values(cleanPayload)[0];
|
||
const nestedUpdate = this.createNestedUpdate(nestedPath, value, currentGlobal);
|
||
const finalPayload = { ...currentGlobal, ...nestedUpdate };
|
||
logger.log(`[handleParentScopeSetState] ${target}: 전역 상태 중첩 경로 업데이트`, finalPayload);
|
||
this.globalStateUpdater(finalPayload);
|
||
} else {
|
||
// $parent._global 형식 - 전체 병합
|
||
const finalPayload = mergeMode === 'deep'
|
||
? this.deepMergeWithState(cleanPayload, currentGlobal)
|
||
: cleanPayload; // replace, shallow: 병합 없이 payload 그대로
|
||
logger.log(`[handleParentScopeSetState] ${target}: 전역 상태 업데이트 (mergeMode=${mergeMode})`, finalPayload);
|
||
this.globalStateUpdater(mergeMode !== 'deep' ? { ...finalPayload, __mergeMode: mergeMode } : finalPayload);
|
||
}
|
||
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return { __target: target, ...cleanPayload };
|
||
} else {
|
||
// $parent._local 또는 $root._local 업데이트
|
||
if (!targetContext.setState) {
|
||
logger.warn(`[handleParentScopeSetState] 타겟 컨텍스트에 setState가 없습니다. target=${target}`);
|
||
if (setStateId && devTools) devTools.completeStateChange(setStateId);
|
||
return undefined;
|
||
}
|
||
|
||
const currentLocal = targetContext.state || {};
|
||
|
||
if (nestedPath) {
|
||
// $parent._local.path.to.field 형식 - 중첩 경로 업데이트
|
||
const value = cleanPayload.value !== undefined ? cleanPayload.value : Object.values(cleanPayload)[0];
|
||
const update = this.createNestedUpdate(nestedPath, value, currentLocal);
|
||
const mergedState = { ...currentLocal, ...update };
|
||
logger.log(`[handleParentScopeSetState] ${target}: 로컬 상태 중첩 경로 업데이트`, update);
|
||
targetContext.setState(update);
|
||
// 스택의 state와 dataContext._local 모두 업데이트하여
|
||
// 다음 setState 호출 시 currentLocal이 최신 값을 읽고,
|
||
// 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함
|
||
targetContext.state = mergedState;
|
||
if (targetContext.dataContext) {
|
||
targetContext.dataContext._local = mergedState;
|
||
}
|
||
// ParentContextProvider를 통해 모달만 선택적으로 리렌더링
|
||
// 전체 앱 리렌더링 없이 모달만 업데이트됨
|
||
triggerModalParentUpdate();
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return mergedState;
|
||
} else {
|
||
// $parent._local 형식 - 전체 병합
|
||
// DynamicRenderer.handleLocalSetState에서 __mergeMode를 참조하여 병합 처리
|
||
const setStatePayload = mergeMode !== 'deep'
|
||
? { ...cleanPayload, __mergeMode: mergeMode }
|
||
: cleanPayload;
|
||
// 실제 상태 결과 계산 (스택 동기화용, 메타데이터 없음)
|
||
const expectedState = mergeMode === 'replace'
|
||
? cleanPayload
|
||
: mergeMode === 'shallow'
|
||
? { ...currentLocal, ...cleanPayload }
|
||
: this.deepMergeWithState(cleanPayload, currentLocal);
|
||
logger.log(`[handleParentScopeSetState] ${target}: 로컬 상태 업데이트 (mergeMode=${mergeMode})`, expectedState);
|
||
targetContext.setState(setStatePayload);
|
||
// 스택의 state와 dataContext._local 모두 업데이트하여
|
||
// 다음 setState 호출 시 currentLocal이 최신 값을 읽고,
|
||
// 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함
|
||
targetContext.state = expectedState;
|
||
if (targetContext.dataContext) {
|
||
targetContext.dataContext._local = expectedState;
|
||
}
|
||
// ParentContextProvider를 통해 모달만 선택적으로 리렌더링
|
||
// 전체 앱 리렌더링 없이 모달만 업데이트됨
|
||
triggerModalParentUpdate();
|
||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||
return expectedState;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* payload를 현재 상태와 깊은 병합합니다.
|
||
*
|
||
* 중첩 객체의 경우 기존 상태의 다른 필드를 유지하면서 지정된 필드만 업데이트합니다.
|
||
* 단, 'errors' 키는 항상 완전히 교체됩니다 (validation 에러는 병합하면 안됨).
|
||
*
|
||
* @param payload 업데이트할 데이터
|
||
* @param currentState 현재 상태
|
||
* @returns 병합된 업데이트 객체
|
||
*
|
||
* @example
|
||
* // currentState: { formData: { name: "old", email: "a@b.com" }, hasChanges: false }
|
||
* // payload: { formData: { name: "new" }, hasChanges: true }
|
||
* // result: { formData: { name: "new", email: "a@b.com" }, hasChanges: true }
|
||
*
|
||
* @example errors는 완전 교체
|
||
* // currentState: { errors: { name: ["필수"], email: ["필수"] } }
|
||
* // payload: { errors: { email: ["필수"] } }
|
||
* // result: { errors: { email: ["필수"] } } -- name 에러 제거됨
|
||
*/
|
||
private deepMergeWithState(
|
||
payload: Record<string, any>,
|
||
currentState: Record<string, any>
|
||
): Record<string, any> {
|
||
// 깊은 병합에서 제외할 키 목록 (완전 교체되어야 하는 필드들)
|
||
const replaceOnlyKeys = ['errors'];
|
||
|
||
// '...' 키가 있으면 먼저 spread 처리
|
||
let processedPayload = payload;
|
||
if ('...' in payload) {
|
||
const spreadValue = payload['...'];
|
||
if (spreadValue && typeof spreadValue === 'object' && !Array.isArray(spreadValue)) {
|
||
// spread 값을 먼저 펼치고, 나머지 키를 덮어쓰기
|
||
const { '...': _, ...rest } = payload;
|
||
processedPayload = { ...spreadValue, ...rest };
|
||
} else {
|
||
// spread 값이 유효하지 않으면 '...' 키만 제거
|
||
const { '...': _, ...rest } = payload;
|
||
processedPayload = rest;
|
||
}
|
||
}
|
||
|
||
// 기존 상태를 기반으로 시작 (모든 기존 필드 유지)
|
||
const result: Record<string, any> = { ...currentState };
|
||
|
||
for (const [key, value] of Object.entries(processedPayload)) {
|
||
// errors 등 특정 키는 항상 완전히 교체 (validation 에러 등은 병합하면 안됨)
|
||
if (replaceOnlyKeys.includes(key)) {
|
||
result[key] = value;
|
||
continue;
|
||
}
|
||
|
||
// Dot notation 키 처리: "form.language_currency.currencies" 형태
|
||
// 단, spread 연산자 '...'는 제외
|
||
if (key.includes('.') && key !== '...') {
|
||
// 중요: 여러 dot notation 키가 같은 루트를 공유할 때 (예: form.a, form.b)
|
||
// 이전 키의 변경사항이 유지되도록 result를 기준으로 nestedUpdate 생성
|
||
const nestedUpdate = this.createNestedUpdate(key, value, result);
|
||
// 결과를 깊은 병합
|
||
this.deepMergeInto(result, nestedUpdate, result);
|
||
continue;
|
||
}
|
||
|
||
// File, Blob, Date 등 non-plain 객체는 재귀 병합하지 않고 직접 할당
|
||
// (spread 연산자로 복사하면 내부 데이터가 소실됨)
|
||
if (
|
||
value !== null &&
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
Object.getPrototypeOf(value) !== Object.prototype &&
|
||
Object.getPrototypeOf(value) !== null
|
||
) {
|
||
result[key] = value;
|
||
continue;
|
||
}
|
||
|
||
// 값이 일반 객체이고, 현재 상태에도 해당 키가 객체로 존재하면 재귀적 깊은 병합
|
||
if (
|
||
value !== null &&
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
currentState[key] !== null &&
|
||
typeof currentState[key] === 'object' &&
|
||
!Array.isArray(currentState[key])
|
||
) {
|
||
// 중첩 객체: 재귀적으로 깊은 병합 수행
|
||
result[key] = this.deepMergeWithState(value, currentState[key]);
|
||
} else {
|
||
// 기본값, 배열, null 등: 그대로 덮어쓰기
|
||
result[key] = value;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* source 객체를 target 객체에 깊은 병합합니다 (in-place).
|
||
*
|
||
* @param target 병합 대상 객체
|
||
* @param source 병합할 소스 객체
|
||
* @param currentState 현재 상태 (깊은 병합 시 기존 값 참조용)
|
||
*/
|
||
private deepMergeInto(
|
||
target: Record<string, any>,
|
||
source: Record<string, any>,
|
||
currentState: Record<string, any>
|
||
): void {
|
||
for (const [key, value] of Object.entries(source)) {
|
||
// value가 null이면 그대로 덮어쓰기 (null로 초기화하는 경우)
|
||
if (value === null) {
|
||
target[key] = value;
|
||
continue;
|
||
}
|
||
|
||
// target[key]가 null이면 source 값으로 교체
|
||
if (target[key] === null) {
|
||
target[key] = value;
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
target[key] !== undefined &&
|
||
target[key] !== null &&
|
||
typeof target[key] === 'object' &&
|
||
!Array.isArray(target[key])
|
||
) {
|
||
// 원본 상태와 같은 참조면 shallow copy 후 병합 (원본 변이 방지)
|
||
if (target[key] === currentState[key]) {
|
||
target[key] = { ...target[key] };
|
||
}
|
||
// 두 값이 모두 객체인 경우 재귀적으로 병합
|
||
this.deepMergeInto(target[key], value, currentState[key] || {});
|
||
} else if (
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
target[key] === undefined &&
|
||
currentState[key] !== null &&
|
||
typeof currentState[key] === 'object' &&
|
||
!Array.isArray(currentState[key])
|
||
) {
|
||
// target에 없지만 currentState에 있으면 currentState와 병합
|
||
target[key] = { ...currentState[key], ...value };
|
||
} else {
|
||
target[key] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* dot notation 경로를 기반으로 중첩 업데이트 객체를 생성합니다.
|
||
*
|
||
* 부분 업데이트를 수행하여 다른 필드는 유지합니다.
|
||
*
|
||
* @param path dot notation 경로 (예: "formData.name")
|
||
* @param value 설정할 값
|
||
* @param currentState 현재 상태
|
||
* @returns 업데이트 객체
|
||
*
|
||
* @example
|
||
* createNestedUpdate("formData.name", "John", { formData: { name: "", email: "" } })
|
||
* // 결과: { formData: { name: "John", email: "" } }
|
||
*/
|
||
private createNestedUpdate(
|
||
path: string,
|
||
value: any,
|
||
currentState: Record<string, any>
|
||
): Record<string, any> {
|
||
const keys = path.split('.');
|
||
|
||
if (keys.length === 1) {
|
||
// 단일 키인 경우
|
||
return { [keys[0]]: value };
|
||
}
|
||
|
||
// 중첩 경로인 경우: 깊은 복사 후 부분 업데이트
|
||
const result: Record<string, any> = {};
|
||
let current = result;
|
||
let stateCursor: Record<string, any> | null | undefined = currentState;
|
||
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
const key = keys[i];
|
||
// 현재 상태의 해당 키 값을 얕은 복사
|
||
// stateCursor가 null이거나 객체가 아닌 경우 빈 객체로 처리
|
||
const stateValue: unknown = stateCursor?.[key];
|
||
const isPlainObject =
|
||
stateValue !== null &&
|
||
stateValue !== undefined &&
|
||
typeof stateValue === 'object' &&
|
||
!Array.isArray(stateValue);
|
||
|
||
if (isPlainObject) {
|
||
current[key] = { ...(stateValue as Record<string, unknown>) };
|
||
} else {
|
||
current[key] = {};
|
||
}
|
||
current = current[key];
|
||
// stateCursor 업데이트: 객체인 경우만 진행, 아니면 빈 객체로
|
||
stateCursor = isPlainObject
|
||
? (stateValue as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
// 마지막 키에 값 설정
|
||
current[keys[keys.length - 1]] = value;
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* dot notation 경로에서 중첩 객체를 생성합니다.
|
||
*
|
||
* resultTo 패턴에서 핸들러 결과를 상태에 저장할 때 사용합니다.
|
||
* createNestedUpdate와 달리 현재 상태 없이 순수하게 중첩 객체만 생성합니다.
|
||
*
|
||
* @param path dot notation 경로 (예: "productColumns", "calculatedPrices.123")
|
||
* @param value 저장할 값
|
||
* @returns 중첩 객체
|
||
*
|
||
* @example
|
||
* buildNestedUpdate("productColumns", [...])
|
||
* // 결과: { productColumns: [...] }
|
||
*
|
||
* buildNestedUpdate("calculatedPrices.123", { KRW: 1000 })
|
||
* // 결과: { calculatedPrices: { "123": { KRW: 1000 } } }
|
||
*/
|
||
private buildNestedUpdate(path: string, value: any): Record<string, any> {
|
||
const keys = path.split('.');
|
||
|
||
if (keys.length === 1) {
|
||
return { [keys[0]]: value };
|
||
}
|
||
|
||
// 중첩 경로인 경우 객체 생성
|
||
const result: Record<string, any> = {};
|
||
let current = result;
|
||
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
current[keys[i]] = {};
|
||
current = current[keys[i]];
|
||
}
|
||
|
||
current[keys[keys.length - 1]] = value;
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* setError 액션을 처리합니다.
|
||
*
|
||
* 컴포넌트의 에러 상태를 설정합니다.
|
||
* - target이 {{}} 바인딩이면: context.data에서 값 추출 (예: {{error.message}})
|
||
* - target이 $t:로 시작하면: 다국어 키로 번역
|
||
* - 그 외: 문자열 그대로 사용
|
||
*
|
||
* params.stateTarget으로 에러를 저장할 상태 스코프를 지정할 수 있습니다:
|
||
* - 'local' (기본값): _local.apiError에 저장
|
||
* - 'global': _global.apiError에 저장
|
||
* - 'isolated': _isolated.apiError에 저장
|
||
*
|
||
* @param target 에러 메시지, 다국어 키, 또는 바인딩 표현식
|
||
* @param params 추가 파라미터 (stateTarget: 'local' | 'global' | 'isolated')
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleSetError(
|
||
target: string,
|
||
params: any,
|
||
context: ActionContext
|
||
): Promise<void> {
|
||
let errorMessage = target;
|
||
|
||
// {{}} 바인딩 처리 (예: {{error.message}}, {{response.error}})
|
||
errorMessage = this.resolveValue(errorMessage, context.data);
|
||
|
||
// $t: 다국어 구문 처리
|
||
if (this.translationEngine && this.translationContext && errorMessage.startsWith('$t:')) {
|
||
errorMessage = this.translationEngine.resolveTranslations(
|
||
errorMessage,
|
||
this.translationContext,
|
||
context.data
|
||
);
|
||
}
|
||
|
||
// stateTarget에 따라 에러 저장 위치 결정
|
||
const stateTarget = params?.stateTarget || 'local';
|
||
|
||
if (stateTarget === 'isolated' && context.isolatedContext) {
|
||
// 격리된 상태에 저장
|
||
context.isolatedContext.mergeState({ apiError: errorMessage });
|
||
logger.log('[handleSetError] Error set to isolated state:', errorMessage);
|
||
} else if (stateTarget === 'global' && this.globalStateUpdater) {
|
||
// 전역 상태에 저장
|
||
this.globalStateUpdater({ apiError: errorMessage });
|
||
logger.log('[handleSetError] Error set to global state:', errorMessage);
|
||
} else if (context.setState) {
|
||
// 로컬 상태에 저장 (기본값)
|
||
context.setState({ apiError: errorMessage });
|
||
logger.log('[handleSetError] Error set to local state:', errorMessage);
|
||
} else {
|
||
logger.warn('[handleSetError] Cannot set error: no state updater available');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* openModal 액션을 처리합니다.
|
||
*
|
||
* 모달 스택에 모달 ID를 추가하여 모달을 엽니다.
|
||
* 멀티 모달(중첩 모달)을 지원합니다.
|
||
*
|
||
* @param target 모달 ID
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleOpenModal(
|
||
target: string,
|
||
context: ActionContext
|
||
): Promise<void> {
|
||
if (!this.globalStateUpdater) {
|
||
logger.warn('Global state updater is not set for openModal');
|
||
return;
|
||
}
|
||
|
||
// 현재 모달 스택 가져오기
|
||
const currentStack = context.data?._global?.modalStack || [];
|
||
|
||
// 이미 열려있는 모달이면 스택에 추가하지 않음
|
||
if (currentStack.includes(target)) {
|
||
logger.warn(`Modal "${target}" is already open`);
|
||
return;
|
||
}
|
||
|
||
// 레이아웃 컨텍스트 스택에 현재 컨텍스트 push (scope: 'parent' 및 $parent 바인딩 지원용)
|
||
// 모달에서 부모 레이아웃의 상태에 접근할 수 있도록 함
|
||
//
|
||
// context.setState/context.state가 없는 경우 (DataSourceManager onSuccess 등):
|
||
// G7Core.state.getLocal()로 현재 상태를 가져오고 globalStateUpdater를 통한 proxy setState 사용
|
||
const G7Core = (window as any).G7Core;
|
||
const hasDirectContext = context.setState && context.state !== undefined;
|
||
const fallbackState = !hasDirectContext ? (G7Core?.state?.getLocal?.() || {}) : undefined;
|
||
const hasFallbackContext = !hasDirectContext && fallbackState !== undefined && this.globalStateUpdater;
|
||
|
||
if (hasDirectContext || hasFallbackContext) {
|
||
const layoutContextStack: Array<{
|
||
state: Record<string, any>;
|
||
setState: (updates: any) => void;
|
||
dataContext?: Record<string, any>; // $parent 바인딩용 전체 데이터 컨텍스트
|
||
}> = (window as any).__g7LayoutContextStack || [];
|
||
|
||
// 상태와 setState 결정
|
||
const effectiveState = hasDirectContext ? context.state : fallbackState!;
|
||
const effectiveSetState = hasDirectContext
|
||
? context.setState!
|
||
: (updates: any) => {
|
||
// DataSourceManager onSuccess 등에서 context.setState가 없는 경우
|
||
// globalStateUpdater를 통해 _local 업데이트
|
||
this.globalStateUpdater!({ _local: updates });
|
||
};
|
||
|
||
layoutContextStack.push({
|
||
state: effectiveState,
|
||
setState: effectiveSetState,
|
||
// $parent 바인딩을 위해 필요한 상태만 저장 (API 응답 데이터 제외)
|
||
// context.data에 포함된 product_labels 등 대량 API 데이터는 복사하지 않음
|
||
// 이를 통해 메모리 사용량을 크게 줄임 (120MB → 수 KB)
|
||
// sequence 내에서 setState 후 호출 시 context.state에 최신 _local 상태가 있음
|
||
// context.state를 우선 사용하여 stale closure 문제 방지
|
||
dataContext: {
|
||
_local: effectiveState || context.data?._local,
|
||
// hasDirectContext: 기존 동작 유지 (context.data?._global만 사용)
|
||
// hasFallbackContext: context.data?._global이 없으므로 G7Core.state.get() 사용
|
||
_global: hasDirectContext ? context.data?._global : (context.data?._global || G7Core?.state?.get?.()),
|
||
_computed: context.data?._computed,
|
||
},
|
||
});
|
||
(window as any).__g7LayoutContextStack = layoutContextStack;
|
||
logger.log(`[handleOpenModal] 레이아웃 컨텍스트 스택에 push, 스택 크기: ${layoutContextStack.length}, state:`, effectiveState, hasDirectContext ? '(direct)' : '(fallback via G7Core)');
|
||
} else {
|
||
logger.warn(`[handleOpenModal] 컨텍스트 스택에 push 실패 - setState: ${!!context.setState}, state: ${context.state !== undefined}, G7Core: ${!!G7Core?.state?.getLocal}`);
|
||
}
|
||
|
||
// 모달 스택에 새 모달 추가 (스택 방식으로 중첩 지원)
|
||
const newStack = [...currentStack, target];
|
||
this.globalStateUpdater({
|
||
modalStack: newStack,
|
||
// 하위 호환성을 위해 activeModal도 유지 (최상위 모달)
|
||
activeModal: target,
|
||
});
|
||
|
||
// DevTools: 모달 열림 추적
|
||
const devTools = getDevTools();
|
||
if (devTools?.isEnabled()) {
|
||
const parentModalId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined;
|
||
devTools.trackModalOpen?.({
|
||
modalId: target,
|
||
modalName: target,
|
||
scopeType: 'isolated',
|
||
parentModalId,
|
||
initialState: context.state ? { ...context.state } : {},
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* closeModal 액션을 처리합니다.
|
||
*
|
||
* 모달 스택에서 최상위 모달을 제거합니다.
|
||
* 스택이 비어있지 않으면 이전 모달이 다시 표시됩니다.
|
||
*
|
||
* @param context 액션 컨텍스트 (선택적)
|
||
*/
|
||
private async handleCloseModal(context?: ActionContext): Promise<void> {
|
||
if (!this.globalStateUpdater) {
|
||
logger.warn('Global state updater is not set for closeModal');
|
||
return;
|
||
}
|
||
|
||
// 현재 모달 스택 가져오기
|
||
const currentStack = context?.data?._global?.modalStack || [];
|
||
|
||
// 닫히는 모달 ID (스택의 마지막 항목)
|
||
const closingModalId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : null;
|
||
|
||
// 스택에서 최상위 모달 제거
|
||
const newStack = currentStack.slice(0, -1);
|
||
|
||
// 스택이 비어있으면 activeModal도 null로 설정
|
||
const newActiveModal = newStack.length > 0 ? newStack[newStack.length - 1] : null;
|
||
|
||
// 레이아웃 컨텍스트 스택에서 pop (scope: 'parent' 및 $parent 바인딩 지원용)
|
||
const layoutContextStack: Array<{
|
||
state: Record<string, any>;
|
||
setState: (updates: any) => void;
|
||
dataContext?: Record<string, any>;
|
||
}> = (window as any).__g7LayoutContextStack || [];
|
||
if (layoutContextStack.length > 0) {
|
||
// 메모리 정리: pop 전에 dataContext 참조 해제
|
||
const poppedContext = layoutContextStack[layoutContextStack.length - 1];
|
||
if (poppedContext) {
|
||
poppedContext.dataContext = undefined;
|
||
}
|
||
layoutContextStack.pop();
|
||
(window as any).__g7LayoutContextStack = layoutContextStack;
|
||
logger.log(`[handleCloseModal] 레이아웃 컨텍스트 스택에서 pop, 스택 크기: ${layoutContextStack.length}`);
|
||
}
|
||
|
||
// DevTools: 모달 닫힘 추적
|
||
if (closingModalId) {
|
||
const devTools = getDevTools();
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackModalClose?.(closingModalId, context?.state);
|
||
}
|
||
}
|
||
|
||
this.globalStateUpdater({
|
||
modalStack: newStack,
|
||
activeModal: newActiveModal,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* showAlert 액션을 처리합니다.
|
||
*
|
||
* @param target 알림 메시지
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleShowAlert(
|
||
target: string,
|
||
context: ActionContext
|
||
): Promise<void> {
|
||
let message = target;
|
||
|
||
// $t: 다국어 구문 처리
|
||
if (this.translationEngine && this.translationContext && target.startsWith('$t:')) {
|
||
message = this.translationEngine.resolveTranslations(
|
||
target,
|
||
this.translationContext,
|
||
context.data
|
||
);
|
||
}
|
||
|
||
alert(message);
|
||
}
|
||
|
||
/**
|
||
* toast 액션을 처리합니다.
|
||
*
|
||
* 토스트 알림을 표시하며, 여러 토스트가 스택으로 쌓입니다.
|
||
*
|
||
* @param params 토스트 파라미터 (type, message, icon, duration)
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleToast(
|
||
params: Record<string, any>,
|
||
context: ActionContext
|
||
): Promise<void> {
|
||
const { type = 'info', message, icon, duration } = params;
|
||
|
||
// [IDV 가드 토스트 중복 억제]
|
||
// 본인확인은 성공했으나 부가 목적(성인인증 등)을 충족하지 못해 challenge 가 실패로 끝나면,
|
||
// provider 가 "성인 인증이 필요합니다" 같은 고유 사유를 이미 토스트로 표출한다. 그 직후
|
||
// 원 요청의 onError 가 generic IDV 가드 토스트("본인 확인이 필요합니다")를 중복 발화하는데,
|
||
// 이 토스트가 (1) error 타입이고 (2) 원인이 IDV 가드 응답(error_code) 이며
|
||
// (3) provider 가 도메인 안내 표출 신호를 남긴 경우 1회 skip 한다.
|
||
// 일반 본인인증 실패(본인확인 자체 실패/취소)는 신호가 없어 그대로 표출된다.
|
||
if (type === 'error') {
|
||
const errorCtx = (context.data as Record<string, any> | undefined)?.error as
|
||
| { error_code?: string; data?: { error_code?: string } }
|
||
| undefined;
|
||
const isIdentityGuardError =
|
||
errorCtx?.error_code === 'identity_verification_required' ||
|
||
errorCtx?.data?.error_code === 'identity_verification_required';
|
||
if (isIdentityGuardError && IdentityGuardInterceptor.consumeDomainNoticeShown()) {
|
||
logger.log('[Toast] IDV 도메인 안내 표출됨 — 중복 가드 토스트 1건 skip');
|
||
return;
|
||
}
|
||
}
|
||
|
||
let resolvedMessage = message;
|
||
|
||
// $t: 다국어 구문 처리
|
||
if (this.translationEngine && this.translationContext && message?.startsWith('$t:')) {
|
||
resolvedMessage = this.translationEngine.resolveTranslations(
|
||
message,
|
||
this.translationContext,
|
||
context.data
|
||
);
|
||
}
|
||
|
||
// 전역 상태에 토스트 메시지 추가 (배열로 스택 관리)
|
||
if (this.globalStateUpdater) {
|
||
const toastId = `toast_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||
const newToast = {
|
||
id: toastId,
|
||
type,
|
||
message: resolvedMessage,
|
||
...(icon && { icon }),
|
||
...(duration && { duration }),
|
||
};
|
||
|
||
// 현재 toasts 배열 가져오기 (G7Core.state.get() 사용)
|
||
const G7Core = (window as any).G7Core;
|
||
const currentState = G7Core?.state?.get();
|
||
const currentToasts = currentState?.toasts || [];
|
||
|
||
// 기존 toasts 배열에 새 토스트 추가
|
||
const newToasts = [...currentToasts, newToast];
|
||
|
||
this.globalStateUpdater({
|
||
toasts: newToasts,
|
||
});
|
||
} else {
|
||
// globalStateUpdater가 없으면 콘솔에 출력
|
||
logger.log(`[Toast ${type}] ${resolvedMessage}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* switch 액션을 처리합니다.
|
||
*
|
||
* 두 가지 방식으로 케이스 키를 결정합니다:
|
||
* 1. params.value가 지정된 경우: 해당 값을 케이스 키로 사용 (데이터 바인딩 지원)
|
||
* 2. params.value가 없는 경우: $args[0] 값을 케이스 키로 사용 (기존 방식)
|
||
*
|
||
* 매칭되는 케이스가 없으면 "default" 케이스를 찾아 실행합니다.
|
||
*
|
||
* @example params.value 방식 (플러그인 환경설정 기반 분기)
|
||
* ```json
|
||
* {
|
||
* "type": "click",
|
||
* "handler": "switch",
|
||
* "params": {
|
||
* "value": "{{_global.plugins['sirsoft-daum_postcode']?.display_mode ?? 'layer'}}"
|
||
* },
|
||
* "cases": {
|
||
* "popup": { "handler": "callExternal", "params": { ... } },
|
||
* "layer": { "handler": "callExternalEmbed", "params": { ... } },
|
||
* "default": { "handler": "toast", "params": { "message": "Unknown mode" } }
|
||
* }
|
||
* }
|
||
* ```
|
||
*
|
||
* @example $args[0] 방식 (DataGrid 행 액션)
|
||
* ```json
|
||
* {
|
||
* "event": "onRowAction",
|
||
* "type": "action",
|
||
* "handler": "switch",
|
||
* "cases": {
|
||
* "view": { "handler": "navigate", "params": { "path": "/users/{{$args[1].id}}" } },
|
||
* "edit": { "handler": "navigate", "params": { "path": "/users/{{$args[1].id}}/edit" } },
|
||
* "delete": { "handler": "openModal", "target": "delete_modal" }
|
||
* }
|
||
* }
|
||
* ```
|
||
*
|
||
* @param action 액션 정의 (cases 포함)
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleSwitch(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
const { cases } = action;
|
||
|
||
if (!cases) {
|
||
logger.warn('switch handler requires cases property');
|
||
return;
|
||
}
|
||
|
||
// 파라미터 바인딩 (params.value에 데이터 바인딩 적용)
|
||
const resolvedParams = this.resolveParams(action.params, context.data);
|
||
|
||
// 케이스 키 결정: params.value가 있으면 사용, 없으면 $args[0] 사용
|
||
let caseKey: any;
|
||
if (resolvedParams?.value !== undefined) {
|
||
caseKey = resolvedParams.value;
|
||
logger.log('switch handler: using params.value as case key:', caseKey);
|
||
} else {
|
||
caseKey = context.data?.$args?.[0];
|
||
logger.log('switch handler: using $args[0] as case key:', caseKey);
|
||
}
|
||
|
||
if (caseKey === undefined || caseKey === null) {
|
||
// default 케이스 확인
|
||
if (cases['default']) {
|
||
logger.log('switch handler: case key is undefined, using default case');
|
||
return await this.executeAction(cases['default'], context);
|
||
}
|
||
logger.warn('switch handler: case key is not defined and no default case');
|
||
return;
|
||
}
|
||
|
||
// 케이스 키를 문자열로 변환
|
||
const caseKeyStr = String(caseKey);
|
||
|
||
// 해당 케이스의 액션 찾기
|
||
let caseAction = cases[caseKeyStr];
|
||
|
||
// 매칭되는 케이스가 없으면 default 케이스 사용
|
||
if (!caseAction && cases['default']) {
|
||
logger.log(`switch handler: no case found for key "${caseKeyStr}", using default case`);
|
||
caseAction = cases['default'];
|
||
}
|
||
|
||
if (!caseAction) {
|
||
logger.warn(`switch handler: no case found for key "${caseKeyStr}" and no default case`);
|
||
return;
|
||
}
|
||
|
||
// 케이스 액션 실행
|
||
return await this.executeAction(caseAction, context);
|
||
}
|
||
|
||
/**
|
||
* conditions 액션을 처리합니다.
|
||
*
|
||
* if/else if/else 체인을 통해 조건부 액션 실행을 지원합니다.
|
||
* 첫 번째 매칭되는 브랜치의 then 액션이 실행됩니다.
|
||
*
|
||
* AND/OR 그룹 조건과 중첩 조건도 지원합니다.
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "type": "click",
|
||
* "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
|
||
*/
|
||
private async handleConditions(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
const { conditions } = action;
|
||
|
||
if (!conditions || !Array.isArray(conditions)) {
|
||
logger.warn('conditions handler requires conditions array');
|
||
return;
|
||
}
|
||
|
||
// 조건 평가를 위한 바인딩 컨텍스트 생성
|
||
const bindingContext = context.data || {};
|
||
|
||
// 조건 브랜치 평가
|
||
const result = evaluateConditionBranches(conditions, bindingContext, this.bindingEngine);
|
||
|
||
if (!result.matched) {
|
||
logger.log('conditions handler: no matching branch found');
|
||
return undefined;
|
||
}
|
||
|
||
const branch = conditions[result.branchIndex];
|
||
logger.log(`conditions handler: matched branch ${result.branchIndex}`);
|
||
|
||
if (!branch.then) {
|
||
logger.warn('conditions handler: matched branch has no "then" action');
|
||
return;
|
||
}
|
||
|
||
// then이 배열이면 sequence처럼 순차 실행
|
||
if (Array.isArray(branch.then)) {
|
||
return await this.handleSequence(
|
||
{ ...action, actions: branch.then as ActionDefinition[] },
|
||
context
|
||
);
|
||
}
|
||
|
||
// 단일 액션 실행
|
||
return await this.executeAction(branch.then as ActionDefinition, context);
|
||
}
|
||
|
||
/**
|
||
* sequence 액션을 처리합니다.
|
||
*
|
||
* 여러 액션을 순차적으로 실행하며, 이전 액션의 결과를 다음 액션에서 사용할 수 있습니다.
|
||
* - $prev: 직전 액션의 결과
|
||
* - $results: 모든 이전 결과 배열 [result0, result1, ...]
|
||
* - $results[0], $results[1] 등으로 특정 결과 접근
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "type": "click",
|
||
* "handler": "sequence",
|
||
* "actions": [
|
||
* { "handler": "setState", "params": { "target": "global", "selectedModule": "{{row}}" } },
|
||
* { "handler": "openModal", "target": "module_install_modal" }
|
||
* ]
|
||
* }
|
||
* ```
|
||
*
|
||
* @example 결과 전달 예시
|
||
* ```json
|
||
* {
|
||
* "handler": "sequence",
|
||
* "actions": [
|
||
* { "handler": "apiCall", "target": "/api/users/1" },
|
||
* { "handler": "setState", "params": { "user": "{{$prev.data}}" } },
|
||
* { "handler": "toast", "params": { "message": "사용자: {{$prev.name}}" } }
|
||
* ]
|
||
* }
|
||
* ```
|
||
*
|
||
* @param action 액션 정의 (actions 배열 포함)
|
||
* @param context 액션 컨텍스트
|
||
* @returns 모든 액션 결과 배열
|
||
*/
|
||
/**
|
||
* startInterval 핸들러.
|
||
*
|
||
* `params.id` (필수) 로 타이머를 등록하고 `params.intervalMs` 주기로 `params.actions` 를 실행합니다.
|
||
* 같은 id 로 재등록 시 기존 타이머를 자동 정리합니다.
|
||
*
|
||
* @since engine-v1.45.0
|
||
*/
|
||
private handleStartInterval(params: Record<string, any>, context: ActionContext): { success: boolean; id?: string } {
|
||
const id = typeof params.id === 'string' ? params.id : '';
|
||
const intervalMs = Number(params.intervalMs ?? 1000);
|
||
const actions = Array.isArray(params.actions) ? params.actions : [];
|
||
|
||
if (id === '' || !actions.length || intervalMs <= 0) {
|
||
logger.warn('startInterval requires non-empty id, positive intervalMs, and actions array');
|
||
return { success: false };
|
||
}
|
||
|
||
// 같은 id 에 이미 등록된 타이머가 있으면 먼저 중단 (idempotent)
|
||
const existing = this.intervals.get(id);
|
||
if (existing) {
|
||
clearInterval(existing);
|
||
this.intervals.delete(id);
|
||
}
|
||
|
||
const timerId = setInterval(() => {
|
||
for (const act of actions) {
|
||
this.executeAction(act as ActionDefinition, context).catch((err) => {
|
||
logger.error(`[startInterval:${id}] tick action failed`, err);
|
||
});
|
||
}
|
||
}, intervalMs);
|
||
|
||
this.intervals.set(id, timerId);
|
||
|
||
return { success: true, id };
|
||
}
|
||
|
||
/**
|
||
* stopInterval 핸들러 — startInterval 로 등록한 타이머를 `params.id` 로 찾아 중단합니다.
|
||
*
|
||
* @since engine-v1.45.0
|
||
*/
|
||
private handleStopInterval(params: Record<string, any>): { success: boolean; id?: string } {
|
||
const id = typeof params.id === 'string' ? params.id : '';
|
||
if (id === '') {
|
||
logger.warn('stopInterval requires id parameter');
|
||
return { success: false };
|
||
}
|
||
|
||
const timerId = this.intervals.get(id);
|
||
if (timerId) {
|
||
clearInterval(timerId);
|
||
this.intervals.delete(id);
|
||
}
|
||
|
||
return { success: true, id };
|
||
}
|
||
|
||
/**
|
||
* 등록된 모든 interval 을 중단합니다. 페이지 전환 등에서 호출해 누수를 방지합니다.
|
||
*
|
||
* @since engine-v1.45.0
|
||
*/
|
||
public stopAllIntervals(): void {
|
||
for (const timerId of this.intervals.values()) {
|
||
clearInterval(timerId);
|
||
}
|
||
this.intervals.clear();
|
||
}
|
||
|
||
private async handleSequence(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any[]> {
|
||
// 레이아웃 JSON 컴파일 시 actions가 params.actions로 배치되는 경우를 처리
|
||
const actions = action.actions || (action.params as any)?.actions;
|
||
|
||
if (!actions || !Array.isArray(actions) || actions.length === 0) {
|
||
logger.warn('sequence handler requires non-empty actions array');
|
||
return [];
|
||
}
|
||
|
||
// DevTools: Sequence 실행 시작
|
||
const devTools = getDevTools();
|
||
const sequenceId = devTools?.isEnabled()
|
||
? devTools.startSequenceExecution?.({
|
||
eventType: action.type,
|
||
}) || ''
|
||
: '';
|
||
|
||
// sequence 내 커스텀 핸들러 상태 동기화용 변수 초기화
|
||
// setLocal()에서 설정한 __g7SequenceLocalSync를 사용하여 후속 액션에 최신 상태 전달
|
||
(window as any).__g7SequenceLocalSync = undefined;
|
||
|
||
const results: any[] = [];
|
||
let prevResult: any = undefined;
|
||
// sequence 내에서 상태 변경을 추적하기 위한 로컬 상태 복사본
|
||
// 이를 통해 setState 후 다음 액션에서 업데이트된 상태를 참조할 수 있음
|
||
let currentState = context.state ? { ...context.state } : {};
|
||
// isolated 상태 추적 (isolatedState 속성이 있는 컴포넌트에서 제공된 경우)
|
||
let isolatedState = context.isolatedContext?.state
|
||
? { ...context.isolatedContext.state }
|
||
: {};
|
||
// computed 상태 추적: _local 변경 시 _computed 재계산하여 다음 액션에서 최신 값 참조
|
||
let currentComputed = context.data?._computed || {};
|
||
|
||
// DevTools: 현재 상태 스냅샷 준비 헬퍼
|
||
const G7Core = (window as any).G7Core;
|
||
const getStateSnapshot = () => ({
|
||
_global: G7Core?.state?.get() || {},
|
||
_local: { ...currentState },
|
||
_isolated: Object.keys(isolatedState).length > 0 ? { ...isolatedState } : undefined,
|
||
});
|
||
|
||
for (let i = 0; i < actions.length; i++) {
|
||
const currentAction = actions[i];
|
||
const actionStartTime = Date.now();
|
||
|
||
// DevTools: 액션 실행 전 상태 캡처
|
||
if (sequenceId && devTools?.isEnabled()) {
|
||
devTools.captureSequenceActionBefore?.(
|
||
sequenceId,
|
||
i,
|
||
currentAction.handler || 'unknown',
|
||
currentAction.params || {},
|
||
getStateSnapshot()
|
||
);
|
||
}
|
||
|
||
// 컨텍스트에 이전 결과 정보와 최신 상태 추가
|
||
const sequenceContext: ActionContext = {
|
||
...context,
|
||
state: currentState, // 최신 상태 반영
|
||
data: {
|
||
...context.data,
|
||
_local: currentState, // sequence 내 setState 후 최신 _local 상태 반영
|
||
_computed: currentComputed, // sequence 내 setState 후 재계산된 _computed 반영
|
||
$computed: currentComputed, // _computed alias
|
||
$prev: prevResult, // 직전 액션 결과
|
||
$results: [...results], // 모든 이전 결과 배열 (복사본)
|
||
_isolated: isolatedState, // 최신 isolated 상태 반영
|
||
},
|
||
// 업데이트된 isolated 상태를 가진 컨텍스트 전달
|
||
isolatedContext: context.isolatedContext ? {
|
||
...context.isolatedContext,
|
||
state: isolatedState,
|
||
} : null,
|
||
};
|
||
|
||
try {
|
||
// 액션 실행
|
||
const result = await this.executeAction(currentAction, sequenceContext);
|
||
|
||
// 결과 저장
|
||
prevResult = result.data;
|
||
results.push(result.data);
|
||
|
||
// setState 액션인 경우 currentState 동기화
|
||
// handleSetState가 반환한 병합된 상태를 사용하여
|
||
// 다음 액션에서 올바른 상태를 참조할 수 있도록 함
|
||
if (currentAction.handler === 'setState' && result.data !== undefined) {
|
||
// handleSetState가 __target: 'global'을 포함한 경우 _global에 병합
|
||
if (result.data.__target === 'global') {
|
||
const { __target, __mergeMode, ...globalPayload } = result.data;
|
||
if (__mergeMode === 'replace') {
|
||
// replace: 기존 _global 완전 교체
|
||
currentState = { ...currentState, _global: globalPayload };
|
||
} else {
|
||
// shallow/deep: 기존 _global에 병합 (deep인 경우 이미 fully merged)
|
||
currentState = {
|
||
...currentState,
|
||
_global: { ...(currentState._global || {}), ...globalPayload }
|
||
};
|
||
}
|
||
logger.log('[handleSequence] global state synchronized (mergeMode=%s):', __mergeMode || 'deep', currentState._global);
|
||
} else if (result.data.__target === 'isolated') {
|
||
// isolated 상태 동기화
|
||
const { __target, __mergeMode, ...isolatedPayload } = result.data;
|
||
if (__mergeMode === 'replace') {
|
||
isolatedState = isolatedPayload;
|
||
} else {
|
||
isolatedState = { ...isolatedState, ...isolatedPayload };
|
||
}
|
||
logger.log('[handleSequence] isolated state synchronized (mergeMode=%s):', __mergeMode || 'deep', isolatedState);
|
||
} else if (result.data.__target === 'local') {
|
||
// local 상태 동기화 - currentState는 이미 _local의 내용물이므로 직접 병합
|
||
// 주의: _local로 감싸면 중첩 발생 ($parent._local._local 문제)
|
||
const { __target, __mergeMode, __setStateId, ...localPayload } = result.data;
|
||
if (__mergeMode === 'replace') {
|
||
// replace: 기존 local 상태 완전 교체
|
||
currentState = localPayload;
|
||
} else {
|
||
// shallow/deep: 기존 local 상태에 병합 (deep인 경우 이미 fully merged)
|
||
currentState = { ...currentState, ...localPayload };
|
||
}
|
||
logger.log('[handleSequence] local state synchronized (mergeMode=%s):', __mergeMode || 'deep', currentState);
|
||
} else {
|
||
// __target이 없는 경우 (하위 호환성)
|
||
currentState = result.data;
|
||
logger.log('[handleSequence] state synchronized (legacy):', currentState);
|
||
}
|
||
|
||
// _computed 재계산: _local 변경 시 _computed도 업데이트해야 다음 액션에서 최신 값 참조 가능
|
||
const computedDefinitions = context.data?._computedDefinitions;
|
||
if (computedDefinitions && Object.keys(computedDefinitions).length > 0) {
|
||
const newComputed: Record<string, any> = {};
|
||
const computedContext = {
|
||
...context.data,
|
||
_local: currentState,
|
||
_computed: newComputed,
|
||
$computed: newComputed,
|
||
_isolated: isolatedState,
|
||
};
|
||
for (const [key, expression] of Object.entries(computedDefinitions)) {
|
||
if (typeof expression === 'string') {
|
||
const trimmed = expression.trim();
|
||
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
|
||
try {
|
||
const innerExpr = trimmed.slice(2, -2).trim();
|
||
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 렌더 경로의
|
||
// computed 재계산(DynamicRenderer)과 같은 규칙이다. 한쪽만 고치면
|
||
// 같은 computed 가 렌더 직후와 액션 직후에 다른 값이 된다.
|
||
// @since engine-v1.54.10
|
||
newComputed[key] = hasPipes(innerExpr)
|
||
? this.bindingEngine.evaluatePipeExpression(
|
||
innerExpr,
|
||
computedContext,
|
||
{ skipCache: true }
|
||
)
|
||
: this.bindingEngine.evaluateExpression(
|
||
innerExpr,
|
||
computedContext,
|
||
{ skipCache: true }
|
||
);
|
||
} catch (e) {
|
||
// 평가 실패 시 기존 값 유지
|
||
newComputed[key] = currentComputed[key];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
currentComputed = newComputed;
|
||
logger.log('[handleSequence] _computed recalculated after setState:', currentComputed);
|
||
}
|
||
}
|
||
|
||
// 비-setState 핸들러 후 상태 동기화
|
||
// 커스텀 핸들러가 G7Core.state.setLocal()을 호출한 경우,
|
||
// __g7SequenceLocalSync에 최신 상태(deepMerge 완료 스냅샷)가 저장되어 있으므로
|
||
// 이를 currentState에 반영하여 후속 액션이 올바른 상태를 참조하도록 함
|
||
//
|
||
// 주의: __g7PendingLocalState는 사용 불가 — setLocal() 내의 setGlobalState() →
|
||
// import().then() → updateTemplateData() → root.render() → useLayoutEffect에서
|
||
// null로 클리어됨 (await 해제 시 마이크로태스크 플러시로 인해)
|
||
if (currentAction.handler !== 'setState') {
|
||
const syncState = (window as any).__g7SequenceLocalSync;
|
||
if (syncState && syncState !== currentState) {
|
||
currentState = syncState;
|
||
// 사용 후 클리어 (같은 sequence 내 다음 비-setState 핸들러에서 stale 값 방지)
|
||
(window as any).__g7SequenceLocalSync = undefined;
|
||
logger.log('[handleSequence] currentState synchronized from __g7SequenceLocalSync after custom handler:', currentAction.handler);
|
||
|
||
// _computed 재계산: 상태가 변경되었으므로 _computed도 업데이트
|
||
const computedDefinitions = context.data?._computedDefinitions;
|
||
if (computedDefinitions && Object.keys(computedDefinitions).length > 0) {
|
||
const newComputed: Record<string, any> = {};
|
||
const computedContext = {
|
||
...context.data,
|
||
_local: currentState,
|
||
_computed: newComputed,
|
||
$computed: newComputed,
|
||
_isolated: isolatedState,
|
||
};
|
||
for (const [key, expression] of Object.entries(computedDefinitions)) {
|
||
if (typeof expression === 'string') {
|
||
const trimmed = expression.trim();
|
||
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
|
||
try {
|
||
const innerExpr = trimmed.slice(2, -2).trim();
|
||
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 위 setState
|
||
// 직후 재계산 블록과 같은 규칙이다. @since engine-v1.54.10
|
||
newComputed[key] = hasPipes(innerExpr)
|
||
? this.bindingEngine.evaluatePipeExpression(
|
||
innerExpr,
|
||
computedContext,
|
||
{ skipCache: true }
|
||
)
|
||
: this.bindingEngine.evaluateExpression(
|
||
innerExpr,
|
||
computedContext,
|
||
{ skipCache: true }
|
||
);
|
||
} catch (e) {
|
||
// 평가 실패 시 기존 값 유지
|
||
newComputed[key] = currentComputed[key];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
currentComputed = newComputed;
|
||
logger.log('[handleSequence] _computed recalculated after custom handler:', currentComputed);
|
||
}
|
||
}
|
||
}
|
||
|
||
// DevTools: 액션 실행 후 상태 캡처
|
||
if (sequenceId && devTools?.isEnabled()) {
|
||
devTools.captureSequenceActionAfter?.(
|
||
sequenceId,
|
||
i,
|
||
getStateSnapshot(),
|
||
Date.now() - actionStartTime,
|
||
result.data
|
||
);
|
||
}
|
||
|
||
} catch (error) {
|
||
// DevTools: 에러 발생 시 상태 캡처
|
||
if (sequenceId && devTools?.isEnabled()) {
|
||
devTools.captureSequenceActionAfter?.(
|
||
sequenceId,
|
||
i,
|
||
getStateSnapshot(),
|
||
Date.now() - actionStartTime,
|
||
undefined,
|
||
error as Error
|
||
);
|
||
devTools.endSequenceExecution?.(sequenceId, error as Error);
|
||
}
|
||
logger.error(`sequence handler: action[${i}] failed:`, error);
|
||
// 에러 발생 시 중단하고 에러를 상위로 전파
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// sequence 종료 시 동기화 변수 클리어
|
||
(window as any).__g7SequenceLocalSync = undefined;
|
||
|
||
// DevTools: Sequence 실행 완료
|
||
if (sequenceId && devTools?.isEnabled()) {
|
||
devTools.endSequenceExecution?.(sequenceId);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* parallel 액션을 처리합니다.
|
||
*
|
||
* 여러 액션을 병렬로 실행하며, 모든 액션이 완료될 때까지 기다립니다.
|
||
* Promise.all을 사용하여 모든 액션을 동시에 실행합니다.
|
||
*
|
||
* 주의: 병렬 실행이므로 액션 간 실행 순서가 보장되지 않습니다.
|
||
* 순서가 중요한 경우 sequence 핸들러를 사용하세요.
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "handler": "parallel",
|
||
* "actions": [
|
||
* { "handler": "toast", "params": { "type": "success", "message": "완료!" } },
|
||
* { "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } },
|
||
* { "handler": "refetchDataSource", "params": { "dataSourceId": "admin_menu" } }
|
||
* ]
|
||
* }
|
||
* ```
|
||
*
|
||
* @example onSuccess에서 사용
|
||
* ```json
|
||
* {
|
||
* "handler": "apiCall",
|
||
* "target": "/api/admin/modules/activate",
|
||
* "onSuccess": [
|
||
* {
|
||
* "handler": "parallel",
|
||
* "actions": [
|
||
* { "handler": "toast", "params": { "type": "success", "message": "활성화 완료" } },
|
||
* { "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } },
|
||
* { "handler": "refetchDataSource", "params": { "dataSourceId": "admin_menu" } }
|
||
* ]
|
||
* }
|
||
* ]
|
||
* }
|
||
* ```
|
||
*
|
||
* @param action 액션 정의 (actions 배열 포함)
|
||
* @param context 액션 컨텍스트
|
||
* @returns 모든 액션 결과 배열 (PromiseSettledResult 형식)
|
||
*/
|
||
private async handleParallel(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<PromiseSettledResult<ActionResult>[]> {
|
||
// 레이아웃 JSON 컴파일 시 actions가 params.actions로 배치되는 경우를 처리
|
||
const actions = action.actions || (action.params as any)?.actions;
|
||
|
||
if (!actions || !Array.isArray(actions) || actions.length === 0) {
|
||
logger.warn('parallel handler requires non-empty actions array');
|
||
return [];
|
||
}
|
||
|
||
logger.log(`parallel handler: executing ${actions.length} actions in parallel`);
|
||
|
||
// isolated 상태 스냅샷 (병렬 실행 시 모든 액션이 동일한 초기 상태 참조하도록)
|
||
const isolatedContextSnapshot = context.isolatedContext ? {
|
||
...context.isolatedContext,
|
||
state: { ...context.isolatedContext.state },
|
||
} : null;
|
||
|
||
// 모든 액션을 병렬로 실행
|
||
const promises = actions.map((currentAction, index) => {
|
||
const actionContext: ActionContext = {
|
||
...context,
|
||
isolatedContext: isolatedContextSnapshot,
|
||
};
|
||
return this.executeAction(currentAction, actionContext)
|
||
.catch(error => {
|
||
// 개별 액션 에러를 로깅하지만 다른 액션은 계속 실행
|
||
logger.error(`parallel handler: action[${index}] failed:`, error);
|
||
throw error;
|
||
});
|
||
});
|
||
|
||
// Promise.allSettled를 사용하여 모든 액션 완료 대기
|
||
// 일부 액션이 실패해도 다른 액션은 계속 실행됨
|
||
const results = await Promise.allSettled(promises);
|
||
|
||
// 실패한 액션이 있는지 확인하고 로깅
|
||
const failedCount = results.filter(r => r.status === 'rejected').length;
|
||
if (failedCount > 0) {
|
||
logger.warn(`parallel handler: ${failedCount}/${actions.length} actions failed`);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 로드된 외부 스크립트 ID를 추적하기 위한 Set
|
||
*/
|
||
private static loadedScripts: Set<string> = new Set();
|
||
|
||
/**
|
||
* 외부 스크립트를 동적으로 로드합니다.
|
||
*
|
||
* 이미 로드된 스크립트는 재로드하지 않고 캐시된 상태를 사용합니다.
|
||
* 스크립트 로드 완료 시 onLoad 액션을 실행합니다.
|
||
*
|
||
* @param params 스크립트 로드 파라미터
|
||
* - src: 스크립트 URL (필수)
|
||
* - id: 스크립트 요소 ID (선택, 중복 로드 방지에 사용)
|
||
* - async: 비동기 로드 여부 (기본값: true)
|
||
* - defer: 지연 로드 여부 (기본값: false)
|
||
* @param action 액션 정의 (onLoad 액션 포함 가능)
|
||
* @param context 액션 컨텍스트
|
||
*
|
||
* @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 } }
|
||
* }
|
||
* ```
|
||
*/
|
||
private async handleLoadScript(
|
||
params: Record<string, any>,
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<boolean> {
|
||
const { src, id, async = true, defer = false } = params;
|
||
|
||
if (!src) {
|
||
throw new ActionError('loadScript handler requires "src" parameter', action);
|
||
}
|
||
|
||
const scriptId = id || `script_${src.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||
|
||
// 이미 로드된 스크립트인지 확인
|
||
if (ActionDispatcher.loadedScripts.has(scriptId)) {
|
||
logger.log(`loadScript: script already loaded, skipping: ${scriptId}`);
|
||
|
||
// onLoad 액션이 있으면 즉시 실행
|
||
if (action.onLoad) {
|
||
await this.executeAction(action.onLoad, context);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// DOM에 이미 스크립트가 존재하는지 확인
|
||
const existingScript = document.getElementById(scriptId);
|
||
if (existingScript) {
|
||
logger.log(`loadScript: script element already exists: ${scriptId}`);
|
||
ActionDispatcher.loadedScripts.add(scriptId);
|
||
|
||
// onLoad 액션이 있으면 즉시 실행
|
||
if (action.onLoad) {
|
||
await this.executeAction(action.onLoad, context);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
logger.log(`loadScript: loading script: ${src}`);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.id = scriptId;
|
||
script.src = src;
|
||
script.async = async;
|
||
script.defer = defer;
|
||
|
||
script.onload = async () => {
|
||
logger.log(`loadScript: script loaded successfully: ${scriptId}`);
|
||
ActionDispatcher.loadedScripts.add(scriptId);
|
||
|
||
// onLoad 액션 실행
|
||
if (action.onLoad) {
|
||
try {
|
||
await this.executeAction(action.onLoad, context);
|
||
} catch (error) {
|
||
logger.error('loadScript: onLoad action failed:', error);
|
||
}
|
||
}
|
||
|
||
resolve(true);
|
||
};
|
||
|
||
script.onerror = (error) => {
|
||
logger.error(`loadScript: failed to load script: ${src}`, error);
|
||
reject(new ActionError(`Failed to load script: ${src}`, action));
|
||
};
|
||
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 외부 스크립트의 생성자나 메서드를 호출합니다.
|
||
*
|
||
* 외부 라이브러리(예: Daum 우편번호 API)의 생성자를 호출하고
|
||
* 콜백 결과를 이벤트로 전달하거나 폼 필드에 직접 매핑합니다.
|
||
*
|
||
* @param params 호출 파라미터
|
||
* - constructor: 호출할 생성자 경로 (예: "daum.Postcode")
|
||
* - args: 생성자에 전달할 인자 객체 (콜백 속성에 true 지정 시 콜백 함수로 변환)
|
||
* - method: 인스턴스 생성 후 호출할 메서드 (예: "open", "embed")
|
||
* - methodArgs: 메서드에 전달할 인자 배열
|
||
* - callbackEvent: 콜백 결과를 전달할 이벤트명 (예: "postcode:complete")
|
||
* - callbackSetState: 콜백 데이터를 폼 필드에 매핑하는 설정 (engine-v1.8.0+)
|
||
* - embedTarget: embed 메서드 사용 시 대상 요소 선택자
|
||
* @param action 액션 정의
|
||
* @param context 액션 컨텍스트
|
||
*
|
||
* @example callbackSetState를 사용한 폼 필드 매핑
|
||
* ```json
|
||
* {
|
||
* "handler": "callExternal",
|
||
* "params": {
|
||
* "constructor": "daum.Postcode",
|
||
* "args": { "oncomplete": true },
|
||
* "callbackSetState": {
|
||
* "basic_info": {
|
||
* "zipcode": "zonecode",
|
||
* "base_address": "roadAddress"
|
||
* }
|
||
* },
|
||
* "method": "open"
|
||
* }
|
||
* }
|
||
* ```
|
||
*
|
||
* @example callbackEvent를 사용한 이벤트 전달
|
||
* ```json
|
||
* {
|
||
* "handler": "callExternal",
|
||
* "params": {
|
||
* "constructor": "daum.Postcode",
|
||
* "args": { "oncomplete": true },
|
||
* "callbackEvent": "postcode:complete",
|
||
* "method": "open"
|
||
* }
|
||
* }
|
||
* ```
|
||
*/
|
||
private async handleCallExternal(
|
||
params: Record<string, any>,
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
// 'constructor'는 예약어이므로 직접 접근
|
||
const constructorPath = params['constructor'] as string | undefined;
|
||
const args = (params.args || {}) as Record<string, any>;
|
||
const method = params.method as string | undefined;
|
||
const methodArgs = (params.methodArgs || []) as any[];
|
||
const callbackEvent = params.callbackEvent as string | undefined;
|
||
const embedTarget = params.embedTarget as string | undefined;
|
||
// 콜백 데이터를 폼 필드에 직접 매핑하는 설정 (engine-v1.8.0+)
|
||
const callbackSetState = params.callbackSetState as Record<string, any> | undefined;
|
||
// 콜백 시 실행할 액션 정의 (engine-v1.9.0+) - extension_point의 props에서 전달받은 액션 실행 가능
|
||
const callbackAction = params.callbackAction as ActionDefinition | ActionDefinition[] | undefined;
|
||
|
||
if (!constructorPath) {
|
||
throw new ActionError('callExternal handler requires "constructor" parameter', action);
|
||
}
|
||
|
||
// 생성자 함수 찾기 (예: "daum.Postcode" → window.daum.Postcode)
|
||
const Constructor = this.getNestedProperty(window as unknown as Record<string, any>, constructorPath);
|
||
|
||
if (!Constructor || typeof Constructor !== 'function') {
|
||
throw new ActionError(
|
||
`Constructor not found or not a function: ${constructorPath}. ` +
|
||
'Make sure the script is loaded first using loadScript handler.',
|
||
action
|
||
);
|
||
}
|
||
|
||
logger.log(`callExternal: calling constructor ${constructorPath}`);
|
||
|
||
// 콜백 함수 생성 (callbackEvent가 지정된 경우)
|
||
const processedArgs = { ...args };
|
||
|
||
// args에서 true로 설정된 콜백 속성을 실제 콜백 함수로 변환
|
||
for (const [key, value] of Object.entries(args)) {
|
||
if (value === true) {
|
||
processedArgs[key] = (data: any) => {
|
||
logger.log(`callExternal: callback triggered for ${key}`, data);
|
||
|
||
// G7Core.componentEvent로 이벤트 발생 (callbackEvent가 지정된 경우)
|
||
if (callbackEvent && typeof window !== 'undefined' && (window as any).G7Core?.componentEvent) {
|
||
(window as any).G7Core.componentEvent.emit(callbackEvent, data);
|
||
}
|
||
|
||
// callbackSetState 처리: 콜백 데이터를 폼 필드에 직접 매핑 (재귀적으로 깊은 중첩 지원 + 깊은 병합)
|
||
// 예: { "form": { "basic_info": { "zipcode": "zonecode" } } }
|
||
// → _local.form.basic_info.zipcode = data.zonecode
|
||
// 기존 상태의 다른 필드는 유지됨 (깊은 병합)
|
||
if (callbackSetState && context.setState) {
|
||
// 재귀적으로 매핑 처리 - 깊은 중첩 구조 지원
|
||
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
||
const result: Record<string, any> = {};
|
||
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
||
if (typeof dataPath === 'string') {
|
||
// 리프 노드: 실제 데이터 매핑
|
||
result[fieldName] = this.getNestedProperty(data, dataPath);
|
||
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
||
// 중첩 객체: 재귀 처리
|
||
result[fieldName] = processMapping(dataPath);
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const mappedValues = processMapping(callbackSetState);
|
||
logger.log(`callExternal: callbackSetState mapping result`, mappedValues);
|
||
|
||
// 깊은 병합 수행: 기존 상태의 다른 필드 유지
|
||
const mergedValues = this.deepMergeWithState(mappedValues, context.state || {});
|
||
logger.log(`callExternal: merged with existing state`, mergedValues);
|
||
context.setState(mergedValues);
|
||
} else if (callbackEvent && context.setState) {
|
||
// callbackSetState가 없으면 기존 방식으로 이벤트 결과 저장
|
||
context.setState({ [`${callbackEvent.replace(/:/g, '_')}_result`]: data });
|
||
}
|
||
|
||
// callbackAction 처리: 콜백 데이터를 $event로 전달하여 액션 실행 (engine-v1.9.0+)
|
||
// extension_point의 props에서 전달받은 onAddressSelect 등의 액션 실행에 활용
|
||
// $event를 context.data에 추가해야 resolveParams에서 접근 가능
|
||
if (callbackAction) {
|
||
const callbackContext = {
|
||
...context,
|
||
data: { ...context.data, $event: data },
|
||
};
|
||
const actions = Array.isArray(callbackAction) ? callbackAction : [callbackAction];
|
||
for (const cbAction of actions) {
|
||
try {
|
||
this.executeAction(cbAction, callbackContext);
|
||
} catch (error) {
|
||
logger.error('callExternal: callbackAction failed:', error);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// 생성자 호출
|
||
const instance = new Constructor(processedArgs);
|
||
|
||
// 메서드 호출 (지정된 경우)
|
||
if (method && typeof instance[method] === 'function') {
|
||
logger.log(`callExternal: calling method ${method}`);
|
||
|
||
// embed 메서드의 경우 대상 요소 찾기
|
||
if (method === 'embed' && embedTarget) {
|
||
const targetElement = document.querySelector(embedTarget);
|
||
if (targetElement) {
|
||
return instance[method](targetElement, ...methodArgs);
|
||
} else {
|
||
throw new ActionError(`Embed target element not found: ${embedTarget}`, action);
|
||
}
|
||
}
|
||
|
||
return instance[method](...methodArgs);
|
||
}
|
||
|
||
return instance;
|
||
}
|
||
|
||
/**
|
||
* 외부 라이브러리를 레이어(오버레이) 모드로 임베드합니다.
|
||
*
|
||
* 페이지 위에 오버레이 레이어를 생성하고 그 안에 외부 라이브러리 UI를 임베드합니다.
|
||
* 주로 Daum 우편번호 API와 같은 외부 서비스를 팝업 대신 레이어로 표시할 때 사용합니다.
|
||
*
|
||
* @param params 호출 파라미터
|
||
* - constructor: 호출할 생성자 경로 (예: "daum.Postcode")
|
||
* - args: 생성자에 전달할 인자 객체 (콜백 속성에 true 지정 시 콜백 함수로 변환)
|
||
* - callbackSetState: 콜백 데이터를 폼 필드에 매핑하는 설정
|
||
* - layerClassName: 레이어 컨테이너에 적용할 추가 CSS 클래스
|
||
* @param action 액션 정의
|
||
* @param context 액션 컨텍스트
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "handler": "callExternalEmbed",
|
||
* "params": {
|
||
* "constructor": "daum.Postcode",
|
||
* "args": { "oncomplete": true },
|
||
* "callbackSetState": {
|
||
* "basic_info": {
|
||
* "zipcode": "zonecode",
|
||
* "base_address": "roadAddress"
|
||
* }
|
||
* }
|
||
* }
|
||
* }
|
||
* ```
|
||
*/
|
||
private async handleCallExternalEmbed(
|
||
params: Record<string, any>,
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
const constructorPath = params['constructor'] as string | undefined;
|
||
const args = (params.args || {}) as Record<string, any>;
|
||
const callbackSetState = params.callbackSetState as Record<string, any> | undefined;
|
||
const callbackEvent = params.callbackEvent as string | undefined;
|
||
const layerClassName = params.layerClassName as string | undefined;
|
||
// 콜백 시 실행할 액션 정의 (engine-v1.9.0+) - extension_point의 props에서 전달받은 액션 실행 가능
|
||
const callbackAction = params.callbackAction as ActionDefinition | ActionDefinition[] | undefined;
|
||
|
||
if (!constructorPath) {
|
||
throw new ActionError('callExternalEmbed handler requires "constructor" parameter', action);
|
||
}
|
||
|
||
// 생성자 함수 찾기
|
||
const Constructor = this.getNestedProperty(window as unknown as Record<string, any>, constructorPath);
|
||
|
||
if (!Constructor || typeof Constructor !== 'function') {
|
||
throw new ActionError(
|
||
`Constructor not found or not a function: ${constructorPath}. ` +
|
||
'Make sure the script is loaded first.',
|
||
action
|
||
);
|
||
}
|
||
|
||
logger.log(`callExternalEmbed: creating layer for ${constructorPath}`);
|
||
|
||
// 레이어 요소들 생성
|
||
const { layer, closeLayer } = this.createEmbedLayer(layerClassName);
|
||
|
||
// 콜백 처리
|
||
const processedArgs = { ...args };
|
||
|
||
for (const [key, value] of Object.entries(args)) {
|
||
if (value === true) {
|
||
processedArgs[key] = (data: any) => {
|
||
logger.log(`callExternalEmbed: callback triggered for ${key}`, data);
|
||
|
||
// 레이어 닫기
|
||
closeLayer();
|
||
|
||
// G7Core.componentEvent로 이벤트 발생
|
||
if (callbackEvent && typeof window !== 'undefined' && (window as any).G7Core?.componentEvent) {
|
||
(window as any).G7Core.componentEvent.emit(callbackEvent, data);
|
||
}
|
||
|
||
// callbackSetState 처리 (재귀적으로 깊은 중첩 지원 + 깊은 병합)
|
||
if (callbackSetState && context.setState) {
|
||
// 재귀적으로 매핑 처리 - 깊은 중첩 구조 지원
|
||
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
||
const result: Record<string, any> = {};
|
||
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
||
if (typeof dataPath === 'string') {
|
||
// 리프 노드: 실제 데이터 매핑
|
||
result[fieldName] = this.getNestedProperty(data, dataPath);
|
||
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
||
// 중첩 객체: 재귀 처리
|
||
result[fieldName] = processMapping(dataPath);
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const mappedValues = processMapping(callbackSetState);
|
||
logger.log(`callExternalEmbed: callbackSetState mapping result`, mappedValues);
|
||
|
||
// 깊은 병합 수행: 기존 상태의 다른 필드 유지
|
||
const mergedValues = this.deepMergeWithState(mappedValues, context.state || {});
|
||
logger.log(`callExternalEmbed: merged with existing state`, mergedValues);
|
||
context.setState(mergedValues);
|
||
}
|
||
|
||
// callbackAction 처리: 콜백 데이터를 $event로 전달하여 액션 실행 (engine-v1.9.0+)
|
||
// extension_point의 props에서 전달받은 onAddressSelect 등의 액션 실행에 활용
|
||
// $event를 context.data에 추가해야 resolveParams에서 접근 가능
|
||
if (callbackAction) {
|
||
const callbackContext = {
|
||
...context,
|
||
data: { ...context.data, $event: data },
|
||
};
|
||
const actions = Array.isArray(callbackAction) ? callbackAction : [callbackAction];
|
||
for (const cbAction of actions) {
|
||
try {
|
||
this.executeAction(cbAction, callbackContext);
|
||
} catch (error) {
|
||
logger.error('callExternalEmbed: callbackAction failed:', error);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// 생성자 호출 및 레이어에 임베드
|
||
const instance = new Constructor(processedArgs);
|
||
|
||
if (typeof instance.embed === 'function') {
|
||
instance.embed(layer);
|
||
logger.log(`callExternalEmbed: embedded in layer`);
|
||
} else {
|
||
closeLayer();
|
||
throw new ActionError(`Constructor ${constructorPath} does not have embed method`, action);
|
||
}
|
||
|
||
return instance;
|
||
}
|
||
|
||
/**
|
||
* IME(한글/일본어/중국어) 조합 중인 keydown 인지 판정합니다.
|
||
*
|
||
* 조합 중 Enter 등은 액션 키 필터 매칭에서 제외해야 글자누락/이중제출이 방지됩니다.
|
||
* isComposing 미지원/false 환경을 위해 legacy keyCode 229 도 함께 검사합니다.
|
||
*
|
||
* @since engine-v1.50.0
|
||
* @param e 판정할 키보드 이벤트
|
||
* @returns 조합 중이면 true
|
||
*/
|
||
private isImeComposing(e: { isComposing?: boolean; keyCode?: number }): boolean {
|
||
return e?.isComposing === true || e?.keyCode === 229;
|
||
}
|
||
|
||
/**
|
||
* 임베드용 레이어(오버레이) 요소를 생성합니다.
|
||
*
|
||
* @param additionalClassName 추가 CSS 클래스
|
||
* @returns overlay 요소, layer 요소, closeLayer 함수
|
||
*/
|
||
private createEmbedLayer(additionalClassName?: string): {
|
||
overlay: HTMLElement;
|
||
layer: HTMLElement;
|
||
closeLayer: () => void;
|
||
} {
|
||
// 오버레이 배경
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'g7-embed-overlay';
|
||
overlay.style.cssText = `
|
||
position: fixed;
|
||
inset: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
`;
|
||
|
||
// 레이어 컨테이너
|
||
const layer = document.createElement('div');
|
||
layer.id = 'g7-embed-layer';
|
||
layer.style.cssText = `
|
||
position: relative;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||
max-width: 90vw;
|
||
max-height: 90vh;
|
||
overflow: hidden;
|
||
`;
|
||
|
||
if (additionalClassName) {
|
||
layer.className = additionalClassName;
|
||
}
|
||
|
||
// 닫기 버튼
|
||
const closeButton = document.createElement('button');
|
||
closeButton.type = 'button';
|
||
closeButton.innerHTML = '×';
|
||
closeButton.style.cssText = `
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 50%;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
z-index: 10;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666;
|
||
transition: background-color 0.2s;
|
||
`;
|
||
closeButton.onmouseenter = () => {
|
||
closeButton.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
|
||
};
|
||
closeButton.onmouseleave = () => {
|
||
closeButton.style.backgroundColor = 'rgba(0, 0, 0, 0.1)';
|
||
};
|
||
|
||
// 레이어 닫기 함수
|
||
const closeLayer = () => {
|
||
if (overlay.parentNode) {
|
||
overlay.parentNode.removeChild(overlay);
|
||
}
|
||
};
|
||
|
||
closeButton.onclick = closeLayer;
|
||
|
||
// ESC 키로 닫기
|
||
const handleKeydown = (e: KeyboardEvent) => {
|
||
// IME 조합 중 ESC keydown 제외 — 공개#54 일관성
|
||
if (this.isImeComposing(e)) return;
|
||
if (e.key === 'Escape') {
|
||
closeLayer();
|
||
document.removeEventListener('keydown', handleKeydown);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handleKeydown);
|
||
|
||
// 오버레이 클릭으로 닫기 (레이어 외부 클릭 시)
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) {
|
||
closeLayer();
|
||
}
|
||
};
|
||
|
||
// DOM에 추가
|
||
layer.appendChild(closeButton);
|
||
overlay.appendChild(layer);
|
||
document.body.appendChild(overlay);
|
||
|
||
return { overlay, layer, closeLayer };
|
||
}
|
||
|
||
/**
|
||
* 중첩된 객체 속성을 경로로 접근합니다.
|
||
*
|
||
* @param obj 대상 객체 (예: window)
|
||
* @param path 점으로 구분된 경로 (예: "daum.Postcode")
|
||
* @returns 해당 경로의 값 또는 undefined
|
||
*/
|
||
private getNestedProperty(obj: Record<string, any>, path: string): any {
|
||
return path.split('.').reduce((current: any, key: string) => {
|
||
return current && current[key] !== undefined ? current[key] : undefined;
|
||
}, obj);
|
||
}
|
||
|
||
/**
|
||
* 로컬스토리지에 데이터를 저장합니다.
|
||
*
|
||
* @param params 파라미터 (key: 저장할 키, value: 저장할 값)
|
||
* @param context 액션 컨텍스트
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "handler": "saveToLocalStorage",
|
||
* "params": {
|
||
* "key": "product_filter_config",
|
||
* "value": "{{_local.visibleFilters}}"
|
||
* }
|
||
* }
|
||
* ```
|
||
*/
|
||
private async handleSaveToLocalStorage(
|
||
params: Record<string, any>,
|
||
context: ActionContext
|
||
): Promise<boolean> {
|
||
const { key, value } = params;
|
||
|
||
if (!key) {
|
||
logger.error('saveToLocalStorage: key parameter is required');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const serializedValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||
localStorage.setItem(key, serializedValue);
|
||
logger.log(`saveToLocalStorage: saved ${key}`, value);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('saveToLocalStorage: failed to save', { key, error });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 로컬스토리지에서 데이터를 불러와 상태에 설정합니다.
|
||
*
|
||
* @param params 파라미터 (key: 불러올 키, target: 상태 타겟, stateKey: 상태 키, defaultValue: 기본값)
|
||
* @param context 액션 컨텍스트
|
||
*
|
||
* @example
|
||
* ```json
|
||
* {
|
||
* "handler": "loadFromLocalStorage",
|
||
* "params": {
|
||
* "key": "product_filter_config",
|
||
* "target": "_local",
|
||
* "stateKey": "visibleFilters",
|
||
* "defaultValue": ["searchField", "searchKeyword", "category", "date", "salesStatus", "displayStatus"]
|
||
* }
|
||
* }
|
||
* ```
|
||
*/
|
||
private async handleLoadFromLocalStorage(
|
||
params: Record<string, any>,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
const { key, target, stateKey, defaultValue } = params;
|
||
|
||
if (!key) {
|
||
logger.error('loadFromLocalStorage: key parameter is required');
|
||
return defaultValue;
|
||
}
|
||
|
||
try {
|
||
const storedValue = localStorage.getItem(key);
|
||
|
||
if (storedValue === null) {
|
||
logger.log(`loadFromLocalStorage: no value found for ${key}, using default`);
|
||
|
||
// 기본값이 있고 상태에 설정해야 하는 경우
|
||
if (stateKey && context.setState) {
|
||
context.setState({ [stateKey]: defaultValue });
|
||
}
|
||
|
||
return defaultValue;
|
||
}
|
||
|
||
// JSON 파싱 시도
|
||
let parsedValue: any;
|
||
try {
|
||
parsedValue = JSON.parse(storedValue);
|
||
} catch {
|
||
// JSON이 아니면 문자열 그대로 사용
|
||
parsedValue = storedValue;
|
||
}
|
||
|
||
logger.log(`loadFromLocalStorage: loaded ${key}`, parsedValue);
|
||
|
||
// 상태에 설정
|
||
if (stateKey && context.setState) {
|
||
context.setState({ [stateKey]: parsedValue });
|
||
}
|
||
|
||
return parsedValue;
|
||
} catch (error) {
|
||
logger.error('loadFromLocalStorage: failed to load', { key, error });
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 커스텀 액션을 처리합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param context 액션 컨텍스트
|
||
*/
|
||
private async handleCustomAction(
|
||
action: ActionDefinition,
|
||
context: ActionContext
|
||
): Promise<any> {
|
||
const handler = this.customHandlers.get(action.handler);
|
||
|
||
if (!handler) {
|
||
// 프리뷰 모드에서는 미등록 핸들러를 throw 하지 않고 silent skip.
|
||
// 편집기 캔버스에는 ckeditor5 등 플러그인 핸들러가 등록되지 않은 격리
|
||
// ActionDispatcher 인스턴스가 사용되므로, lifecycle onMount 단계에서
|
||
// 미등록 핸들러가 호출되면 errorHandling.onError → setState(500) →
|
||
// ErrorBoundary 트리거 → 캔버스 unmount 로 이어진다. 프리뷰 모드에서는
|
||
// 외부 효과를 건너뛰는 것이 의도된 동작이므로 warn 후 undefined 반환.
|
||
// @since engine-v1.50.0
|
||
if (this.previewMode) {
|
||
logger.warn(
|
||
`[Preview] Unknown action handler "${action.handler}" — skipped (preview mode)`,
|
||
);
|
||
return undefined;
|
||
}
|
||
|
||
// throw 는 유지한다 (호출부의 기존 try/catch 흐름 보존). 다만 미등록 핸들러임을
|
||
// 표시해 표시 계층이 raw 내부 식별자를 사용자에게 노출하지 않도록 한다.
|
||
// @since engine-v1.53.0
|
||
const unknownHandlerError = new ActionError(
|
||
`Unknown action handler: ${action.handler}`,
|
||
action
|
||
);
|
||
unknownHandlerError.unknownHandler = true;
|
||
throw unknownHandlerError;
|
||
}
|
||
|
||
return await handler(action, context);
|
||
}
|
||
|
||
/**
|
||
* 파라미터를 해석합니다.
|
||
*
|
||
* @param params 원본 파라미터
|
||
* @param dataContext 데이터 컨텍스트
|
||
*/
|
||
private resolveParams(
|
||
params: Record<string, any> | undefined,
|
||
dataContext?: any
|
||
): Record<string, any> {
|
||
if (!params) return {};
|
||
|
||
const resolved: Record<string, any> = {};
|
||
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (key.includes('{{')) {
|
||
logger.warn(
|
||
`[resolveParams] setState params의 키에 표현식이 포함되어 있습니다: "${key}". ` +
|
||
`키는 해석되지 않습니다. 배열 항목 수정은 .map()/.filter() 패턴을 사용하세요.`
|
||
);
|
||
}
|
||
if (typeof value === 'string' && value.includes('{{')) {
|
||
// {{}} 표현식인 경우 evaluateExpression 사용 (타입 보존)
|
||
resolved[key] = this.evaluateExpression(value, dataContext);
|
||
} else if (typeof value === 'string') {
|
||
// 일반 문자열은 그대로 반환
|
||
resolved[key] = value;
|
||
} else if (Array.isArray(value)) {
|
||
resolved[key] = value.map((item) =>
|
||
typeof item === 'string' && item.includes('{{')
|
||
? this.evaluateExpression(item, dataContext)
|
||
: item
|
||
);
|
||
} else if (
|
||
typeof value === 'object' &&
|
||
value !== null &&
|
||
!Array.isArray(value) &&
|
||
Object.getPrototypeOf(value) !== Object.prototype &&
|
||
Object.getPrototypeOf(value) !== null
|
||
) {
|
||
// File, Blob, Date 등 non-plain 객체는 재귀 해석하지 않고 직접 전달
|
||
resolved[key] = value;
|
||
} else if (typeof value === 'object' && value !== null) {
|
||
resolved[key] = this.resolveParams(value, dataContext);
|
||
} else {
|
||
resolved[key] = value;
|
||
}
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* 문자열 값을 해석합니다 ({{}} 바인딩 처리).
|
||
*
|
||
* @param value 원본 값
|
||
* @param dataContext 데이터 컨텍스트
|
||
*/
|
||
private resolveValue(value: string, dataContext?: any): string {
|
||
if (!dataContext) return value;
|
||
|
||
// 액션 실행 시점에서는 항상 캐시를 사용하지 않음
|
||
// - 이벤트마다 컨텍스트가 다름 (iteration 변수: item, index 등)
|
||
// - $args, $event는 매번 새로운 값
|
||
// - 트러블슈팅 가이드: "액션 실행 시점 (ActionDispatcher) | X 사용 안 함"
|
||
return this.bindingEngine.resolveBindings(value, dataContext, { skipCache: true });
|
||
}
|
||
|
||
/**
|
||
* 객체 내의 모든 {{}} 표현식을 평가합니다.
|
||
*
|
||
* "..." 키는 JavaScript spread 연산자처럼 동작합니다.
|
||
* 예: { "...": "{{_local.form}}", "name": "new" } → { ...oldForm, name: "new" }
|
||
*
|
||
* @param obj 평가할 객체
|
||
* @param dataContext 데이터 컨텍스트
|
||
*/
|
||
private evaluateExpressions(obj: Record<string, any>, dataContext?: any): Record<string, any> {
|
||
let result: Record<string, any> = {};
|
||
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (key === '...') {
|
||
// "..." 키는 spread 연산자로 처리
|
||
// 값이 {{}} 표현식이면 평가하고, 객체면 병합
|
||
let spreadValue = value;
|
||
if (typeof value === 'string' && value.includes('{{')) {
|
||
spreadValue = this.evaluateExpression(value, dataContext);
|
||
}
|
||
// spreadValue가 객체인 경우에만 병합
|
||
if (spreadValue && typeof spreadValue === 'object' && !Array.isArray(spreadValue)) {
|
||
result = { ...result, ...spreadValue };
|
||
}
|
||
// spreadValue가 null, undefined, 또는 객체가 아닌 경우 무시
|
||
} else if (typeof value === 'string' && value.includes('{{')) {
|
||
// {{}} 표현식 평가
|
||
result[key] = this.evaluateExpression(value, dataContext);
|
||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||
// 중첩 객체 재귀 처리
|
||
result[key] = this.evaluateExpressions(value, dataContext);
|
||
} else {
|
||
result[key] = value;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 이미 resolve된 payload에서 남아있는 {{}} 표현식만 평가합니다.
|
||
* 배열, 객체 등 이미 평가된 값은 그대로 유지합니다.
|
||
*/
|
||
private evaluateExpressionsIfNeeded(obj: Record<string, any>, dataContext?: any): Record<string, any> {
|
||
const result: Record<string, any> = {};
|
||
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
if (typeof value === 'string' && value.includes('{{')) {
|
||
// 문자열이고 {{}}를 포함하면 평가
|
||
result[key] = this.evaluateExpression(value, dataContext);
|
||
} else {
|
||
// 그 외는 그대로 유지 (배열, 객체, 기본값 등)
|
||
result[key] = value;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* {{}} 표현식을 평가합니다.
|
||
*
|
||
* DataBindingEngine.evaluateExpression을 사용하여 $t: 토큰 등을 올바르게 처리합니다.
|
||
* 평가 결과가 $t:로 시작하는 문자열이면 번역을 수행합니다.
|
||
*
|
||
* @param expr 표현식 문자열
|
||
* @param dataContext 데이터 컨텍스트
|
||
*/
|
||
private evaluateExpression(expr: string, dataContext?: any): any {
|
||
if (!dataContext) return expr;
|
||
|
||
// 단일 `{{...}}` 판정은 BindingShape 정본을 쓴다. 종전에는 greedy 정규식
|
||
// (`^\{\{(.+)\}\}$`)이 `{{A}}/text/{{B}}` 까지 잡아, 캡처 안에 `{{`/`}}` 가 있는지
|
||
// 확인하는 가드를 덧대어 걸러냈다. 정본은 따옴표·중괄호 균형을 추적하므로
|
||
// 그 가드 없이도 같은 판정을 하고, 식 안의 객체 리터럴(`?? {}`)도 지킨다.
|
||
// @since engine-v1.55.0
|
||
const singleExpression = extractSingleBinding(expr);
|
||
|
||
if (singleExpression === null) {
|
||
// {{}} 패턴이 아니거나 복합 표현식({{A}}/text/{{B}})인 경우
|
||
// resolveBindings가 각 {{...}} 블록을 개별 처리
|
||
// 복합 표현식에서도 최신 _global/_computed 상태 주입 (Stale Closure 방지)
|
||
let effectiveContext = dataContext;
|
||
if (expr.includes('_global')) {
|
||
const G7Core = (window as any).G7Core;
|
||
const latestGlobal = G7Core?.state?.get()?._global;
|
||
if (latestGlobal) {
|
||
effectiveContext = { ...dataContext, _global: latestGlobal };
|
||
}
|
||
}
|
||
if (expr.includes('_computed') || expr.includes('$computed')) {
|
||
const actionContext = (window as any).__g7ActionContext;
|
||
const latestComputed = actionContext?.computedRef?.current;
|
||
if (latestComputed && Object.keys(latestComputed).length > 0) {
|
||
effectiveContext = {
|
||
...effectiveContext,
|
||
_computed: latestComputed,
|
||
$computed: latestComputed,
|
||
};
|
||
}
|
||
}
|
||
return this.bindingEngine.resolveBindings(expr, effectiveContext, { skipCache: true });
|
||
}
|
||
|
||
let expression = singleExpression;
|
||
|
||
// $args.숫자 형태를 $args[숫자]로 변환 (예: $args.1 → $args[1])
|
||
expression = expression.replace(/\$args\.(\d+)/g, '$args[$1]');
|
||
|
||
// _global 참조 시 최신 전역 상태 사용 (Stale Closure 문제 해결)
|
||
// 액션 핸들러가 렌더링 시점의 dataContext를 캡처하고 있어도,
|
||
// _global 값은 항상 최신 상태를 참조하도록 함
|
||
let effectiveDataContext = dataContext;
|
||
if (expression.includes('_global')) {
|
||
const G7Core = (window as any).G7Core;
|
||
const latestGlobal = G7Core?.state?.get()?._global;
|
||
if (latestGlobal) {
|
||
effectiveDataContext = {
|
||
...dataContext,
|
||
_global: latestGlobal,
|
||
};
|
||
}
|
||
}
|
||
|
||
// _computed 참조 시 최신 computed 상태 사용 (Stale Closure 문제 해결)
|
||
// _computed는 _local 기반으로 계산되므로, _local 변경 후 캐싱된 컨텍스트의 _computed가 이전 값을 참조할 수 있음
|
||
// computedRef.current는 매 렌더링마다 DynamicRenderer에서 최신 값으로 업데이트됨
|
||
if (expression.includes('_computed') || expression.includes('$computed')) {
|
||
const actionContext = (window as any).__g7ActionContext;
|
||
const latestComputed = actionContext?.computedRef?.current;
|
||
if (latestComputed && Object.keys(latestComputed).length > 0) {
|
||
effectiveDataContext = {
|
||
...effectiveDataContext,
|
||
_computed: latestComputed,
|
||
$computed: latestComputed,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 디버그 로그 (init_actions 바인딩 문제 진단용)
|
||
if (expression.includes('_global.modules')) {
|
||
logger.log('[evaluateExpression] expression:', expression);
|
||
logger.log('[evaluateExpression] dataContext._global:', effectiveDataContext._global);
|
||
logger.log('[evaluateExpression] dataContext._global?.modules:', effectiveDataContext._global?.modules);
|
||
}
|
||
|
||
try {
|
||
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
|
||
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
|
||
// 원본 `{{...}}` 문자열이 그대로 서버로 전송되고, 인자 없는 파이프는
|
||
// 날짜 문자열이 `0` 이 되는 식의 조용한 오답이 된다. @since engine-v1.54.10
|
||
// DataBindingEngine.evaluateExpression을 사용하여 $t: 토큰 등을 올바르게 처리
|
||
const result = hasPipes(expression)
|
||
? this.bindingEngine.evaluatePipeExpression(expression, effectiveDataContext, { skipCache: true })
|
||
: this.bindingEngine.evaluateExpression(expression, effectiveDataContext);
|
||
|
||
// 디버그 로그 (init_actions 바인딩 문제 진단용)
|
||
if (expression.includes('_global.modules')) {
|
||
logger.log('[evaluateExpression] result:', result);
|
||
}
|
||
|
||
// 결과가 $t:로 시작하는 문자열이면 번역 수행
|
||
if (typeof result === 'string' && result.startsWith('$t:') && this.translationEngine && this.translationContext) {
|
||
return this.translationEngine.resolveTranslations(
|
||
result,
|
||
this.translationContext,
|
||
dataContext
|
||
);
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('Expression evaluation failed:', expr, error);
|
||
return expr;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 기본 컨텍스트를 설정합니다.
|
||
*
|
||
* 이 메서드는 초기화 이후에 navigate 함수 등을 주입할 때 사용됩니다.
|
||
*
|
||
* @param context 추가할 컨텍스트 (기존 컨텍스트와 병합됨)
|
||
*/
|
||
setDefaultContext(context: Partial<ActionContext>): void {
|
||
this.defaultContext = {
|
||
...this.defaultContext,
|
||
...context,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 전역 상태 업데이트 함수를 설정합니다.
|
||
*
|
||
* @param updater 전역 상태 업데이트 함수
|
||
*/
|
||
setGlobalStateUpdater(updater: (updates: any, options?: { render?: boolean }) => void): void {
|
||
this.globalStateUpdater = updater;
|
||
}
|
||
|
||
/**
|
||
* 전역 상태 업데이트 함수를 반환합니다.
|
||
*
|
||
* @returns 전역 상태 업데이트 함수 또는 undefined
|
||
*/
|
||
getGlobalStateUpdater(): ((updates: any) => void) | undefined {
|
||
return this.globalStateUpdater;
|
||
}
|
||
|
||
/**
|
||
* 액션을 직접 실행합니다. (템플릿 컴포넌트에서 useActions 훅을 통해 사용)
|
||
*
|
||
* 레이아웃 JSON의 액션 정의와 동일한 형태로 액션을 실행할 수 있습니다.
|
||
* onSuccess, onError 체이닝도 지원됩니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param context 액션 컨텍스트 (선택적, 기본 컨텍스트와 병합됨)
|
||
* @returns 액션 실행 결과
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* const result = await dispatcher.dispatchAction({
|
||
* handler: 'navigate',
|
||
* params: { path: '/admin/users/1/edit' }
|
||
* });
|
||
*
|
||
* const result = await dispatcher.dispatchAction({
|
||
* handler: 'apiCall',
|
||
* target: '/api/admin/users/1',
|
||
* params: { method: 'DELETE' },
|
||
* onSuccess: [
|
||
* { handler: 'toast', params: { type: 'success', message: '삭제 완료' } }
|
||
* ]
|
||
* });
|
||
* ```
|
||
*/
|
||
async dispatchAction(
|
||
action: ActionDefinition,
|
||
context?: Partial<ActionContext>
|
||
): Promise<ActionResult> {
|
||
// 컨텍스트 병합
|
||
const mergedContext: ActionContext = {
|
||
...this.defaultContext,
|
||
...context,
|
||
data: {
|
||
...this.defaultContext.data,
|
||
...context?.data,
|
||
},
|
||
};
|
||
|
||
// 확인 메시지 표시
|
||
if (action.confirm) {
|
||
let message = this.resolveValue(action.confirm, mergedContext.data);
|
||
|
||
// $t: 다국어 구문 처리
|
||
if (this.translationEngine && this.translationContext && message.startsWith('$t:')) {
|
||
message = this.translationEngine.resolveTranslations(
|
||
message,
|
||
this.translationContext,
|
||
mergedContext.data
|
||
);
|
||
}
|
||
|
||
if (!confirm(message)) {
|
||
return { success: false };
|
||
}
|
||
}
|
||
|
||
try {
|
||
const result = await this.executeAction(action, mergedContext);
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('dispatchAction failed:', error);
|
||
|
||
// onError 액션 실행
|
||
if (action.onError) {
|
||
const errorData = error instanceof ActionError && error.originalError
|
||
? { message: error.originalError.message, response: (error.originalError as any).response }
|
||
: { message: error instanceof Error ? error.message : String(error) };
|
||
|
||
const errorContext: ActionContext = {
|
||
...mergedContext,
|
||
data: {
|
||
...mergedContext.data,
|
||
error: errorData,
|
||
},
|
||
};
|
||
|
||
const errorActions = Array.isArray(action.onError) ? action.onError : [action.onError];
|
||
for (const errorAction of errorActions) {
|
||
await this.executeAction(errorAction, errorContext);
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error : new Error(String(error)),
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 커스텀 액션 핸들러를 등록합니다.
|
||
*
|
||
* @param name 핸들러 이름
|
||
* @param handler 핸들러 함수
|
||
* @param options 핸들러 옵션 (DevTools용)
|
||
*/
|
||
registerHandler(
|
||
name: string,
|
||
handler: ActionHandler,
|
||
options?: {
|
||
category?: 'built-in' | 'custom' | 'module' | 'plugin';
|
||
description?: string;
|
||
source?: string;
|
||
}
|
||
): void {
|
||
this.customHandlers.set(name, handler);
|
||
|
||
// DevTools에 핸들러 등록 추적
|
||
const devTools = getDevTools();
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackHandlerRegistration(
|
||
name,
|
||
options?.category ?? 'custom',
|
||
options?.description,
|
||
options?.source
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 커스텀 액션 핸들러를 제거합니다.
|
||
*
|
||
* @param name 핸들러 이름
|
||
*/
|
||
unregisterHandler(name: string): void {
|
||
this.customHandlers.delete(name);
|
||
|
||
// DevTools에 핸들러 해제 추적
|
||
const devTools = getDevTools();
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackHandlerUnregistration(name);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 등록된 모든 핸들러를 조회합니다.
|
||
*/
|
||
getRegisteredHandlers(): string[] {
|
||
return Array.from(this.customHandlers.keys());
|
||
}
|
||
|
||
/**
|
||
* 특정 이름의 커스텀 핸들러 함수를 반환합니다 (격리 dispatcher 복제용).
|
||
*
|
||
* 편집기 캔버스의 격리 dispatcher 가 호스트의 활성 플러그인 핸들러(예:
|
||
* `sirsoft-ckeditor5.initEditor`)를 복제 등록해 위지윅 에디터 등 플러그인
|
||
* 제공 UI 가 캔버스에 정상 마운트되도록 한다. 핸들러 함수 자체는 호스트와
|
||
* 동일하지만 격리 dispatcher 의 컨텍스트(격리 store/dispatcher)에서 실행되므로
|
||
* 호스트 globalState 누수가 없다.
|
||
*
|
||
* @param name 핸들러 이름
|
||
* @returns 등록된 핸들러 함수 또는 undefined (미등록)
|
||
* @since engine-v1.50.0
|
||
*/
|
||
getHandler(name: string): ActionHandler | undefined {
|
||
return this.customHandlers.get(name);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Debounce 유틸리티 메서드
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Debounce 설정을 정규화합니다.
|
||
*
|
||
* 숫자만 전달된 경우 기본 설정을 적용합니다.
|
||
*
|
||
* @param config debounce 설정 (숫자 또는 객체)
|
||
* @returns 정규화된 debounce 설정
|
||
*/
|
||
private normalizeDebounceConfig(
|
||
config: number | { delay: number; leading?: boolean; trailing?: boolean }
|
||
): { delay: number; leading: boolean; trailing: boolean } {
|
||
if (typeof config === 'number') {
|
||
return { delay: config, leading: false, trailing: true };
|
||
}
|
||
return {
|
||
delay: config.delay,
|
||
leading: config.leading ?? false,
|
||
trailing: config.trailing ?? true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 이벤트에서 필요한 데이터를 추출합니다.
|
||
*
|
||
* 이벤트 객체는 비동기 콜백에서 접근할 수 없으므로
|
||
* 필요한 데이터를 미리 추출합니다.
|
||
*
|
||
* @param event DOM 이벤트
|
||
* @returns 추출된 이벤트 데이터
|
||
*/
|
||
private extractEventData(event: Event): Record<string, any> {
|
||
const target = event.target as HTMLInputElement | HTMLSelectElement | null;
|
||
|
||
// 커스텀 이벤트 감지 (예: MultilingualInput에서 emit하는 { target: { name, value } })
|
||
// 커스텀 이벤트는 tagName이 없고, value가 객체일 수 있음
|
||
const isCustomEvent = target && !('tagName' in target);
|
||
|
||
if (isCustomEvent) {
|
||
// 커스텀 이벤트: target 속성을 그대로 복사
|
||
const result: Record<string, any> = {
|
||
type: (event as any).type ?? 'custom',
|
||
target: {
|
||
value: (target as any).value,
|
||
name: (target as any).name ?? '',
|
||
},
|
||
};
|
||
|
||
// _changedKeys 메타데이터 보존 (디바운스 병합에 사용)
|
||
if ((event as any)._changedKeys) {
|
||
result._changedKeys = (event as any)._changedKeys;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// DOM 이벤트: 기존 로직
|
||
const targetData: Record<string, any> = target
|
||
? {
|
||
value: target.value,
|
||
name: target.name ?? '',
|
||
checked: (target as HTMLInputElement).checked ?? false,
|
||
type: target.type ?? '',
|
||
tagName: target.tagName,
|
||
}
|
||
: {};
|
||
|
||
// scroll 이벤트인 경우 스크롤 관련 속성 추가
|
||
if (event.type === 'scroll' && target) {
|
||
const element = target as HTMLElement;
|
||
targetData.scrollHeight = element.scrollHeight ?? 0;
|
||
targetData.scrollTop = element.scrollTop ?? 0;
|
||
targetData.clientHeight = element.clientHeight ?? 0;
|
||
targetData.scrollLeft = element.scrollLeft ?? 0;
|
||
targetData.clientWidth = element.clientWidth ?? 0;
|
||
targetData.scrollWidth = element.scrollWidth ?? 0;
|
||
}
|
||
|
||
return {
|
||
type: event.type,
|
||
target: target ? targetData : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Debounced 액션을 실행합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param extractedEvent 추출된 이벤트 데이터
|
||
* @param dataContext 데이터 컨텍스트
|
||
* @param componentContext 컴포넌트 컨텍스트
|
||
* @param args 원본 이벤트 인자
|
||
*/
|
||
private executeDebouncedAction(
|
||
action: ActionDefinition,
|
||
extractedEvent: Record<string, any>,
|
||
dataContext: any,
|
||
componentContext?: { state?: any; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null },
|
||
args?: any[]
|
||
): void {
|
||
// $event를 추출된 데이터로 대체한 컨텍스트 생성
|
||
const contextWithEvent = {
|
||
...dataContext,
|
||
$event: extractedEvent,
|
||
$args: args,
|
||
};
|
||
|
||
// 가짜 이벤트 객체 생성 (extractedEvent 기반)
|
||
const syntheticEvent = {
|
||
type: extractedEvent.type,
|
||
target: extractedEvent.target,
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
} as Event;
|
||
|
||
this.createHandler(action, contextWithEvent, componentContext)(syntheticEvent);
|
||
}
|
||
|
||
/**
|
||
* Debounce가 적용된 핸들러를 실행합니다.
|
||
*
|
||
* @param action 액션 정의
|
||
* @param event DOM 이벤트
|
||
* @param dataContext 데이터 컨텍스트
|
||
* @param componentContext 컴포넌트 컨텍스트
|
||
* @param debounceKey debounce 고유 키
|
||
* @param args 원본 이벤트 인자
|
||
*/
|
||
private handleDebouncedAction(
|
||
action: ActionDefinition,
|
||
event: Event,
|
||
dataContext: any,
|
||
componentContext?: { state?: any; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null },
|
||
debounceKey?: string,
|
||
args?: any[]
|
||
): void {
|
||
const config = this.normalizeDebounceConfig(action.debounce!);
|
||
const key = debounceKey || `default-${action.handler}-${Date.now()}`;
|
||
|
||
// 이벤트에서 필요한 데이터 즉시 추출 (비동기 접근 불가)
|
||
const extractedEvent = this.extractEventData(event);
|
||
|
||
// _changedKeys 프로토콜: 객체 값의 변경 키만 누적하여 stale closure 방지
|
||
// MultilingualInput 등에서 emit한 _changedKeys를 기반으로 이전 디바운스 누적값과 병합
|
||
const changedKeys = extractedEvent._changedKeys;
|
||
if (changedKeys && Array.isArray(changedKeys)
|
||
&& extractedEvent?.target?.value
|
||
&& typeof extractedEvent.target.value === 'object'
|
||
&& !Array.isArray(extractedEvent.target.value)) {
|
||
const accumulated = this.debounceAccumulatedValues.get(key);
|
||
if (accumulated) {
|
||
// 이전 누적값 기반, 현재 변경 키만 덮어쓰기
|
||
const merged = { ...accumulated };
|
||
for (const k of changedKeys) {
|
||
merged[k] = extractedEvent.target.value[k];
|
||
}
|
||
extractedEvent.target.value = merged;
|
||
}
|
||
this.debounceAccumulatedValues.set(key, extractedEvent.target.value);
|
||
}
|
||
|
||
// 기존 타이머 취소
|
||
const existingTimer = this.debounceTimers.get(key);
|
||
if (existingTimer) {
|
||
clearTimeout(existingTimer);
|
||
this.debounceTimers.delete(key);
|
||
}
|
||
|
||
// DevTools 추적 - pending 상태
|
||
const devTools = getDevTools();
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackAction({
|
||
handler: typeof action.handler === 'string' ? action.handler : String(action.handler),
|
||
type: action.type,
|
||
status: 'pending',
|
||
params: action.params,
|
||
debounce: {
|
||
delay: config.delay,
|
||
status: 'pending',
|
||
scheduledAt: Date.now(),
|
||
},
|
||
});
|
||
}
|
||
|
||
// leading: true이고 첫 호출인 경우 즉시 실행
|
||
const isFirstCall = !this.debounceTimers.has(key + '_leading');
|
||
if (config.leading && isFirstCall) {
|
||
this.debounceTimers.set(key + '_leading', setTimeout(() => {}, 0)); // 마커용
|
||
this.executeDebouncedAction(action, extractedEvent, dataContext, componentContext, args);
|
||
|
||
// DevTools 추적 - leading 실행
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackAction({
|
||
handler: typeof action.handler === 'string' ? action.handler : String(action.handler),
|
||
type: action.type,
|
||
status: 'success',
|
||
params: action.params,
|
||
debounce: {
|
||
delay: config.delay,
|
||
status: 'executed',
|
||
executedAt: Date.now(),
|
||
mode: 'leading',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// trailing: true이면 마지막 호출 후 delay 후 실행
|
||
if (config.trailing) {
|
||
// 플러시 함수: 비디바운스 액션이 실행되기 전에 즉시 호출될 수 있음
|
||
const executeTrailing = () => {
|
||
this.executeDebouncedAction(action, extractedEvent, dataContext, componentContext, args);
|
||
|
||
// DevTools 추적 - trailing 실행
|
||
if (devTools?.isEnabled()) {
|
||
devTools.trackAction({
|
||
handler: typeof action.handler === 'string' ? action.handler : String(action.handler),
|
||
type: action.type,
|
||
status: 'success',
|
||
params: action.params,
|
||
debounce: {
|
||
delay: config.delay,
|
||
status: 'executed',
|
||
executedAt: Date.now(),
|
||
mode: 'trailing',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
const timer = setTimeout(() => {
|
||
this.debounceTimers.delete(key);
|
||
this.debounceTimers.delete(key + '_leading'); // leading 마커 정리
|
||
this.pendingDebounceFlushers.delete(key);
|
||
this.debounceAccumulatedValues.delete(key); // _changedKeys 누적값 정리
|
||
|
||
executeTrailing();
|
||
}, config.delay);
|
||
|
||
this.debounceTimers.set(key, timer);
|
||
this.pendingDebounceFlushers.set(key, executeTrailing);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 컴포넌트 언마운트 시 debounce 타이머를 정리합니다.
|
||
*
|
||
* @param componentId 컴포넌트 ID
|
||
*/
|
||
clearDebounceTimers(componentId?: string): void {
|
||
if (componentId) {
|
||
// 특정 컴포넌트의 타이머만 정리
|
||
for (const [key, timer] of this.debounceTimers) {
|
||
if (key.startsWith(componentId + '-')) {
|
||
clearTimeout(timer);
|
||
this.debounceTimers.delete(key);
|
||
this.pendingDebounceFlushers.delete(key);
|
||
this.debounceAccumulatedValues.delete(key);
|
||
}
|
||
}
|
||
} else {
|
||
// 모든 타이머 정리
|
||
for (const timer of this.debounceTimers.values()) {
|
||
clearTimeout(timer);
|
||
}
|
||
this.debounceTimers.clear();
|
||
this.pendingDebounceFlushers.clear();
|
||
this.debounceAccumulatedValues.clear();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 프로그래매틱 호출(G7Core.state.setLocal, G7Core.dispatch)에 대한 디바운스를 처리합니다.
|
||
*
|
||
* 레이아웃 JSON 액션의 debounce와 동일한 타이머 인프라(debounceTimers, pendingDebounceFlushers)를
|
||
* 사용하여 컴포넌트 언마운트 시 자동 정리 및 flushPendingDebounceTimers 연동을 보장합니다.
|
||
*
|
||
* @param key debounce 고유 키 (동일 키의 이전 타이머를 취소)
|
||
* @param delay 디바운스 지연 시간 (ms)
|
||
* @param callback 지연 후 실행할 콜백
|
||
*
|
||
* @since engine-v1.41.0
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* // G7Core.state.setLocal({ debounce: 300, debounceKey: 'my-key' })에서 호출
|
||
* actionDispatcher.debouncedCall('my-key', 300, () => {
|
||
* G7Core.state.setLocal(updates);
|
||
* });
|
||
* ```
|
||
*/
|
||
debouncedCall(key: string, delay: number, callback: () => void): void {
|
||
// 기존 타이머 취소
|
||
const existingTimer = this.debounceTimers.get(key);
|
||
if (existingTimer) {
|
||
clearTimeout(existingTimer);
|
||
this.debounceTimers.delete(key);
|
||
}
|
||
|
||
const timer = setTimeout(() => {
|
||
this.debounceTimers.delete(key);
|
||
this.pendingDebounceFlushers.delete(key);
|
||
callback();
|
||
}, delay);
|
||
|
||
this.debounceTimers.set(key, timer);
|
||
// flushPendingDebounceTimers에서 즉시 실행 가능하도록 등록
|
||
this.pendingDebounceFlushers.set(key, callback);
|
||
}
|
||
|
||
/**
|
||
* 대기 중인 모든 debounce 액션을 즉시 실행합니다.
|
||
*
|
||
* 비디바운스 액션이 실행되기 전에 호출하여, 디바운스로 인해
|
||
* 아직 state에 반영되지 않은 변경사항을 즉시 적용합니다.
|
||
* 이를 통해 비디바운스 핸들러가 항상 최신 state를 읽을 수 있습니다.
|
||
*/
|
||
flushPendingDebounceTimers(): void {
|
||
if (this.pendingDebounceFlushers.size === 0) return;
|
||
|
||
// 반복 중 맵 변경 방지를 위해 복사 후 정리
|
||
const flushers = new Map(this.pendingDebounceFlushers);
|
||
|
||
for (const [key, flushFn] of flushers) {
|
||
// 대기 중인 타이머 취소
|
||
const timer = this.debounceTimers.get(key);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
this.debounceTimers.delete(key);
|
||
}
|
||
this.debounceTimers.delete(key + '_leading');
|
||
this.pendingDebounceFlushers.delete(key);
|
||
|
||
// 즉시 실행 (render: false 콜백 포함)
|
||
flushFn();
|
||
}
|
||
|
||
// engine-v1.42.0: flush된 콜백 중 render: false로 등록된 것이 있을 수 있음
|
||
// 저장 직전 등 flush 시점에는 항상 최신 상태를 React 트리에 반영해야 하므로
|
||
// 빈 업데이트로 강제 렌더 트리거 (render: true가 기본값)
|
||
if (this.globalStateUpdater) {
|
||
this.globalStateUpdater({});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 컴포넌트 props에서 액션 핸들러를 생성합니다.
|
||
*
|
||
* @param props 컴포넌트 props
|
||
* @param dataContext 데이터 컨텍스트
|
||
* @param componentContext 컴포넌트 컨텍스트 (state, setState)
|
||
*/
|
||
bindActionsToProps(
|
||
props: Record<string, any>,
|
||
dataContext?: any,
|
||
componentContext?: { state?: any; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null }
|
||
): Record<string, any> {
|
||
const boundProps: Record<string, any> = { ...props };
|
||
|
||
// actions 필드에서 액션 정의 추출
|
||
if (props.actions && Array.isArray(props.actions)) {
|
||
// 동일한 이벤트 타입의 액션들을 그룹화
|
||
const actionsByEvent: Map<string, ActionDefinition[]> = new Map();
|
||
|
||
for (const rawAction of props.actions) {
|
||
// actionRef 해석 - named_actions 참조를 실제 액션 정의로 변환
|
||
const action = this.resolveActionRef(rawAction);
|
||
const eventName = action.event
|
||
? this.normalizeEventPropName(action.event)
|
||
: this.getEventHandlerName(action.type);
|
||
if (!actionsByEvent.has(eventName)) {
|
||
actionsByEvent.set(eventName, []);
|
||
}
|
||
actionsByEvent.get(eventName)!.push(action);
|
||
}
|
||
|
||
// 컴포넌트 ID 추출 (debounce key 생성용)
|
||
const componentId = props.name || props.id || 'unknown';
|
||
|
||
// drop 액션이 있지만 dragover/dragenter 액션이 없으면 자동으로 preventDefault 핸들러 추가
|
||
// HTML5 드래그앤드롭에서 drop을 허용하려면 dragenter + dragover 모두 preventDefault() 호출 필요
|
||
const hasDropAction = actionsByEvent.has('onDrop');
|
||
const hasDragOverAction = actionsByEvent.has('onDragOver');
|
||
const hasDragEnterAction = actionsByEvent.has('onDragEnter');
|
||
if (hasDropAction && !hasDragOverAction) {
|
||
boundProps['onDragOver'] = (e: DragEvent) => {
|
||
e.preventDefault();
|
||
};
|
||
}
|
||
if (hasDropAction && !hasDragEnterAction) {
|
||
boundProps['onDragEnter'] = (e: DragEvent) => {
|
||
e.preventDefault();
|
||
};
|
||
}
|
||
|
||
// 각 이벤트 타입별로 핸들러 생성
|
||
for (const [eventName, actions] of actionsByEvent) {
|
||
// 커스텀 이벤트 (onSortChange, onSelectionChange 등)는 가변 인자를 받음
|
||
boundProps[eventName] = (...args: any[]) => {
|
||
for (const action of actions) {
|
||
// 첫 번째 인자가 이벤트 객체인 경우 (표준 DOM 이벤트)
|
||
const firstArg = args[0];
|
||
const isStandardEvent = firstArg && typeof firstArg === 'object' && 'preventDefault' in firstArg;
|
||
|
||
// dragover/drop 이벤트에서 자동으로 preventDefault 호출 (드롭 허용)
|
||
if (isStandardEvent && (eventName === 'onDragOver' || eventName === 'onDrop')) {
|
||
firstArg.preventDefault();
|
||
}
|
||
|
||
// 키보드 이벤트에서 key 필터링
|
||
if (isStandardEvent && action.key && 'key' in firstArg) {
|
||
// IME 조합 중 keydown 은 매칭에서 제외 (글자누락/이중제출 방지) — 공개#54
|
||
if (this.isImeComposing(firstArg as { isComposing?: boolean; keyCode?: number })) {
|
||
logger.log('Key filter skipped (IME composing):', action.key);
|
||
continue;
|
||
}
|
||
if (firstArg.key !== action.key) {
|
||
logger.log('Key filter not matched:', action.key, 'actual:', firstArg.key);
|
||
continue; // 키가 일치하지 않으면 이 액션 건너뛰기
|
||
}
|
||
logger.log('Key filter matched:', action.key);
|
||
}
|
||
|
||
// debounce 옵션이 있는 경우 debounce 처리
|
||
if (action.debounce) {
|
||
const debounceKey = `${componentId}-${action.handler}-${eventName}`;
|
||
|
||
// 커스텀 컴포넌트 이벤트 객체 감지 (예: MultilingualInput의 { target: { name, value } })
|
||
// React 컴포넌트에서 onChange로 emit하는 plain object
|
||
const isCustomComponentEvent =
|
||
firstArg &&
|
||
typeof firstArg === 'object' &&
|
||
!('preventDefault' in firstArg) &&
|
||
'target' in firstArg &&
|
||
firstArg.target !== null;
|
||
|
||
// 이벤트 객체 결정: 표준 DOM 이벤트 > 커스텀 컴포넌트 이벤트 > 빈 이벤트
|
||
let eventForHandler: Event;
|
||
if (isStandardEvent) {
|
||
eventForHandler = firstArg;
|
||
} else if (isCustomComponentEvent) {
|
||
// 커스텀 컴포넌트 이벤트를 synthetic event로 변환
|
||
const syntheticEvent: any = {
|
||
type: 'custom',
|
||
target: firstArg.target,
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
};
|
||
// _changedKeys 메타데이터 보존 (디바운스 병합에 사용)
|
||
if (firstArg._changedKeys) {
|
||
syntheticEvent._changedKeys = firstArg._changedKeys;
|
||
}
|
||
eventForHandler = syntheticEvent as unknown as Event;
|
||
} else {
|
||
eventForHandler = new Event('custom');
|
||
}
|
||
|
||
// 커스텀 이벤트인 경우 $args 컨텍스트 추가
|
||
const contextForDebounce = action.event
|
||
? { ...dataContext, $args: args }
|
||
: dataContext;
|
||
|
||
this.handleDebouncedAction(
|
||
action,
|
||
eventForHandler,
|
||
contextForDebounce,
|
||
componentContext,
|
||
debounceKey,
|
||
args
|
||
);
|
||
} else {
|
||
// 대기 중인 debounce 액션을 즉시 실행하여 state 최신화
|
||
this.flushPendingDebounceTimers();
|
||
|
||
// 기존 로직 (debounce 없음)
|
||
// 커스텀 이벤트 핸들러인 경우 (action.event 사용)
|
||
if (action.event) {
|
||
// 커스텀 콜백의 모든 인자를 $args 배열로 전달
|
||
const contextWithArgs = {
|
||
...dataContext,
|
||
$args: args,
|
||
};
|
||
|
||
// 이벤트 객체 결정: 표준 DOM 이벤트 > 커스텀 컴포넌트 이벤트 > 빈 이벤트.
|
||
// `type` 경로와 **같은 규칙**이어야 한다. 합성 컴포넌트(Select/MultilingualInput 등)는
|
||
// `preventDefault` 없는 `{ target: { name, value } }` 를 emit 하는데, 이걸 빈 이벤트로
|
||
// 갈아끼우면 `$event.target.value` 가 사라져 **핸들러는 실행되는데 값만 비는** 상태가 된다
|
||
// (콘솔·네트워크에 흔적이 없어 발견이 늦다).
|
||
const eventForHandler = this.resolveEventForHandler(firstArg, isStandardEvent);
|
||
this.createHandler(action, contextWithArgs, componentContext)(eventForHandler);
|
||
} else {
|
||
// 표준 이벤트 핸들러
|
||
if (isStandardEvent) {
|
||
this.createHandler(action, dataContext, componentContext)(firstArg);
|
||
} else {
|
||
// 커스텀 컴포넌트 이벤트 감지 (예: MultilingualInput의 { target: { name, value } })
|
||
// React 컴포넌트에서 onChange로 emit하는 plain object
|
||
const isCustomComponentEvent =
|
||
firstArg &&
|
||
typeof firstArg === 'object' &&
|
||
!('preventDefault' in firstArg) &&
|
||
'target' in firstArg &&
|
||
firstArg.target !== null;
|
||
|
||
if (isCustomComponentEvent) {
|
||
// 커스텀 컴포넌트 이벤트를 synthetic event로 변환
|
||
const eventForHandler = {
|
||
type: 'custom',
|
||
target: firstArg.target,
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
} as unknown as Event;
|
||
this.createHandler(action, dataContext, componentContext)(eventForHandler);
|
||
}
|
||
// 주의: raw value fallback 없음 — 컴포넌트는 `{ target: { value } }` 패턴을 사용해야 함
|
||
// (사례 26 회귀 테스트 참조: 마운트 시 raw value로 초기 setState가 발동하여
|
||
// API 로드 데이터를 덮어쓰는 버그 방지를 위해 의도적으로 제거됨)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
return boundProps;
|
||
}
|
||
|
||
/**
|
||
* 이벤트 타입을 핸들러 prop 이름으로 변환합니다.
|
||
*
|
||
* React는 camelCase 이벤트 이름을 사용합니다 (예: onKeyDown, onMouseEnter)
|
||
*
|
||
* @param eventType 이벤트 타입
|
||
*/
|
||
private getEventHandlerName(eventType: EventType): string {
|
||
return (
|
||
DOM_EVENT_PROP_MAP[eventType] || `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `event` 키로 적힌 이벤트 이름을 React prop 이름으로 정규화합니다.
|
||
*
|
||
* 액션은 이벤트를 `type` 또는 `event` 로 적을 수 있는데, `event` 값은 그대로 prop 이름이
|
||
* 되도록 설계돼 있습니다(`onSortEnd` 같은 컴포넌트 콜백, `upload:*` 같은 확장 발행 이벤트).
|
||
* 그래서 DOM 이벤트 이름을 `event: "click"` 처럼 적으면 `props.click` 이 만들어지고,
|
||
* React 는 그런 prop 을 무시하므로 **예외도 경고도 없이 핸들러가 붙지 않은 채** 렌더됩니다.
|
||
*
|
||
* 정규화는 알려진 DOM 이벤트 이름에만 적용합니다. 그 외(이미 `onXxx` 형태이거나
|
||
* 네임스페이스 커스텀 이벤트)는 손대지 않습니다 — 접두사를 덧붙이면 기존 확장 이벤트가
|
||
* 통째로 끊깁니다(`onSortEnd` → `onOnSortEnd`).
|
||
*
|
||
* @param eventName 액션의 `event` 값
|
||
* @returns React prop 이름 (알려진 DOM 이벤트가 아니면 입력 그대로)
|
||
*/
|
||
private normalizeEventPropName(eventName: string): string {
|
||
return DOM_EVENT_PROP_MAP[eventName] ?? eventName;
|
||
}
|
||
|
||
/**
|
||
* 콜백 첫 인자를 핸들러에 넘길 이벤트 객체로 해석합니다.
|
||
*
|
||
* 우선순위: 표준 DOM 이벤트 > 커스텀 컴포넌트 이벤트(synthetic 승격) > 빈 이벤트.
|
||
*
|
||
* 합성 컴포넌트(Select, MultilingualInput 등)는 `preventDefault` 없는
|
||
* `{ target: { name, value } }` 를 emit 합니다. 이를 빈 이벤트로 대체하면
|
||
* `$event.target.value` 바인딩이 조용히 `undefined` 가 되어, 액션은 성공으로 기록되는데
|
||
* 저장되는 값만 비는 상태가 됩니다. `type` 경로와 `event` 경로가 같은 규칙을 쓰도록
|
||
* 이 해석을 한 곳에 모읍니다.
|
||
*
|
||
* @param firstArg 콜백의 첫 번째 인자
|
||
* @param isStandardEvent 표준 DOM 이벤트 여부(`preventDefault` 보유)
|
||
* @returns 핸들러에 전달할 이벤트 객체
|
||
*/
|
||
private resolveEventForHandler(firstArg: any, isStandardEvent: boolean): Event {
|
||
if (isStandardEvent) {
|
||
return firstArg as Event;
|
||
}
|
||
|
||
const isCustomComponentEvent =
|
||
firstArg &&
|
||
typeof firstArg === 'object' &&
|
||
!('preventDefault' in firstArg) &&
|
||
'target' in firstArg &&
|
||
firstArg.target !== null;
|
||
|
||
if (!isCustomComponentEvent) {
|
||
return new Event('custom');
|
||
}
|
||
|
||
const syntheticEvent: any = {
|
||
type: 'custom',
|
||
target: firstArg.target,
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
};
|
||
|
||
// _changedKeys 메타데이터 보존 (디바운스 병합에 사용)
|
||
if (firstArg._changedKeys) {
|
||
syntheticEvent._changedKeys = firstArg._changedKeys;
|
||
}
|
||
|
||
return syntheticEvent as Event;
|
||
}
|
||
|
||
/**
|
||
* DevTools 로깅용으로 데이터를 안전하게 직렬화합니다.
|
||
*
|
||
* 순환 참조, 함수, DOM 요소 등을 제거하여 안전하게 로깅할 수 있도록 합니다.
|
||
*
|
||
* @param data 직렬화할 데이터
|
||
* @param maxDepth 최대 깊이 (기본값: 5)
|
||
* @returns 직렬화된 데이터
|
||
*/
|
||
private sanitizeForDevTools(data: any, maxDepth: number = 5): any {
|
||
const seen = new WeakSet();
|
||
|
||
const sanitize = (value: any, depth: number): any => {
|
||
// 기본 타입은 그대로 반환
|
||
if (value === null || value === undefined) {
|
||
return value;
|
||
}
|
||
|
||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||
return value;
|
||
}
|
||
|
||
// 깊이 제한 초과
|
||
if (depth > maxDepth) {
|
||
return '[Max Depth Exceeded]';
|
||
}
|
||
|
||
// 함수는 함수명만 반환
|
||
if (typeof value === 'function') {
|
||
return `[Function: ${value.name || 'anonymous'}]`;
|
||
}
|
||
|
||
// DOM 요소는 태그명과 클래스 반환
|
||
if (value instanceof Element) {
|
||
return `[Element: ${value.tagName}${value.className ? '.' + value.className.split(' ').join('.') : ''}]`;
|
||
}
|
||
|
||
// Event 객체는 타입만 반환
|
||
if (value instanceof Event) {
|
||
return `[Event: ${value.type}]`;
|
||
}
|
||
|
||
// 배열 처리
|
||
if (Array.isArray(value)) {
|
||
if (seen.has(value)) {
|
||
return '[Circular Array]';
|
||
}
|
||
seen.add(value);
|
||
// 큰 배열은 처음 10개만
|
||
const result = value.slice(0, 10).map(item => sanitize(item, depth + 1));
|
||
if (value.length > 10) {
|
||
result.push(`... ${value.length - 10} more items`);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// 객체 처리
|
||
if (typeof value === 'object') {
|
||
if (seen.has(value)) {
|
||
return '[Circular Reference]';
|
||
}
|
||
seen.add(value);
|
||
|
||
const result: Record<string, any> = {};
|
||
const keys = Object.keys(value);
|
||
|
||
// 큰 객체는 처음 20개 키만
|
||
const keysToProcess = keys.slice(0, 20);
|
||
for (const key of keysToProcess) {
|
||
try {
|
||
result[key] = sanitize(value[key], depth + 1);
|
||
} catch {
|
||
result[key] = '[Error accessing property]';
|
||
}
|
||
}
|
||
|
||
if (keys.length > 20) {
|
||
result['...'] = `${keys.length - 20} more properties`;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
return String(value);
|
||
};
|
||
|
||
return sanitize(data, 0);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 싱글톤 인스턴스 생성 헬퍼
|
||
*/
|
||
let instance: ActionDispatcher | null = null;
|
||
|
||
/**
|
||
* ActionDispatcher 싱글톤 인스턴스를 반환합니다.
|
||
*
|
||
* @param defaultContext 기본 컨텍스트 (인스턴스 생성 시에만 적용)
|
||
* @returns ActionDispatcher 싱글톤 인스턴스
|
||
*/
|
||
export function getActionDispatcher(
|
||
defaultContext?: Partial<ActionContext>
|
||
): ActionDispatcher {
|
||
if (!instance) {
|
||
instance = new ActionDispatcher(defaultContext);
|
||
}
|
||
return instance;
|
||
}
|
||
|
||
/**
|
||
* ActionDispatcher 싱글톤 인스턴스를 설정합니다.
|
||
*
|
||
* template-engine.ts에서 생성한 ActionDispatcher 인스턴스를
|
||
* 싱글톤으로 설정하여 renderItemChildren 등에서 동일한 인스턴스를 사용하도록 합니다.
|
||
*
|
||
* @param dispatcher 설정할 ActionDispatcher 인스턴스
|
||
*/
|
||
export function setActionDispatcherInstance(dispatcher: ActionDispatcher): void {
|
||
instance = dispatcher;
|
||
}
|