/** * 렌더링 관련 헬퍼 함수 모듈 * * DataGrid의 cellChildren, ul의 li 등 반복 컨텍스트에서 * 자식 컴포넌트를 렌더링할 때 사용하는 헬퍼 함수들을 제공합니다. * * @packageDocumentation */ import React from 'react'; import { DataBindingEngine, BindingContext } from '../DataBindingEngine'; import { TranslationEngine, TranslationContext } from '../TranslationEngine'; import { ComponentDefinition } from '../DynamicRenderer'; import { responsiveManager } from '../ResponsiveManager'; import { getActionDispatcher } from '../ActionDispatcher'; import { createLogger } from '../../utils/Logger'; import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from '../rawMarkers'; import type { G7DevToolsInterface } from '../G7CoreGlobals'; import { evaluateConditionExpression, evaluateConditionBranches, isConditionExpression, isConditionBranches, type ConditionsProperty, } from './ConditionEvaluator'; const logger = createLogger('RenderHelpers'); /** * JavaScript 리터럴 값 매핑 * * resolve()가 리터럴을 컨텍스트 경로로 해석하는 것을 방지합니다. * undefined는 Map에서 get() 시 undefined를 반환하므로 별도 센티널 불필요 — * `has()`로 존재 여부를 먼저 확인하지 않고 `get() !== undefined`로 체크합니다. * null은 falsy이므로 undefined와 동일하게 처리됩니다. * * @since engine-v1.29.2 */ const LITERALS = new Map([ ['true', true], ['false', false], ['null', null], // undefined는 Map.get()의 기본 반환값과 구분 불가하므로 제외 // → resolve("undefined")는 context에 없으면 undefined 반환 → Boolean(undefined) = false (정상) ]); /** * 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; } } /** * 공유 DataBindingEngine 인스턴스 * * 표현식 평가에서 재사용하여 성능 최적화 */ const sharedBindingEngine = new DataBindingEngine(); /** * renderItemChildren 함수 옵션 */ export interface RenderItemChildrenOptions { /** * TranslationContext - 다국어 번역에 필요 */ translationContext?: TranslationContext; /** * DataBindingEngine 인스턴스 - 미제공 시 새로 생성 */ bindingEngine?: DataBindingEngine; /** * TranslationEngine 인스턴스 - 미제공 시 싱글톤 사용 */ translationEngine?: TranslationEngine; /** * ActionDispatcher 인스턴스 - 미제공 시 싱글톤 사용 * CardGrid, DataGrid 등 composite 컴포넌트에서 actions 바인딩에 필요 */ actionDispatcher?: any; /** * 부모 컴포넌트 컨텍스트 (state, setState) * * expandChildren 등에서 부모 컴포넌트의 로컬 상태를 업데이트할 때 사용합니다. * 이 컨텍스트가 전달되면 G7Core.state.setLocal()이 부모의 dynamicState를 업데이트합니다. */ componentContext?: { state?: any; setState?: (updates: any) => void }; /** * remount 키 생성 함수 * * DynamicRenderer의 getRemountKey 함수를 전달받아 cellChildren에서도 * remount 핸들러가 작동하도록 지원합니다. * * @param componentId - 컴포넌트 ID * @param fallbackKey - ID가 없을 때 사용할 기본 키 * @returns React key로 사용할 문자열 */ getRemountKey?: (componentId: string | undefined, fallbackKey: string) => string; } /** * 복잡한 표현식 여부 감지 * * 삼항 연산자(?:), 논리 연산자(&&, ||), 비교 연산자, 함수 호출, * 배열/객체 리터럴 등이 포함된 경우 true 반환 * * true 인 경우 evaluateExpression(JS 평가)로, false 인 경우 resolve(dot/bracket * path 탐색)로 라우팅된다. * * `[`/`]`/`{`/`}` 포함 (@since engine-v1.50.0): * `{{['a','b']}}` 같은 배열 리터럴이나 `{{[{...}]}}` 같은 객체 리터럴이 * "단순 경로"로 오판되어 resolve() 경로탐색 → undefined 가 되던 문제를 해결한다. * 순수 숫자 대괄호 인덱싱(`items[0]`, `entry[1]`)은 resolve()/evaluateExpression() * 양 경로의 결과가 동일하므로(회귀 테스트로 입증) 경로 이동에 따른 동작 변화가 없다. * * @param expr - 검사할 표현식 * @returns 복잡한 표현식 여부 */ function isComplexExpression(expr: string): boolean { // () 추가: $localized(menu.name) 같은 함수 호출 표현식도 복잡한 표현식으로 처리 // []{} 추가: 배열/객체 리터럴(`['a','b']`, `[{...}]`)을 evaluateExpression 으로 라우팅 return /[?:|&!+\-*/<>=()[\]{}]/.test(expr); } /** * 단일 바인딩 표현식 추출 * * `{{expression}}` 형태에서 expression 부분만 추출합니다. * 문자열 리터럴 내부의 `}` 문자를 올바르게 처리합니다. * * @param value - 검사할 문자열 * @returns 추출된 표현식 또는 null (유효하지 않은 경우) * * @example * ```typescript * extractSingleBinding("{{row.name}}") // "row.name" * extractSingleBinding("{{row.status === 'active' ? 'yes' : 'no'}}") // "row.status === 'active' ? 'yes' : 'no'" * extractSingleBinding("hello {{name}}") // null (단일 바인딩이 아님) * ``` */ function extractSingleBinding(value: string): string | null { if (!value.startsWith('{{') || !value.endsWith('}}')) { return null; } const inner = value.slice(2, -2); let inString: string | null = null; let braceCount = 0; for (let i = 0; i < inner.length; i++) { const char = inner[i]; const prevChar = i > 0 ? inner[i - 1] : ''; // 이스케이프 문자 처리 if (prevChar === '\\') continue; // 문자열 시작/종료 감지 (따옴표 추적) if ((char === '"' || char === "'") && !inString) { inString = char; } else if (char === inString) { inString = null; } // 문자열 외부에서만 중괄호 카운트 if (!inString) { if (char === '{') braceCount++; if (char === '}') braceCount--; if (braceCount < 0) return null; } } return braceCount === 0 ? inner.trim() : null; } /** * iteration source 표현식을 평가하여 배열을 반환합니다. * * 단순 경로(`users.data`)와 복잡한 표현식(`roles?.data ?? []`) 모두 지원합니다. * 표현식 여부에 따라 적절한 평가 방식을 선택하여 원본 타입(배열)을 반환합니다. * * @param source - iteration source 문자열 (예: "roles?.data ?? []", "users.data") * @param context - 데이터 컨텍스트 * @param bindingEngine - DataBindingEngine 인스턴스 * @returns 평가된 배열 또는 원본 값 * * @example * ```typescript * // 복잡한 표현식 * const data = resolveIterationSource("roles?.data ?? []", context, bindingEngine); * // 결과: [{id: 1, name: "admin"}, ...] * * // 단순 경로 * const data = resolveIterationSource("users.data", context, bindingEngine); * // 결과: [{id: 1, name: "홍길동"}, ...] * ``` */ export function resolveIterationSource( source: string, context: BindingContext, bindingEngine: DataBindingEngine ): any { // {{...}} 형식의 표현식에서 중괄호 제거 let normalizedSource = source; if (source.startsWith('{{') && source.endsWith('}}')) { normalizedSource = source.slice(2, -2).trim(); } // 표현식 여부 확인 (?, :, ||, &&, !, 산술/비교 연산자 등) if (isComplexExpression(normalizedSource)) { // 표현식인 경우 evaluateExpression 사용 (원본 타입 유지) try { return bindingEngine.evaluateExpression(normalizedSource, context); } catch (error) { logger.warn(`resolveIterationSource: 표현식 평가 실패: ${source}`, error); return undefined; } } // 단순 경로인 경우 resolve 사용 return bindingEngine.resolve(normalizedSource, context, { skipCache: true }); } /** * 격리된 상태 컨텍스트 인터페이스 */ export interface IsolatedContextValue { state: Record; setState: (path: string, value: any) => void; getState: (path: string) => any; mergeState: (updates: Record) => void; } /** * 컴포넌트 컨텍스트 인터페이스 */ export interface ComponentContext { state?: Record; setState?: (updates: any) => void; isolatedContext?: IsolatedContextValue | null; /** 부모 DynamicRenderer의 전체 데이터 컨텍스트 (데이터소스 포함) @since engine-v1.19.1 */ parentDataContext?: Record; } /** * 효과적인 데이터 컨텍스트를 생성합니다. * * baseContext에 componentContext의 상태를 병합하여 바인딩에 사용할 수 있는 * 완전한 컨텍스트를 생성합니다. _local, _global, _isolated 상태를 모두 포함합니다. * * @param baseContext - 기본 데이터 컨텍스트 (_local, _global 등 포함) * @param componentContext - 컴포넌트별 로컬 상태 및 격리 상태 컨텍스트 * @returns 병합된 효과적인 컨텍스트 * * @example * ```typescript * const effectiveContext = getEffectiveContext( * { _local: { user: {...} }, _global: { settings: {...} } }, * { state: { filter: 'active' }, isolatedContext: { state: { step: 1 } } } * ); * // 결과: { * // _local: { user: {...}, filter: 'active' }, * // _global: { settings: {...} }, * // _isolated: { step: 1 } * // } * ``` */ export function getEffectiveContext( baseContext: Record, componentContext?: ComponentContext ): Record { const result = { ...baseContext }; // 부모 데이터 컨텍스트 병합 (engine-v1.19.1) // DataGrid 등 복합 컴포넌트의 cellChildren/footerCells/footerCardChildren에서 // 데이터소스(order, active_carriers 등)에 접근할 수 있도록 최상위 컨텍스트에 병합. // 기존 baseContext 키(row, value, $value 등)를 덮어쓰지 않음. if (componentContext?.parentDataContext) { for (const [key, value] of Object.entries(componentContext.parentDataContext)) { if (!(key in result)) { result[key] = value; } } } // componentContext.state를 _local에 병합 if (componentContext?.state) { const existingLocal = result._local || {}; result._local = { ...existingLocal, ...componentContext.state, }; } // isolatedContext.state를 _isolated에 포함 if (componentContext?.isolatedContext?.state) { result._isolated = componentContext.isolatedContext.state; } return result; } /** * bindComponentActions 옵션 */ export interface BindComponentActionsOptions { /** * 컴포넌트 컨텍스트 (state, setState, isolatedContext) * DynamicRenderer에서 컴포넌트 로컬 상태 관리에 사용 */ componentContext?: { state?: any; setState?: (updates: any) => void; /** 격리된 상태 컨텍스트 (isolatedState 속성이 있는 컴포넌트에서 제공) */ isolatedContext?: IsolatedContextValue | null; }; /** * ActionDispatcher 인스턴스 * 미제공 시 getActionDispatcher() 싱글톤 사용 * DynamicRenderer에서는 props로 전달받은 인스턴스 사용 권장 */ actionDispatcher?: any; } /** * 컴포넌트의 actions 배열을 props에 바인딩합니다. * * DynamicRenderer의 액션 바인딩 로직과 동일한 패턴을 사용합니다. * actions 배열을 이벤트 핸들러(onClick, onChange 등)로 변환합니다. * * 코드 중복을 최소화하기 위해 공유 유틸리티로 추출되었습니다. * DynamicRenderer와 renderItemChildren에서 동일하게 사용됩니다. * * @param props - 기존 props 객체 * @param actions - 액션 정의 배열 * @param context - 데이터 컨텍스트 * @param options - 추가 옵션 (componentContext 등) * @returns 이벤트 핸들러가 바인딩된 props 객체 (actions prop 제거됨) * * @example * ```typescript * const actions = [{ type: 'click', handler: 'openModal', target: 'myModal' }]; * const boundProps = bindComponentActions({ className: 'btn' }, actions, { row: {...} }); * // 결과: { className: 'btn', onClick: [Function] } * ``` */ export function bindComponentActions( props: Record, actions: any[] | undefined, context: Record, options?: BindComponentActionsOptions ): Record { if (!actions || !Array.isArray(actions) || actions.length === 0) { return props; } // options에서 전달된 actionDispatcher를 우선 사용, 없으면 싱글톤 사용 const actionDispatcher = options?.actionDispatcher ?? getActionDispatcher(); const propsWithActions = { ...props, actions }; const boundProps = actionDispatcher.bindActionsToProps( propsWithActions, context, options?.componentContext ); // actions prop 제거 (HTML 속성으로 렌더링되지 않도록) delete boundProps.actions; return boundProps; } /** * 조건부 렌더링 평가 함수 * * DynamicRenderer의 shouldRender 로직과 동일한 패턴을 사용합니다. * `if` 속성을 평가하여 컴포넌트를 렌더링할지 결정합니다. * * 코드 중복을 최소화하기 위해 공유 유틸리티로 추출되었습니다. * DynamicRenderer와 renderItemChildren에서 동일하게 사용됩니다. * * @param ifCondition - if 속성 값 (예: "{{row.status == 'active'}}") * @param context - 데이터 컨텍스트 * @param bindingEngine - DataBindingEngine 인스턴스 * @param componentId - 컴포넌트 ID (디버깅용, 선택) * @returns 렌더링 여부 (true: 렌더링, false: 숨김) * * @example * ```typescript * // 단순 조건 * evaluateIfCondition("{{row.active}}", { row: { active: true } }, bindingEngine) * // 결과: true * * // 비교 조건 * evaluateIfCondition("{{row.status == 'active'}}", { row: { status: 'active' } }, bindingEngine) * // 결과: true * * // falsy 값 * evaluateIfCondition("{{row.count}}", { row: { count: 0 } }, bindingEngine) * // 결과: false * ``` */ export function evaluateIfCondition( ifCondition: string | boolean | undefined, context: BindingContext, bindingEngine: DataBindingEngine, componentId?: string ): boolean { // boolean 값이 이미 해석된 경우 (엔진 prop 사전 해석으로 condition이 boolean이 될 수 있음) if (typeof ifCondition === 'boolean') { return ifCondition; } // if 조건이 없으면 항상 렌더링 if (!ifCondition) { return true; } try { // {{variable}} 형태의 단일 바인딩인지 체크 const singleBindingPath = extractSingleBinding(ifCondition); let resolved: any; if (singleBindingPath !== null) { // JavaScript 리터럴 값 직접 처리 (resolve 경로 탐색 방지) @since engine-v1.29.2 const literal = LITERALS.get(singleBindingPath); if (literal !== undefined) { resolved = literal; } else if (isComplexExpression(singleBindingPath)) { // Optional chaining(?.)이나 복잡한 표현식은 evaluateExpression 사용 resolved = bindingEngine.evaluateExpression(singleBindingPath, context); } else { // 단순 경로는 resolve 메서드 사용 (원본 타입 유지) resolved = bindingEngine.resolve(singleBindingPath, context, { skipCache: true }); } } else { // 문자열 보간인 경우 resolveBindings 사용 resolved = bindingEngine.resolveBindings(ifCondition, context, { skipCache: true }); } // 문자열 "false", "0", "", "null", "undefined"를 false로 처리 if (typeof resolved === 'string') { const lowerResolved = resolved.toLowerCase().trim(); if (lowerResolved === 'false' || lowerResolved === '0' || lowerResolved === '' || lowerResolved === 'null' || lowerResolved === 'undefined') { return false; } } // 결과를 boolean으로 변환 return Boolean(resolved); } catch (error) { logger.warn( `evaluateIfCondition: 조건 평가 실패 (컴포넌트: ${componentId || 'unknown'}):`, error ); return false; } } /** * 컴포넌트 렌더링 조건 옵션 */ export interface EvaluateRenderConditionOptions { /** * if 속성 (기존 단일 조건, 하위 호환) */ if?: string; /** * condition 속성 (if의 별칭 — 컴포넌트 내부 children에서 관례적으로 사용) * @since engine-v1.17.1 */ condition?: string | boolean; /** * conditions 속성 (AND/OR 그룹 또는 if/else 체인) */ conditions?: ConditionsProperty; } /** * 컴포넌트의 렌더링 조건을 평가합니다 (if 또는 conditions) * * `if` 속성과 `conditions` 속성을 모두 지원합니다. * - `if`: 기존 단일 조건 (하위 호환) * - `conditions`: AND/OR 그룹 또는 if/else 체인 * * 우선순위: `if` 속성이 있으면 먼저 평가, 없으면 `conditions` 평가 * * @param componentDef - 컴포넌트 정의 또는 조건 옵션 ({ if?, conditions? }) * @param context - 데이터 컨텍스트 * @param bindingEngine - DataBindingEngine 인스턴스 * @param componentId - 컴포넌트 ID (디버깅용, 선택) * @returns 렌더링 여부 (true: 렌더링, false: 숨김) * * @example * ```typescript * // 기존 if 조건 (하위 호환) * evaluateRenderCondition( * { if: "{{user.isAdmin}}" }, * context, * bindingEngine * ); // → true/false * * // AND 그룹 조건 * evaluateRenderCondition( * { conditions: { and: ["{{route.id}}", "{{form_data?.data?.author}}"] } }, * context, * bindingEngine * ); // → 모든 조건이 true일 때만 true * * // OR 그룹 조건 * evaluateRenderCondition( * { conditions: { or: ["{{user.isAdmin}}", "{{user.isManager}}"] } }, * context, * bindingEngine * ); // → 하나라도 true면 true * * // if/else 체인 (컴포넌트에서 하나라도 true면 렌더링) * evaluateRenderCondition( * { * conditions: [ * { if: "{{user.role === 'admin'}}" }, * { if: "{{user.role === 'manager'}}" } * ] * }, * context, * bindingEngine * ); // → 어느 조건이라도 true면 렌더링 * ``` */ export function evaluateRenderCondition( componentDef: EvaluateRenderConditionOptions, context: BindingContext, bindingEngine: DataBindingEngine, componentId?: string ): boolean { // 1. 기존 if 속성 우선 (하위 호환) if (componentDef.if !== undefined) { return evaluateIfCondition(componentDef.if, context, bindingEngine, componentId); } // 1-1. condition 속성 (if의 별칭 — 컴포넌트 내부 children에서 관례적 사용) if (componentDef.condition !== undefined) { return evaluateIfCondition(componentDef.condition, context, bindingEngine, componentId); } // 2. conditions 속성이 없으면 항상 렌더링 if (componentDef.conditions === undefined) { return true; } const conditions = componentDef.conditions; try { // 3. 단일 ConditionExpression (문자열, AND, OR) if (isConditionExpression(conditions)) { return evaluateConditionExpression(conditions, context, bindingEngine); } // 4. ConditionBranch[] (if/else 체인) if (isConditionBranches(conditions)) { const result = evaluateConditionBranches(conditions, context, bindingEngine); // 컴포넌트에서는 하나라도 매칭되면 렌더링 return result.matched; } // 알 수 없는 형식 logger.warn( `evaluateRenderCondition: 알 수 없는 conditions 형식 (컴포넌트: ${componentId || 'unknown'})` ); return true; } catch (error) { logger.warn( `evaluateRenderCondition: conditions 평가 실패 (컴포넌트: ${componentId || 'unknown'}):`, error ); return false; } } /** * 컴포넌트 정의에 반응형 오버라이드를 적용합니다. * * responsive 속성이 있으면 현재 화면 너비에 맞는 오버라이드를 적용합니다. * DynamicRenderer의 effectiveComponentDef 로직과 동일한 패턴을 사용합니다. * * @param componentDef - 원본 컴포넌트 정의 * @returns 반응형 오버라이드가 적용된 컴포넌트 정의 */ function applyResponsiveOverrides(componentDef: ComponentDefinition): ComponentDefinition { if (!componentDef.responsive) { return componentDef; } const currentWidth = responsiveManager.getWidth(); const matchedKey = responsiveManager.getMatchingKey(componentDef.responsive, currentWidth); if (!matchedKey) { return componentDef; } const override = componentDef.responsive[matchedKey]; return { ...componentDef, // props는 얕은 머지 (지정한 속성만 대체, 미지정 속성 유지) props: { ...componentDef.props, ...override.props }, // children, text, if, iteration은 완전 교체 children: override.children ?? componentDef.children, text: override.text ?? componentDef.text, if: override.if ?? componentDef.if, iteration: override.iteration ?? componentDef.iteration, }; } /** * 반복 아이템의 자식 컴포넌트를 렌더링합니다. * * DataGrid의 cellChildren, ul의 li 등 반복 컨텍스트에서 * 자식 컴포넌트를 렌더링할 때 사용합니다. * * 템플릿 엔진의 바인딩, 표현식 평가, 다국어 번역 등 * 모든 기능을 지원합니다. * * @param children - 렌더링할 컴포넌트 정의 배열 * @param itemContext - 반복 아이템 컨텍스트 (row, item, value, index 등) * @param componentMap - 컴포넌트 맵 (ComponentRegistry에서 가져온 컴포넌트들) * @param keyPrefix - React key 접두사 (선택) * @param options - 추가 옵션 (translationContext, bindingEngine 등) * @returns React 노드 배열 * * @example * ```tsx * // DataGrid에서 cellChildren 렌더링 * const renderedChildren = renderItemChildren( * cellChildren, * { row, value, index }, * componentMap, * `cell-${index}` * ); * * // ul에서 li 아이템 렌더링 * const listItems = renderItemChildren( * itemChildren, * { item, index }, * componentMap, * `list-item-${index}` * ); * ``` */ export function renderItemChildren( children: ComponentDefinition[], itemContext: Record, componentMap: Record>, keyPrefix?: string, options?: RenderItemChildrenOptions ): React.ReactNode[] { if (!children || children.length === 0) { return []; } // 엔진 인스턴스 확인 - 없으면 새로 생성 const bindingEngine = options?.bindingEngine ?? new DataBindingEngine(); const translationEngine = options?.translationEngine ?? TranslationEngine.getInstance(); const translationContext = options?.translationContext ?? { templateId: '', locale: 'ko', }; // Phase 1-1: componentContext가 있으면 자동으로 state를 _local에 병합 // Phase 2: isolatedContext.state를 _isolated에 포함 // 이전에는 DataGrid 등에서 수동으로 수행하던 로직 const effectiveContext = getEffectiveContext(itemContext, options?.componentContext); /** * props 값을 바인딩/표현식 평가하여 해석 * 반복 렌더링이므로 캐시를 사용하지 않음 (같은 경로가 다른 row에서 다른 값을 가져야 함) */ const resolveValue = (value: any, context: Record): any => { if (typeof value === 'string') { // raw 마커: 번역 면제 — 마커 제거 후 원본 반환 // @since engine-v1.27.0 if (isRawWrapped(value)) { return unwrapRaw(value); } // 혼합 보간 시 raw 마커 영역 보호 // @since engine-v1.27.0 if (containsRawMarker(value)) { const rawSegments: string[] = []; const protectedValue = value.replace( new RegExp(`${RAW_MARKER_START}([^${RAW_MARKER_END}]*)${RAW_MARKER_END}`, 'g'), (_m: string, content: string) => { rawSegments.push(content); return `${RAW_PLACEHOLDER_MARKER}${rawSegments.length - 1}${RAW_PLACEHOLDER_MARKER}`; } ); let translated = protectedValue; if (/\$t:[a-zA-Z0-9._-]+/.test(protectedValue)) { translated = translationEngine.resolveTranslations( protectedValue, translationContext, context ); } return translated.replace( new RegExp(`${RAW_PLACEHOLDER_MARKER}(\\d+)${RAW_PLACEHOLDER_MARKER}`, 'g'), (_m: string, idx: string) => rawSegments[parseInt(idx)] ); } // 다국어 번역 // $t:defer: prefix는 DynamicRenderer에서 건너뛰고 여기서 처리됨 // defer: 부분을 제거하고 일반 $t: 번역으로 처리 if (value.startsWith('$t:defer:')) { const normalizedValue = '$t:' + value.slice(9); // '$t:defer:'.length === 9 return translationEngine.resolveTranslations( normalizedValue, translationContext, context ); } if (value.startsWith('$t:')) { return translationEngine.resolveTranslations( value, translationContext, context ); } // 단일 바인딩 표현식 const singleBindingPath = extractSingleBinding(value); if (singleBindingPath !== null) { // raw: 접두사 감지 @since engine-v1.27.0 let isRawBinding = false; let effectivePath = singleBindingPath; if (singleBindingPath.startsWith(RAW_PREFIX)) { isRawBinding = true; effectivePath = singleBindingPath.slice(RAW_PREFIX.length); } let result; if (isComplexExpression(effectivePath)) { try { result = bindingEngine.evaluateExpression(effectivePath, context); } catch (error) { logger.warn('renderItemChildren: 표현식 평가 실패:', error); return undefined; } } else { // skipCache: true - 반복 렌더링에서 같은 경로가 다른 값을 가져야 함 result = bindingEngine.resolve(effectivePath, context, { skipCache: true }); } // 표현식 평가 결과가 $t: 접두사 문자열이면 번역 처리 // 예: {{row.published ? '$t:module.key.published' : '$t:module.key.unpublished'}} // → 평가 결과 "$t:module.key.published"를 번역하여 "발행" 반환 // @since engine-v1.28.1 if (!isRawBinding && typeof result === 'string' && /\$t:[a-zA-Z0-9._-]+/.test(result)) { result = translationEngine.resolveTranslations( result, translationContext, context ); } return isRawBinding && result != null ? wrapRawDeep(result) : result; } // 문자열 보간 if (value.includes('{{')) { // skipCache: true - 반복 렌더링에서 같은 경로가 다른 값을 가져야 함 const interpolated = bindingEngine.resolveBindings(value, context, { skipCache: true }); // 보간 결과에 $t: 접두사가 포함되면 번역 처리 @since engine-v1.28.1 if (typeof interpolated === 'string' && /\$t:[a-zA-Z0-9._-]+/.test(interpolated)) { return translationEngine.resolveTranslations( interpolated, translationContext, context ); } return interpolated; } return value; } if (Array.isArray(value)) { return value.map((item) => resolveValue(item, context)); } if (value && typeof value === 'object') { const resolved: Record = {}; for (const [k, v] of Object.entries(value)) { resolved[k] = resolveValue(v, context); } return resolved; } return value; }; /** * 컴포넌트 정의의 props를 해석 */ const resolveProps = ( props: Record | undefined, context: Record ): Record => { if (!props) return {}; const resolved: Record = {}; for (const [key, value] of Object.entries(props)) { resolved[key] = resolveValue(value, context); } return resolved; }; /** * iteration이 있는 컴포넌트 렌더링 * source 배열의 각 아이템에 대해 컴포넌트를 반복 렌더링 */ const renderWithIteration = ( originalChildDef: ComponentDefinition, baseKey: string, baseContext: Record ): React.ReactNode[] => { // 반응형 오버라이드 적용 const childDef = applyResponsiveOverrides(originalChildDef); const iteration = childDef.iteration as { source: string; item_var: string; index_var?: string } | undefined; if (!iteration) return []; // source 경로에서 배열 가져오기 (표현식 지원) const sourceArray = resolveIterationSource(iteration.source, baseContext, bindingEngine); if (!Array.isArray(sourceArray)) { logger.warn(`renderItemChildren: iteration.source가 배열이 아닙니다: ${iteration.source}`); return []; } // DevTools: iteration 추적 const devTools = getDevTools(); if (devTools?.isEnabled()) { const iterationId = `${baseKey}-iteration`; devTools.trackIteration( iterationId, iteration.source, iteration.item_var, iteration.index_var, sourceArray.length ); } // 컴포넌트 조회 const Component = componentMap[childDef.name]; if (!Component) { logger.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${childDef.name}`); return []; } // 각 아이템에 대해 컴포넌트 렌더링 (if 조건 실패 시 필터링) return sourceArray.flatMap((item: any, itemIndex: number) => { // 컨텍스트에 iteration 변수 추가 // index_var가 지정되어 있으면 해당 이름 사용, 아니면 기본 패턴(item_var + _index) 사용 const iterationContext = { ...baseContext, [iteration.item_var]: item, [`${iteration.item_var}_index`]: itemIndex, // index_var가 명시적으로 지정된 경우 해당 이름으로도 인덱스 추가 ...(iteration.index_var ? { [iteration.index_var]: itemIndex } : {}), }; // if/condition/conditions 조건 평가 - 조건이 false이면 렌더링하지 않음 const shouldRender = evaluateRenderCondition( { if: childDef.if, condition: childDef.condition, conditions: childDef.conditions }, iterationContext, bindingEngine, childDef.id ); // DevTools: if 조건 추적 (iteration 내부) // TODO: conditions 속성은 DevTools Phase 5에서 별도 추적 구현 예정 if (childDef.if && devTools?.isEnabled()) { const conditionId = `${baseKey}-iter-${itemIndex}-if`; devTools.trackIfCondition(conditionId, childDef.if, shouldRender, childDef.name); } if (!shouldRender) { return []; } // remount 지원: getRemountKey가 제공되면 사용, 아니면 기본 키 생성 // childDef.id의 표현식을 해석 (예: "toggle_{{row.id}}" → "toggle_3") const resolvedIterationId = childDef.id ? String(resolveValue(childDef.id, iterationContext)) : undefined; const iterationFallbackKey = `${baseKey}-iter-${itemIndex}`; const iterationKey = options?.getRemountKey ? options.getRemountKey(resolvedIterationId, iterationFallbackKey) : iterationFallbackKey; // props 해석 const resolvedProps = resolveProps(childDef.props, iterationContext); // actions 바인딩 (DynamicRenderer와 동일한 패턴) const finalProps = bindComponentActions(resolvedProps, childDef.actions, iterationContext, { actionDispatcher: options?.actionDispatcher, componentContext: options?.componentContext, }); // text 처리 let childrenNodes: React.ReactNode = null; if (childDef.text !== undefined) { childrenNodes = resolveValue(childDef.text, iterationContext); } else if (childDef.children && childDef.children.length > 0) { childrenNodes = renderItemChildren( childDef.children as ComponentDefinition[], iterationContext, componentMap, iterationKey, options ); } // DevTools: 컴포넌트 렌더링 추적 (iteration 내부) if (devTools?.isEnabled()) { devTools.trackRender(childDef.name); } return [React.createElement(Component, { key: iterationKey, ...finalProps }, childrenNodes)]; }); }; /** * 자식 컴포넌트 렌더링 * Phase 1-1: effectiveContext 사용 (componentContext.state가 _local에 자동 병합됨) */ return children.flatMap((originalChildDef, index) => { // 반응형 오버라이드 적용 const childDef = applyResponsiveOverrides(originalChildDef); // remount 지원: getRemountKey가 제공되면 사용, 아니면 기본 키 생성 // childDef.id의 표현식을 해석 (예: "toggle_{{row.id}}" → "toggle_3") const resolvedId = childDef.id ? String(resolveValue(childDef.id, effectiveContext)) : undefined; const fallbackKey = keyPrefix ? `${keyPrefix}-${resolvedId || index}` : (resolvedId || `child-${index}`); const key = options?.getRemountKey ? options.getRemountKey(resolvedId, fallbackKey) : fallbackKey; // iteration이 있는 경우 반복 렌더링 // if 조건은 renderWithIteration 내부에서 각 아이템 컨텍스트로 평가됨 if (childDef.iteration) { return renderWithIteration(childDef, key, effectiveContext); } // if/condition/conditions 조건 평가 - 조건이 false이면 렌더링하지 않음 // iteration이 없는 경우에만 여기서 평가 const shouldRender = evaluateRenderCondition( { if: childDef.if, condition: childDef.condition, conditions: childDef.conditions }, effectiveContext, bindingEngine, childDef.id ); // DevTools: if 조건 추적 (일반 컴포넌트) // TODO: conditions 속성은 DevTools Phase 5에서 별도 추적 구현 예정 const devTools = getDevTools(); if (childDef.if && devTools?.isEnabled()) { const conditionId = `${key}-if`; devTools.trackIfCondition(conditionId, childDef.if, shouldRender, childDef.name); } if (!shouldRender) { return []; } // 컴포넌트 조회 const Component = componentMap[childDef.name]; if (!Component) { logger.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${childDef.name}`); return []; } // props 해석 const resolvedProps = resolveProps(childDef.props, effectiveContext); // actions 바인딩 (DynamicRenderer와 동일한 패턴) const finalProps = bindComponentActions(resolvedProps, childDef.actions, effectiveContext, { actionDispatcher: options?.actionDispatcher, componentContext: options?.componentContext, }); // text 처리 let childrenNodes: React.ReactNode = null; if (childDef.text !== undefined) { childrenNodes = resolveValue(childDef.text, effectiveContext); } else if (childDef.children && childDef.children.length > 0) { // 재귀적으로 자식 렌더링 childrenNodes = renderItemChildren( childDef.children as ComponentDefinition[], effectiveContext, componentMap, key, options ); } // DevTools: 컴포넌트 렌더링 추적 (일반 컴포넌트) if (devTools?.isEnabled()) { devTools.trackRender(childDef.name); } return [React.createElement(Component, { key, ...finalProps }, childrenNodes)]; }); } /** * 문자열 내의 모든 바인딩 표현식을 컨텍스트 데이터로 치환 * * 단순 변수({{route.id}})뿐 아니라 복잡한 표현식({{route?.id}}, {{a ? b : c}})도 지원합니다. * 코드 중복을 피하기 위해 DataBindingEngine의 기능을 재사용합니다. * * @param template 바인딩 표현식이 포함된 문자열 (예: "/api/users/{{route?.id}}") * @param context 데이터 컨텍스트 (예: { route: { id: "123" } }) * @param bindingEngine DataBindingEngine 인스턴스 (선택, 미제공 시 공유 인스턴스 사용) * @returns 치환된 문자열 (예: "/api/users/123") * * @example * ```typescript * // 단순 변수 치환 * resolveExpressionString("/api/users/{{route.id}}", { route: { id: "123" } }); * // 결과: "/api/users/123" * * // Optional chaining 지원 * resolveExpressionString("/api/users/{{route?.id}}", { route: { id: "456" } }); * // 결과: "/api/users/456" * * // 복잡한 표현식 지원 * resolveExpressionString("{{isEdit ? 'update' : 'create'}}", { isEdit: true }); * // 결과: "update" * ``` */ /** * 표현식 해석 옵션 */ export interface ResolveExpressionOptions { /** * 캐시 사용 안 함 (기본값: false) * true로 설정하면 매 호출마다 새로운 컨텍스트로 평가 */ skipCache?: boolean; } export function resolveExpressionString( template: string, context: BindingContext, bindingEngineOrOptions?: DataBindingEngine | ResolveExpressionOptions, options?: ResolveExpressionOptions ): string { // 파라미터 오버로드 처리: (template, context, options) 또는 (template, context, bindingEngine, options) let engine: DataBindingEngine; let opts: ResolveExpressionOptions | undefined; if (bindingEngineOrOptions instanceof DataBindingEngine) { engine = bindingEngineOrOptions; opts = options; } else { engine = sharedBindingEngine; opts = bindingEngineOrOptions; } return engine.resolveBindings(template, context, opts); } /** * 객체 내의 모든 문자열 값에서 바인딩 표현식을 치환 * * 중첩된 객체와 배열도 재귀적으로 처리합니다. * * @param obj 바인딩 표현식이 포함된 객체 * @param context 데이터 컨텍스트 * @param bindingEngine DataBindingEngine 인스턴스 (선택) * @returns 치환된 객체 * * @example * ```typescript * resolveExpressionObject( * { endpoint: "/api/users/{{route?.id}}", params: { status: "{{filter}}" } }, * { route: { id: "123" }, filter: "active" } * ); * // 결과: { endpoint: "/api/users/123", params: { status: "active" } } * ``` */ export function resolveExpressionObject>( obj: T, context: BindingContext, bindingEngine?: DataBindingEngine ): T { const engine = bindingEngine ?? sharedBindingEngine; return engine.resolveObject(obj, context) as T; } /** * classMap 정의 인터페이스 */ export interface ClassMapDefinition { /** 항상 적용되는 기본 클래스 */ base?: string; /** 키 값에 따른 클래스 변형 */ variants: Record; /** 변형을 결정하는 키 표현식 (예: "{{row.status}}") */ key: string; /** 매칭되는 변형이 없을 때 사용할 기본 클래스 */ default?: string; } /** * resolveClassMap 옵션 */ export interface ResolveClassMapOptions { /** * 캐시 사용 안 함 (기본값: true) * * iteration/cellChildren 컨텍스트에서 사용될 수 있으므로 기본적으로 캐시 비활성화 */ skipCache?: boolean; } /** * classMap 정의를 해석하여 CSS 클래스 문자열로 변환 * * 동적 값에 따라 다른 CSS 클래스를 적용합니다. * 중첩 삼항 연산자를 사용하는 복잡한 className 표현식을 대체합니다. * * 트러블슈팅 주의사항: * - iteration 컨텍스트에서 key 평가 시 skipCache: true 적용 (각 row별 다른 값) * - variants 객체 자체는 정적이므로 캐싱 가능 * * @param classMap classMap 정의 객체 * @param context 데이터 컨텍스트 * @param bindingEngine DataBindingEngine 인스턴스 (선택, 미제공 시 공유 인스턴스 사용) * @param options 해석 옵션 * @returns 최종 CSS 클래스 문자열 * * @example * ```typescript * const classMap = { * base: "px-2 py-1 rounded-full text-xs font-medium", * variants: { * success: "bg-green-100 text-green-800", * danger: "bg-red-100 text-red-800", * warning: "bg-yellow-100 text-yellow-800" * }, * key: "{{row.status}}", * default: "bg-gray-100 text-gray-600" * }; * * const context = { row: { status: "success" } }; * resolveClassMap(classMap, context); * // 결과: "px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800" * ``` */ export function resolveClassMap( classMap: ClassMapDefinition, context: BindingContext, bindingEngine?: DataBindingEngine, options?: ResolveClassMapOptions ): string { const engine = bindingEngine ?? sharedBindingEngine; const opts = { skipCache: true, ...options }; // 기본적으로 캐시 비활성화 // 1. key 표현식 평가 (항상 skipCache) let keyValue: string; try { const resolved = engine.resolveBindings(classMap.key, context, { skipCache: opts.skipCache }); // 빈 문자열이거나 undefined/null인 경우 처리 keyValue = resolved?.toString()?.trim() ?? ''; } catch (error) { logger.warn('resolveClassMap: key 평가 실패:', classMap.key, error); keyValue = ''; } // 2. variants에서 매칭되는 클래스 찾기 let variantClass = ''; if (keyValue && classMap.variants && keyValue in classMap.variants) { variantClass = classMap.variants[keyValue]; } else if (classMap.default) { // 매칭되는 variant가 없으면 default 사용 variantClass = classMap.default; } // 3. base와 variant 클래스 결합 const classes: string[] = []; if (classMap.base?.trim()) { classes.push(classMap.base.trim()); } if (variantClass?.trim()) { classes.push(variantClass.trim()); } return classes.join(' '); }