feat(pop): 컴포넌트 연결 시스템 구현 - 디자이너 설정 기반 검색->리스트 필터링

ConnectionEditor(연결 탭 UI) + useConnectionResolver(런타임 이벤트 라우터)를 추가하여
디자이너가 코드 없이 컴포넌트 간 데이터 흐름을 설정할 수 있도록 구현.
pop-search -> pop-string-list 실시간 필터링(시나리오 2) 검증 완료.

주요 변경:
- ConnectionEditor: 연결 추가/수정/삭제, 복수 컬럼 체크박스, 필터 모드 선택
- useConnectionResolver: connections 기반 __comp_output__/__comp_input__ 자동 라우팅
- connectionMeta 타입 + pop-search/pop-string-list에 sendable/receivable 등록
- PopDataConnection 확장 (sourceOutput, targetInput, filterConfig, targetColumns)
- pop-search 개선: 필드명 자동화, set_value receivable, number 타입, DRY
- pop-string-list: 복수 컬럼 OR 클라이언트 필터 수신
- "데이터" 탭 -> "연결" 탭, UI 용어 자연어화

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim
2026-02-23 18:45:21 +09:00
parent 52b217c180
commit 9ccd94d927
15 changed files with 903 additions and 83 deletions

View File

@@ -0,0 +1,68 @@
/**
* useConnectionResolver - 런타임 컴포넌트 연결 해석기
*
* PopViewerWithModals에서 사용.
* layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를
* 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다.
*
* 이벤트 규칙:
* 소스: __comp_output__${sourceComponentId}__${outputKey}
* 타겟: __comp_input__${targetComponentId}__${inputKey}
*/
import { useEffect, useRef } from "react";
import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
interface UseConnectionResolverOptions {
screenId: string;
connections: PopDataConnection[];
}
export function useConnectionResolver({
screenId,
connections,
}: UseConnectionResolverOptions): void {
const { publish, subscribe } = usePopEvent(screenId);
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
const connectionsRef = useRef(connections);
connectionsRef.current = connections;
useEffect(() => {
if (!connections || connections.length === 0) return;
const unsubscribers: (() => void)[] = [];
// 소스별로 그룹핑하여 구독 생성
const sourceGroups = new Map<string, PopDataConnection[]>();
for (const conn of connections) {
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
const existing = sourceGroups.get(sourceEvent) || [];
existing.push(conn);
sourceGroups.set(sourceEvent, existing);
}
for (const [sourceEvent, conns] of sourceGroups) {
const unsub = subscribe(sourceEvent, (payload: unknown) => {
for (const conn of conns) {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
// filterConfig가 있으면 payload에 첨부
const enrichedPayload = conn.filterConfig
? { value: payload, filterConfig: conn.filterConfig }
: payload;
publish(targetEvent, enrichedPayload);
}
});
unsubscribers.push(unsub);
}
return () => {
for (const unsub of unsubscribers) {
unsub();
}
};
}, [screenId, connections, subscribe, publish]);
}