From 9ccd94d927ae163d94372f554ced9134a9a608ba Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 23 Feb 2026 18:45:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B0=98=20=EA=B2=80=EC=83=89?= =?UTF-8?q?->=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/pop/designer/PopDesigner.tsx | 70 +++ .../designer/panels/ComponentEditorPanel.tsx | 52 +- .../pop/designer/panels/ConnectionEditor.tsx | 541 ++++++++++++++++++ .../pop/designer/renderers/PopRenderer.tsx | 2 +- .../pop/designer/types/pop-layout.ts | 10 + .../pop/viewer/PopViewerWithModals.tsx | 7 + frontend/hooks/pop/index.ts | 3 + frontend/hooks/pop/useConnectionResolver.ts | 68 +++ frontend/lib/registry/PopComponentRegistry.ts | 19 + .../pop-search/PopSearchComponent.tsx | 59 +- .../pop-search/PopSearchConfig.tsx | 16 - .../pop-components/pop-search/index.tsx | 26 +- .../pop-components/pop-search/types.ts | 12 + .../PopStringListComponent.tsx | 93 ++- .../pop-components/pop-string-list/index.tsx | 8 + 15 files changed, 903 insertions(+), 83 deletions(-) create mode 100644 frontend/components/pop/designer/panels/ConnectionEditor.tsx create mode 100644 frontend/hooks/pop/useConnectionResolver.ts diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index e3623f1b..c9456e3c 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -31,6 +31,7 @@ import { createComponentDefinitionV5, GRID_BREAKPOINTS, PopModalDefinition, + PopDataConnection, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; @@ -291,6 +292,71 @@ export default function PopDesigner({ [saveToHistory, activeCanvasId] ); + // ======================================== + // 연결 CRUD + // ======================================== + + const handleAddConnection = useCallback( + (conn: Omit) => { + setLayout((prev) => { + const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const newConnection: PopDataConnection = { ...conn, id: newId }; + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: [...prevConnections, newConnection], + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleUpdateConnection = useCallback( + (connectionId: string, conn: Omit) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.map((c) => + c.id === connectionId ? { ...conn, id: connectionId } : c + ), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleRemoveConnection = useCallback( + (connectionId: string) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.filter((c) => c.id !== connectionId), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + const handleDeleteComponent = useCallback( (componentId: string) => { setLayout(prev => { @@ -788,6 +854,10 @@ export default function PopDesigner({ selectedComponentId={selectedComponentId} previewPageIndex={previewPageIndex} onPreviewPage={setPreviewPageIndex} + connections={layout.dataFlow?.connections || []} + onAddConnection={handleAddConnection} + onUpdateConnection={handleUpdateConnection} + onRemoveConnection={handleRemoveConnection} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index a7cde997..9ab3deff 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -10,7 +10,7 @@ import { } from "../types/pop-layout"; import { Settings, - Database, + Link2, Eye, Grid3x3, MoveHorizontal, @@ -22,6 +22,8 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { PopDataConnection } from "../types/pop-layout"; +import ConnectionEditor from "./ConnectionEditor"; // ======================================== // Props @@ -46,6 +48,14 @@ interface ComponentEditorPanelProps { previewPageIndex?: number; /** 페이지 미리보기 요청 콜백 */ onPreviewPage?: (pageIndex: number) => void; + /** 데이터 흐름 연결 목록 */ + connections?: PopDataConnection[]; + /** 연결 추가 콜백 */ + onAddConnection?: (conn: Omit) => void; + /** 연결 수정 콜백 */ + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + /** 연결 삭제 콜백 */ + onRemoveConnection?: (connectionId: string) => void; } // ======================================== @@ -83,6 +93,10 @@ export default function ComponentEditorPanel({ selectedComponentId, previewPageIndex, onPreviewPage, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -133,9 +147,9 @@ export default function ComponentEditorPanel({ 표시 - - - 데이터 + + + 연결 @@ -205,9 +219,16 @@ export default function ComponentEditorPanel({ /> - {/* 데이터 탭 */} - - + {/* 연결 탭 */} + + @@ -484,20 +505,3 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { ); } -// ======================================== -// 데이터 바인딩 플레이스홀더 -// ======================================== - -function DataBindingPlaceholder() { - return ( -
-
- -

데이터 바인딩

-

- Phase 4에서 구현 예정 -

-
-
- ); -} diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx new file mode 100644 index 00000000..90afa939 --- /dev/null +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -0,0 +1,541 @@ +"use client"; + +import React from "react"; +import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + PopComponentDefinitionV5, + PopDataConnection, +} from "../types/pop-layout"; +import { + PopComponentRegistry, + type ComponentConnectionMeta, +} from "@/lib/registry/PopComponentRegistry"; + +// ======================================== +// Props +// ======================================== + +interface ConnectionEditorProps { + component: PopComponentDefinitionV5; + allComponents: PopComponentDefinitionV5[]; + connections: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +// ======================================== +// ConnectionEditor +// ======================================== + +export default function ConnectionEditor({ + component, + allComponents, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: ConnectionEditorProps) { + const registeredComp = PopComponentRegistry.getComponent(component.type); + const meta = registeredComp?.connectionMeta; + + const outgoing = connections.filter( + (c) => c.sourceComponent === component.id + ); + const incoming = connections.filter( + (c) => c.targetComponent === component.id + ); + + const hasSendable = meta?.sendable && meta.sendable.length > 0; + const hasReceivable = meta?.receivable && meta.receivable.length > 0; + + if (!hasSendable && !hasReceivable) { + return ( +
+
+ +

연결 없음

+

+ 이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다 +

+
+
+ ); + } + + return ( +
+ {hasSendable && ( + + )} + + {hasReceivable && ( + + )} +
+ ); +} + +// ======================================== +// 대상 컴포넌트의 컬럼 목록 추출 +// ======================================== + +function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): string[] { + if (!comp?.config) return []; + const cfg = comp.config as Record; + const cols: string[] = []; + + if (Array.isArray(cfg.listColumns)) { + (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => { + if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName); + }); + } + + if (Array.isArray(cfg.selectedColumns)) { + (cfg.selectedColumns as string[]).forEach((c) => { + if (!cols.includes(c)) cols.push(c); + }); + } + + return cols; +} + +// ======================================== +// 보내기 섹션 +// ======================================== + +interface SendSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + outgoing: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +function SendSection({ + component, + meta, + allComponents, + outgoing, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: SendSectionProps) { + const [editingId, setEditingId] = React.useState(null); + + return ( +
+ + + {/* 기존 연결 목록 */} + {outgoing.map((conn) => ( +
+ {editingId === conn.id ? ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) : ( +
+ + {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} + + + {onRemoveConnection && ( + + )} +
+ )} +
+ ))} + + {/* 새 연결 추가 */} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> +
+ ); +} + +// ======================================== +// 연결 폼 (추가/수정 공용) +// ======================================== + +interface ConnectionFormProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + initial?: PopDataConnection; + onSubmit: (data: Omit) => void; + onCancel?: () => void; + submitLabel: string; +} + +function ConnectionForm({ + component, + meta, + allComponents, + initial, + onSubmit, + onCancel, + submitLabel, +}: ConnectionFormProps) { + const [selectedOutput, setSelectedOutput] = React.useState( + initial?.sourceOutput || meta.sendable[0]?.key || "" + ); + const [selectedTargetId, setSelectedTargetId] = React.useState( + initial?.targetComponent || "" + ); + const [selectedTargetInput, setSelectedTargetInput] = React.useState( + initial?.targetInput || "" + ); + const [filterColumns, setFilterColumns] = React.useState( + initial?.filterConfig?.targetColumns || + (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) + ); + const [filterMode, setFilterMode] = React.useState< + "equals" | "contains" | "starts_with" | "range" + >(initial?.filterConfig?.filterMode || "contains"); + + const targetCandidates = allComponents.filter((c) => { + if (c.id === component.id) return false; + const reg = PopComponentRegistry.getComponent(c.type); + return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; + }); + + const targetComp = selectedTargetId + ? allComponents.find((c) => c.id === selectedTargetId) + : null; + + const targetMeta = targetComp + ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta + : null; + + const targetColumns = React.useMemo( + () => extractTargetColumns(targetComp || undefined), + [targetComp] + ); + + const toggleColumn = (col: string) => { + setFilterColumns((prev) => + prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] + ); + }; + + const handleSubmit = () => { + if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; + + onSubmit({ + sourceComponent: component.id, + sourceField: "", + sourceOutput: selectedOutput, + targetComponent: selectedTargetId, + targetField: "", + targetInput: selectedTargetInput, + filterConfig: + filterColumns.length > 0 + ? { + targetColumn: filterColumns[0], + targetColumns: filterColumns, + filterMode, + } + : undefined, + label: buildConnectionLabel( + component, + selectedOutput, + allComponents.find((c) => c.id === selectedTargetId), + selectedTargetInput, + filterColumns + ), + }); + + if (!initial) { + setSelectedTargetId(""); + setSelectedTargetInput(""); + setFilterColumns([]); + } + }; + + return ( +
+ {onCancel && ( +
+

연결 수정

+ +
+ )} + {!onCancel && ( +

새 연결 추가

+ )} + + {/* 보내는 값 */} +
+ 보내는 값 + +
+ + {/* 받는 컴포넌트 */} +
+ 받는 컴포넌트 + +
+ + {/* 받는 방식 */} + {targetMeta && ( +
+ 받는 방식 + +
+ )} + + {/* 필터 설정 */} + {selectedTargetInput && ( +
+ {/* 컬럼 선택 (복수) */} +

필터할 컬럼

+ {targetColumns.length > 0 ? ( +
+ {targetColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ ) : ( + setFilterColumns(e.target.value ? [e.target.value] : [])} + placeholder="컬럼명 입력" + className="h-7 text-xs" + /> + )} + + {filterColumns.length > 0 && ( +

+ {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 +

+ )} + + {/* 필터 방식 */} +
+

필터 방식

+ +
+
+ )} + + {/* 제출 버튼 */} + +
+ ); +} + +// ======================================== +// 받기 섹션 (읽기 전용) +// ======================================== + +interface ReceiveSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + incoming: PopDataConnection[]; +} + +function ReceiveSection({ + component, + meta, + allComponents, + incoming, +}: ReceiveSectionProps) { + return ( +
+ + +
+ {meta.receivable.map((r) => ( +
+ {r.label} + {r.description && ( +

+ {r.description} +

+ )} +
+ ))} +
+ + {incoming.length > 0 ? ( +
+

연결된 소스

+ {incoming.map((conn) => { + const sourceComp = allComponents.find( + (c) => c.id === conn.sourceComponent + ); + return ( +
+ + + {sourceComp?.label || conn.sourceComponent} + +
+ ); + })} +
+ ) : ( +

+ 아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요. +

+ )} +
+ ); +} + +// ======================================== +// 유틸 +// ======================================== + +function buildConnectionLabel( + source: PopComponentDefinitionV5, + _outputKey: string, + target: PopComponentDefinitionV5 | undefined, + _inputKey: string, + columns?: string[] +): string { + const srcLabel = source.label || source.id; + const tgtLabel = target?.label || target?.id || "?"; + const colInfo = columns && columns.length > 0 + ? ` [${columns.join(", ")}]` + : ""; + return `${srcLabel} -> ${tgtLabel}${colInfo}`; +} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 968d2534..433ac8aa 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -591,7 +591,7 @@ function renderActualComponent(component: PopComponentDefinitionV5, screenId?: s if (ActualComp) { return (
- +
); } diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index c17cecd6..1d862438 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -25,6 +25,16 @@ export interface PopDataConnection { targetComponent: string; targetField: string; transformType?: "direct" | "calculate" | "lookup"; + + // v2: 연결 시스템 전용 + sourceOutput?: string; + targetInput?: string; + filterConfig?: { + targetColumn: string; + targetColumns?: string[]; + filterMode: "equals" | "contains" | "starts_with" | "range"; + }; + label?: string; } /** diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index 7e75672d..55d87b22 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -22,6 +22,7 @@ import PopRenderer from "../designer/renderers/PopRenderer"; import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; // ======================================== // 타입 @@ -62,6 +63,12 @@ export default function PopViewerWithModals({ const [modalStack, setModalStack] = useState([]); const { subscribe } = usePopEvent(screenId); + // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 + useConnectionResolver({ + screenId, + connections: layout.dataFlow?.connections || [], + }); + // 모달 열기 이벤트 구독 useEffect(() => { const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => { diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index c43d5c0b..3a6c792b 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -19,5 +19,8 @@ export type { ActionResult } from "./executePopAction"; export { usePopAction } from "./usePopAction"; export type { PendingConfirmState } from "./usePopAction"; +// 연결 해석기 +export { useConnectionResolver } from "./useConnectionResolver"; + // SQL 빌더 유틸 (고급 사용 시) export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts new file mode 100644 index 00000000..f590c4f5 --- /dev/null +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -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(); + 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]); +} diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 31f9a4e1..0d7df5ec 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -2,6 +2,24 @@ import React from "react"; +/** + * 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯 + */ +export interface ConnectionMetaItem { + key: string; + label: string; + type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string; + description?: string; +} + +/** + * 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의 + */ +export interface ComponentConnectionMeta { + sendable: ConnectionMetaItem[]; + receivable: ConnectionMetaItem[]; +} + /** * POP 컴포넌트 정의 인터페이스 */ @@ -15,6 +33,7 @@ export interface PopComponentDefinition { configPanel?: React.ComponentType; preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; + connectionMeta?: ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 776cb557..a00e0dea 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -18,7 +18,7 @@ import type { PopSearchConfig, DatePresetOption, } from "./types"; -import { DATE_PRESET_LABELS, computeDateRange } from "./types"; +import { DATE_PRESET_LABELS, computeDateRange, DEFAULT_SEARCH_CONFIG } from "./types"; // ======================================== // 메인 컴포넌트 @@ -28,38 +28,58 @@ interface PopSearchComponentProps { config: PopSearchConfig; label?: string; screenId?: string; + componentId?: string; } -const DEFAULT_CONFIG: PopSearchConfig = { - inputType: "text", - fieldName: "", - placeholder: "검색어 입력", - debounceMs: 500, - triggerOnEnter: true, - labelPosition: "top", - labelText: "", - labelVisible: true, -}; +const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG; export function PopSearchComponent({ config: rawConfig, label, screenId, + componentId, }: PopSearchComponentProps) { const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) }; - const { publish, setSharedData } = usePopEvent(screenId || ""); + const { publish, subscribe, setSharedData } = usePopEvent(screenId || ""); const [value, setValue] = useState(config.defaultValue ?? ""); + const fieldKey = config.fieldName || componentId || "search"; + const emitFilterChanged = useCallback( (newValue: unknown) => { - if (!config.fieldName) return; setValue(newValue); - setSharedData(`search_${config.fieldName}`, newValue); - publish("filter_changed", { [config.fieldName]: newValue }); + setSharedData(`search_${fieldKey}`, newValue); + + // 표준 출력 이벤트 (연결 시스템용) + if (componentId) { + publish(`__comp_output__${componentId}__filter_value`, { + fieldName: fieldKey, + value: newValue, + }); + } + + // 레거시 호환 + publish("filter_changed", { [fieldKey]: newValue }); }, - [config.fieldName, publish, setSharedData] + [fieldKey, publish, setSharedData, componentId] ); + // 외부 값 수신 (스캔 결과, 모달 선택 등) + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const incoming = typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + emitFilterChanged(incoming); + } + ); + return unsub; + }, [componentId, subscribe, emitFilterChanged]); + const showLabel = config.labelVisible !== false && !!config.labelText; return ( @@ -100,6 +120,7 @@ interface InputRendererProps { function SearchInputRenderer({ config, value, onChange }: InputRendererProps) { switch (config.inputType) { case "text": + case "number": return ( diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 5b66ef57..af62afe4 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -148,22 +148,6 @@ function StepBasicSettings({ cfg, update }: StepProps) { - {/* 필드명 */} -
- - update({ fieldName: e.target.value })} - placeholder="예: supplier_code" - className="h-8 text-xs" - /> -

- filter_changed 이벤트에서 이 이름으로 값이 전달됩니다 -

-
- {/* 플레이스홀더 */}
diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index 3d5bbc56..87069f38 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -4,21 +4,11 @@ import { PopComponentRegistry } from "../../PopComponentRegistry"; import { PopSearchComponent } from "./PopSearchComponent"; import { PopSearchConfigPanel } from "./PopSearchConfig"; import type { PopSearchConfig } from "./types"; - -const defaultConfig: PopSearchConfig = { - inputType: "text", - fieldName: "", - placeholder: "검색어 입력", - debounceMs: 500, - triggerOnEnter: true, - labelPosition: "top", - labelText: "", - labelVisible: true, -}; +import { DEFAULT_SEARCH_CONFIG } from "./types"; function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) { - const cfg = config || defaultConfig; - const displayLabel = label || cfg.fieldName || "검색"; + const cfg = config || DEFAULT_SEARCH_CONFIG; + const displayLabel = cfg.labelText || label || cfg.fieldName || "검색"; return (
@@ -43,7 +33,15 @@ PopComponentRegistry.registerComponent({ component: PopSearchComponent, configPanel: PopSearchConfigPanel, preview: PopSearchPreviewComponent, - defaultProps: defaultConfig, + defaultProps: DEFAULT_SEARCH_CONFIG, + connectionMeta: { + sendable: [ + { key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, + ], + receivable: [ + { key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], }); diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 7eaf06ec..fdb43cac 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -70,6 +70,18 @@ export interface PopSearchConfig { labelPosition?: "top" | "left"; } +/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ +export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { + inputType: "text", + fieldName: "", + placeholder: "검색어 입력", + debounceMs: 500, + triggerOnEnter: true, + labelPosition: "top", + labelText: "", + labelVisible: true, +}; + /** 날짜 프리셋 라벨 매핑 */ export const DATE_PRESET_LABELS: Record = { today: "오늘", diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 2270e12f..7bf23365 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -47,6 +47,7 @@ interface PopStringListComponentProps { config?: PopStringListConfig; className?: string; screenId?: string; + componentId?: string; } // 테이블 행 데이터 타입 @@ -58,6 +59,7 @@ export function PopStringListComponent({ config, className, screenId, + componentId, }: PopStringListComponentProps) { const displayMode = config?.displayMode || "list"; const header = config?.header; @@ -78,8 +80,37 @@ export function PopStringListComponent({ // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) const [loadingRowIdx, setLoadingRowIdx] = useState(-1); - // 이벤트 발행 (카드 버튼 액션에서 사용) - const { publish } = usePopEvent(screenId || ""); + // 이벤트 버스 + const { publish, subscribe } = usePopEvent(screenId || ""); + + // 외부 필터 조건 (연결 시스템에서 수신) + const [externalFilter, setExternalFilter] = useState<{ + fieldName: string; + value: unknown; + filterConfig?: { targetColumn: string; filterMode: string }; + } | null>(null); + + // 표준 입력 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__filter_condition`, + (payload: unknown) => { + const data = payload as { + value?: { fieldName?: string; value?: unknown }; + filterConfig?: { targetColumn: string; filterMode: string }; + }; + if (data?.value) { + setExternalFilter({ + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + }); + } + } + ); + return unsub; + }, [componentId, subscribe]); // 카드 버튼 클릭 핸들러 const handleCardButtonClick = useCallback( @@ -124,32 +155,72 @@ export function PopStringListComponent({ const pageSize = Number(overflow?.pageSize) || visibleRows; const paginationStyle = overflow?.paginationStyle || "bottom"; + // --- 외부 필터 적용 --- + const filteredRows = useMemo(() => { + if (!externalFilter || !externalFilter.value) return rows; + + const searchValue = String(externalFilter.value).toLowerCase(); + if (!searchValue) return rows; + + // 복수 컬럼 지원: targetColumns > targetColumn > fieldName + const fc = externalFilter.filterConfig; + const columns: string[] = + (fc as any)?.targetColumns?.length > 0 + ? (fc as any).targetColumns + : fc?.targetColumn + ? [fc.targetColumn] + : externalFilter.fieldName + ? [externalFilter.fieldName] + : []; + + if (columns.length === 0) return rows; + + const mode = fc?.filterMode || "contains"; + + const matchCell = (cellValue: string) => { + switch (mode) { + case "equals": + return cellValue === searchValue; + case "starts_with": + return cellValue.startsWith(searchValue); + case "contains": + default: + return cellValue.includes(searchValue); + } + }; + + // 하나라도 일치하면 표시 + return rows.filter((row) => + columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())) + ); + }, [rows, externalFilter]); + // --- 더보기 모드 --- useEffect(() => { setDisplayCount(visibleRows); }, [visibleRows]); - const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, rows.length); - const hasMore = showExpandButton && rows.length > effectiveLimit && effectiveLimit < maxExpandRows; + const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length); + const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows; const isExpanded = effectiveLimit > visibleRows; const handleLoadMore = useCallback(() => { setDisplayCount((prev) => { const current = prev || visibleRows; - return Math.min(current + loadMoreCount, maxExpandRows, rows.length); + return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length); }); - }, [visibleRows, loadMoreCount, maxExpandRows, rows.length]); + }, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]); const handleCollapse = useCallback(() => { setDisplayCount(visibleRows); }, [visibleRows]); // --- 페이지네이션 모드 --- - const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); useEffect(() => { setCurrentPage(1); - }, [pageSize, rows.length]); + }, [pageSize, filteredRows.length]); const handlePageChange = useCallback((page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); @@ -159,10 +230,10 @@ export function PopStringListComponent({ const visibleData = useMemo(() => { if (overflowMode === "pagination") { const start = (currentPage - 1) * pageSize; - return rows.slice(start, start + pageSize); + return filteredRows.slice(start, start + pageSize); } - return rows.slice(0, effectiveLimit); - }, [overflowMode, rows, currentPage, pageSize, effectiveLimit]); + return filteredRows.slice(0, effectiveLimit); + }, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]); // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용) const dsTableName = dataSource?.tableName; diff --git a/frontend/lib/registry/pop-components/pop-string-list/index.tsx b/frontend/lib/registry/pop-components/pop-string-list/index.tsx index 86fa156d..4bf6c638 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/index.tsx @@ -33,6 +33,14 @@ PopComponentRegistry.registerComponent({ configPanel: PopStringListConfigPanel, preview: PopStringListPreviewComponent, defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], });