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:
parent
52b217c180
commit
9ccd94d927
|
|
@ -31,6 +31,7 @@ import {
|
||||||
createComponentDefinitionV5,
|
createComponentDefinitionV5,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
PopModalDefinition,
|
PopModalDefinition,
|
||||||
|
PopDataConnection,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
@ -291,6 +292,71 @@ export default function PopDesigner({
|
||||||
[saveToHistory, activeCanvasId]
|
[saveToHistory, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 연결 CRUD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const handleAddConnection = useCallback(
|
||||||
|
(conn: Omit<PopDataConnection, "id">) => {
|
||||||
|
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<PopDataConnection, "id">) => {
|
||||||
|
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(
|
const handleDeleteComponent = useCallback(
|
||||||
(componentId: string) => {
|
(componentId: string) => {
|
||||||
setLayout(prev => {
|
setLayout(prev => {
|
||||||
|
|
@ -788,6 +854,10 @@ export default function PopDesigner({
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
onPreviewPage={setPreviewPageIndex}
|
onPreviewPage={setPreviewPageIndex}
|
||||||
|
connections={layout.dataFlow?.connections || []}
|
||||||
|
onAddConnection={handleAddConnection}
|
||||||
|
onUpdateConnection={handleUpdateConnection}
|
||||||
|
onRemoveConnection={handleRemoveConnection}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Database,
|
Link2,
|
||||||
Eye,
|
Eye,
|
||||||
Grid3x3,
|
Grid3x3,
|
||||||
MoveHorizontal,
|
MoveHorizontal,
|
||||||
|
|
@ -22,6 +22,8 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { PopDataConnection } from "../types/pop-layout";
|
||||||
|
import ConnectionEditor from "./ConnectionEditor";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -46,6 +48,14 @@ interface ComponentEditorPanelProps {
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
/** 페이지 미리보기 요청 콜백 */
|
/** 페이지 미리보기 요청 콜백 */
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
|
/** 데이터 흐름 연결 목록 */
|
||||||
|
connections?: PopDataConnection[];
|
||||||
|
/** 연결 추가 콜백 */
|
||||||
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
/** 연결 수정 콜백 */
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
/** 연결 삭제 콜백 */
|
||||||
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -83,6 +93,10 @@ export default function ComponentEditorPanel({
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
previewPageIndex,
|
previewPageIndex,
|
||||||
onPreviewPage,
|
onPreviewPage,
|
||||||
|
connections,
|
||||||
|
onAddConnection,
|
||||||
|
onUpdateConnection,
|
||||||
|
onRemoveConnection,
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
|
@ -133,9 +147,9 @@ export default function ComponentEditorPanel({
|
||||||
<Eye className="h-3 w-3" />
|
<Eye className="h-3 w-3" />
|
||||||
표시
|
표시
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
<TabsTrigger value="connection" className="gap-1 text-xs">
|
||||||
<Database className="h-3 w-3" />
|
<Link2 className="h-3 w-3" />
|
||||||
데이터
|
연결
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -205,9 +219,16 @@ export default function ComponentEditorPanel({
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 탭 */}
|
{/* 연결 탭 */}
|
||||||
<TabsContent value="data" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
<TabsContent value="connection" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||||
<DataBindingPlaceholder />
|
<ConnectionEditor
|
||||||
|
component={component}
|
||||||
|
allComponents={allComponents || []}
|
||||||
|
connections={connections || []}
|
||||||
|
onAddConnection={onAddConnection}
|
||||||
|
onUpdateConnection={onUpdateConnection}
|
||||||
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -484,20 +505,3 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 데이터 바인딩 플레이스홀더
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function DataBindingPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
|
||||||
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
|
||||||
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Phase 4에서 구현 예정
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<PopDataConnection, "id">) => void;
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||||
|
<Link2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-gray-700">연결 없음</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{hasSendable && (
|
||||||
|
<SendSection
|
||||||
|
component={component}
|
||||||
|
meta={meta!}
|
||||||
|
allComponents={allComponents}
|
||||||
|
outgoing={outgoing}
|
||||||
|
onAddConnection={onAddConnection}
|
||||||
|
onUpdateConnection={onUpdateConnection}
|
||||||
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasReceivable && (
|
||||||
|
<ReceiveSection
|
||||||
|
component={component}
|
||||||
|
meta={meta!}
|
||||||
|
allComponents={allComponents}
|
||||||
|
incoming={incoming}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 대상 컴포넌트의 컬럼 목록 추출
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||||
|
if (!comp?.config) return [];
|
||||||
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
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<PopDataConnection, "id">) => void;
|
||||||
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendSection({
|
||||||
|
component,
|
||||||
|
meta,
|
||||||
|
allComponents,
|
||||||
|
outgoing,
|
||||||
|
onAddConnection,
|
||||||
|
onUpdateConnection,
|
||||||
|
onRemoveConnection,
|
||||||
|
}: SendSectionProps) {
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||||
|
이때 (보내기)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 연결 목록 */}
|
||||||
|
{outgoing.map((conn) => (
|
||||||
|
<div key={conn.id}>
|
||||||
|
{editingId === conn.id ? (
|
||||||
|
<ConnectionForm
|
||||||
|
component={component}
|
||||||
|
meta={meta}
|
||||||
|
allComponents={allComponents}
|
||||||
|
initial={conn}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
onUpdateConnection?.(conn.id, data);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
submitLabel="수정"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||||
|
<span className="flex-1 truncate text-xs">
|
||||||
|
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(conn.id)}
|
||||||
|
className="shrink-0 p-0.5 text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{onRemoveConnection && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveConnection(conn.id)}
|
||||||
|
className="shrink-0 p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 새 연결 추가 */}
|
||||||
|
<ConnectionForm
|
||||||
|
component={component}
|
||||||
|
meta={meta}
|
||||||
|
allComponents={allComponents}
|
||||||
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
|
submitLabel="연결 추가"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 연결 폼 (추가/수정 공용)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ConnectionFormProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
meta: ComponentConnectionMeta;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
initial?: PopDataConnection;
|
||||||
|
onSubmit: (data: Omit<PopDataConnection, "id">) => 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<string[]>(
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2 rounded border border-dashed p-3">
|
||||||
|
{onCancel && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||||
|
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!onCancel && (
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 보내는 값 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||||
|
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{meta.sendable.map((s) => (
|
||||||
|
<SelectItem key={s.key} value={s.key} className="text-xs">
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 받는 컴포넌트 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||||
|
<Select
|
||||||
|
value={selectedTargetId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSelectedTargetId(v);
|
||||||
|
setSelectedTargetInput("");
|
||||||
|
setFilterColumns([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetCandidates.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||||
|
{c.label || c.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 받는 방식 */}
|
||||||
|
{targetMeta && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||||
|
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetMeta.receivable.map((r) => (
|
||||||
|
<SelectItem key={r.key} value={r.key} className="text-xs">
|
||||||
|
{r.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 설정 */}
|
||||||
|
{selectedTargetInput && (
|
||||||
|
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||||
|
{/* 컬럼 선택 (복수) */}
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||||
|
{targetColumns.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{targetColumns.map((col) => (
|
||||||
|
<div key={col} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
checked={filterColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleColumn(col)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
className="cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={filterColumns[0] || ""}
|
||||||
|
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
|
||||||
|
placeholder="컬럼명 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterColumns.length > 0 && (
|
||||||
|
<p className="text-[10px] text-blue-600">
|
||||||
|
{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 방식 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||||
|
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="contains" className="text-xs">포함</SelectItem>
|
||||||
|
<SelectItem value="equals" className="text-xs">일치</SelectItem>
|
||||||
|
<SelectItem value="starts_with" className="text-xs">시작</SelectItem>
|
||||||
|
<SelectItem value="range" className="text-xs">범위</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제출 버튼 */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 받기 섹션 (읽기 전용)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ReceiveSectionProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
meta: ComponentConnectionMeta;
|
||||||
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
incoming: PopDataConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiveSection({
|
||||||
|
component,
|
||||||
|
meta,
|
||||||
|
allComponents,
|
||||||
|
incoming,
|
||||||
|
}: ReceiveSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
|
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||||
|
이렇게 (받기)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{meta.receivable.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.key}
|
||||||
|
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{r.label}</span>
|
||||||
|
{r.description && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{r.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{incoming.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||||
|
{incoming.map((conn) => {
|
||||||
|
const sourceComp = allComponents.find(
|
||||||
|
(c) => c.id === conn.sourceComponent
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conn.id}
|
||||||
|
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{sourceComp?.label || conn.sourceComponent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 유틸
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
@ -591,7 +591,7 @@ function renderActualComponent(component: PopComponentDefinitionV5, screenId?: s
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-full">
|
<div className="w-full min-h-full">
|
||||||
<ActualComp config={component.config} label={component.label} screenId={screenId} />
|
<ActualComp config={component.config} label={component.label} screenId={screenId} componentId={component.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ export interface PopDataConnection {
|
||||||
targetComponent: string;
|
targetComponent: string;
|
||||||
targetField: string;
|
targetField: string;
|
||||||
transformType?: "direct" | "calculate" | "lookup";
|
transformType?: "direct" | "calculate" | "lookup";
|
||||||
|
|
||||||
|
// v2: 연결 시스템 전용
|
||||||
|
sourceOutput?: string;
|
||||||
|
targetInput?: string;
|
||||||
|
filterConfig?: {
|
||||||
|
targetColumn: string;
|
||||||
|
targetColumns?: string[];
|
||||||
|
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||||
|
};
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import PopRenderer from "../designer/renderers/PopRenderer";
|
||||||
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||||
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
||||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
|
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 타입
|
// 타입
|
||||||
|
|
@ -62,6 +63,12 @@ export default function PopViewerWithModals({
|
||||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||||
const { subscribe } = usePopEvent(screenId);
|
const { subscribe } = usePopEvent(screenId);
|
||||||
|
|
||||||
|
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||||
|
useConnectionResolver({
|
||||||
|
screenId,
|
||||||
|
connections: layout.dataFlow?.connections || [],
|
||||||
|
});
|
||||||
|
|
||||||
// 모달 열기 이벤트 구독
|
// 모달 열기 이벤트 구독
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,8 @@ export type { ActionResult } from "./executePopAction";
|
||||||
export { usePopAction } from "./usePopAction";
|
export { usePopAction } from "./usePopAction";
|
||||||
export type { PendingConfirmState } from "./usePopAction";
|
export type { PendingConfirmState } from "./usePopAction";
|
||||||
|
|
||||||
|
// 연결 해석기
|
||||||
|
export { useConnectionResolver } from "./useConnectionResolver";
|
||||||
|
|
||||||
// SQL 빌더 유틸 (고급 사용 시)
|
// SQL 빌더 유틸 (고급 사용 시)
|
||||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,24 @@
|
||||||
|
|
||||||
import React from "react";
|
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 컴포넌트 정의 인터페이스
|
* POP 컴포넌트 정의 인터페이스
|
||||||
*/
|
*/
|
||||||
|
|
@ -15,6 +33,7 @@ export interface PopComponentDefinition {
|
||||||
configPanel?: React.ComponentType<any>;
|
configPanel?: React.ComponentType<any>;
|
||||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||||
defaultProps?: Record<string, any>;
|
defaultProps?: Record<string, any>;
|
||||||
|
connectionMeta?: ComponentConnectionMeta;
|
||||||
// POP 전용 속성
|
// POP 전용 속성
|
||||||
touchOptimized?: boolean;
|
touchOptimized?: boolean;
|
||||||
minTouchArea?: number;
|
minTouchArea?: number;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
} from "./types";
|
} 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;
|
config: PopSearchConfig;
|
||||||
label?: string;
|
label?: string;
|
||||||
screenId?: string;
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PopSearchConfig = {
|
const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG;
|
||||||
inputType: "text",
|
|
||||||
fieldName: "",
|
|
||||||
placeholder: "검색어 입력",
|
|
||||||
debounceMs: 500,
|
|
||||||
triggerOnEnter: true,
|
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
|
||||||
labelVisible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PopSearchComponent({
|
export function PopSearchComponent({
|
||||||
config: rawConfig,
|
config: rawConfig,
|
||||||
label,
|
label,
|
||||||
screenId,
|
screenId,
|
||||||
|
componentId,
|
||||||
}: PopSearchComponentProps) {
|
}: PopSearchComponentProps) {
|
||||||
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
|
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
|
||||||
const { publish, setSharedData } = usePopEvent(screenId || "");
|
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
|
||||||
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
|
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
|
||||||
|
|
||||||
|
const fieldKey = config.fieldName || componentId || "search";
|
||||||
|
|
||||||
const emitFilterChanged = useCallback(
|
const emitFilterChanged = useCallback(
|
||||||
(newValue: unknown) => {
|
(newValue: unknown) => {
|
||||||
if (!config.fieldName) return;
|
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
setSharedData(`search_${config.fieldName}`, newValue);
|
setSharedData(`search_${fieldKey}`, newValue);
|
||||||
publish("filter_changed", { [config.fieldName]: 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;
|
const showLabel = config.labelVisible !== false && !!config.labelText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -100,6 +120,7 @@ interface InputRendererProps {
|
||||||
function SearchInputRenderer({ config, value, onChange }: InputRendererProps) {
|
function SearchInputRenderer({ config, value, onChange }: InputRendererProps) {
|
||||||
switch (config.inputType) {
|
switch (config.inputType) {
|
||||||
case "text":
|
case "text":
|
||||||
|
case "number":
|
||||||
return (
|
return (
|
||||||
<TextSearchInput
|
<TextSearchInput
|
||||||
config={config}
|
config={config}
|
||||||
|
|
@ -177,14 +198,18 @@ function TextSearchInput({ config, value, onChange }: TextInputProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNumber = config.inputType === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
inputMode={isNumber ? "numeric" : undefined}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={config.placeholder || "검색어 입력"}
|
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
||||||
className="h-8 pl-7 text-xs"
|
className="h-8 pl-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -148,22 +148,6 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드명 */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-[10px]">
|
|
||||||
필드명 <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={cfg.fieldName}
|
|
||||||
onChange={(e) => update({ fieldName: e.target.value })}
|
|
||||||
placeholder="예: supplier_code"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[9px] text-muted-foreground">
|
|
||||||
filter_changed 이벤트에서 이 이름으로 값이 전달됩니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 플레이스홀더 */}
|
{/* 플레이스홀더 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">플레이스홀더</Label>
|
<Label className="text-[10px]">플레이스홀더</Label>
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,11 @@ import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||||
import { PopSearchComponent } from "./PopSearchComponent";
|
import { PopSearchComponent } from "./PopSearchComponent";
|
||||||
import { PopSearchConfigPanel } from "./PopSearchConfig";
|
import { PopSearchConfigPanel } from "./PopSearchConfig";
|
||||||
import type { PopSearchConfig } from "./types";
|
import type { PopSearchConfig } from "./types";
|
||||||
|
import { DEFAULT_SEARCH_CONFIG } from "./types";
|
||||||
const defaultConfig: PopSearchConfig = {
|
|
||||||
inputType: "text",
|
|
||||||
fieldName: "",
|
|
||||||
placeholder: "검색어 입력",
|
|
||||||
debounceMs: 500,
|
|
||||||
triggerOnEnter: true,
|
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
|
||||||
labelVisible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) {
|
function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) {
|
||||||
const cfg = config || defaultConfig;
|
const cfg = config || DEFAULT_SEARCH_CONFIG;
|
||||||
const displayLabel = label || cfg.fieldName || "검색";
|
const displayLabel = cfg.labelText || label || cfg.fieldName || "검색";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||||
|
|
@ -43,7 +33,15 @@ PopComponentRegistry.registerComponent({
|
||||||
component: PopSearchComponent,
|
component: PopSearchComponent,
|
||||||
configPanel: PopSearchConfigPanel,
|
configPanel: PopSearchConfigPanel,
|
||||||
preview: PopSearchPreviewComponent,
|
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,
|
touchOptimized: true,
|
||||||
supportedDevices: ["mobile", "tablet"],
|
supportedDevices: ["mobile", "tablet"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@ export interface PopSearchConfig {
|
||||||
labelPosition?: "top" | "left";
|
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<DatePresetOption, string> = {
|
export const DATE_PRESET_LABELS: Record<DatePresetOption, string> = {
|
||||||
today: "오늘",
|
today: "오늘",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface PopStringListComponentProps {
|
||||||
config?: PopStringListConfig;
|
config?: PopStringListConfig;
|
||||||
className?: string;
|
className?: string;
|
||||||
screenId?: string;
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 행 데이터 타입
|
// 테이블 행 데이터 타입
|
||||||
|
|
@ -58,6 +59,7 @@ export function PopStringListComponent({
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
screenId,
|
screenId,
|
||||||
|
componentId,
|
||||||
}: PopStringListComponentProps) {
|
}: PopStringListComponentProps) {
|
||||||
const displayMode = config?.displayMode || "list";
|
const displayMode = config?.displayMode || "list";
|
||||||
const header = config?.header;
|
const header = config?.header;
|
||||||
|
|
@ -78,8 +80,37 @@ export function PopStringListComponent({
|
||||||
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
|
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
|
||||||
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1);
|
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-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(
|
const handleCardButtonClick = useCallback(
|
||||||
|
|
@ -124,32 +155,72 @@ export function PopStringListComponent({
|
||||||
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
||||||
const paginationStyle = overflow?.paginationStyle || "bottom";
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayCount(visibleRows);
|
setDisplayCount(visibleRows);
|
||||||
}, [visibleRows]);
|
}, [visibleRows]);
|
||||||
|
|
||||||
const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, rows.length);
|
const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length);
|
||||||
const hasMore = showExpandButton && rows.length > effectiveLimit && effectiveLimit < maxExpandRows;
|
const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows;
|
||||||
const isExpanded = effectiveLimit > visibleRows;
|
const isExpanded = effectiveLimit > visibleRows;
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
setDisplayCount((prev) => {
|
setDisplayCount((prev) => {
|
||||||
const current = prev || visibleRows;
|
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(() => {
|
const handleCollapse = useCallback(() => {
|
||||||
setDisplayCount(visibleRows);
|
setDisplayCount(visibleRows);
|
||||||
}, [visibleRows]);
|
}, [visibleRows]);
|
||||||
|
|
||||||
// --- 페이지네이션 모드 ---
|
// --- 페이지네이션 모드 ---
|
||||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [pageSize, rows.length]);
|
}, [pageSize, filteredRows.length]);
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||||
|
|
@ -159,10 +230,10 @@ export function PopStringListComponent({
|
||||||
const visibleData = useMemo(() => {
|
const visibleData = useMemo(() => {
|
||||||
if (overflowMode === "pagination") {
|
if (overflowMode === "pagination") {
|
||||||
const start = (currentPage - 1) * pageSize;
|
const start = (currentPage - 1) * pageSize;
|
||||||
return rows.slice(start, start + pageSize);
|
return filteredRows.slice(start, start + pageSize);
|
||||||
}
|
}
|
||||||
return rows.slice(0, effectiveLimit);
|
return filteredRows.slice(0, effectiveLimit);
|
||||||
}, [overflowMode, rows, currentPage, pageSize, effectiveLimit]);
|
}, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]);
|
||||||
|
|
||||||
// dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
|
// dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
|
||||||
const dsTableName = dataSource?.tableName;
|
const dsTableName = dataSource?.tableName;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ PopComponentRegistry.registerComponent({
|
||||||
configPanel: PopStringListConfigPanel,
|
configPanel: PopStringListConfigPanel,
|
||||||
preview: PopStringListPreviewComponent,
|
preview: PopStringListPreviewComponent,
|
||||||
defaultProps: defaultConfig,
|
defaultProps: defaultConfig,
|
||||||
|
connectionMeta: {
|
||||||
|
sendable: [
|
||||||
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" },
|
||||||
|
],
|
||||||
|
receivable: [
|
||||||
|
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||||
|
],
|
||||||
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
supportedDevices: ["mobile", "tablet"],
|
supportedDevices: ["mobile", "tablet"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue