feat(pop): 컴포넌트 연결 단순화 + 상태 변경 규칙 UI 개선 + 조회 키 설정
컴포넌트 연결 단순화 - ConnectionEditor: 이벤트 연결 시 "어디로" Select 1개로 단순화 - useConnectionResolver: 호환 이벤트 자동 라우팅 (_auto 모드) - connectionMeta에 category(event/filter/data) 필드 추가 상태 변경 규칙 UI 개선 - StatusChangeRule 타입 통합, 모든 버튼 프리셋에서 사용 가능 - TableCombobox/ColumnCombobox 공용 컴포넌트 추출 (pop-shared/) - 테이블/컬럼 드롭다운, 고정값/조건부 값 설정 UI - 입고확정 API 신규 (popActionRoutes.ts, 동적 상태 변경 처리) 조회 키 자동/수동 설정 - 대상 테이블 기반 자동 판단 (cart_items -> id, 그 외 -> row_key -> PK) - 수동 모드: 카드 항목 필드와 대상 PK 컬럼을 직접 지정 가능 - PK 컬럼명 동적 표시 (isPrimaryKey 정보 활용)
This commit is contained in:
@@ -36,6 +36,15 @@ interface ConnectionEditorProps {
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
||||
// ========================================
|
||||
|
||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
||||
if (!meta?.sendable) return false;
|
||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
@@ -75,6 +84,8 @@ export default function ConnectionEditor({
|
||||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
@@ -83,6 +94,7 @@ export default function ConnectionEditor({
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
@@ -92,7 +104,6 @@ export default function ConnectionEditor({
|
||||
{hasReceivable && (
|
||||
<ReceiveSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
incoming={incoming}
|
||||
/>
|
||||
@@ -105,7 +116,6 @@ export default function ConnectionEditor({
|
||||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
/** 화면에 표시 중인 컬럼만 추출 */
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
@@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
@@ -143,6 +152,7 @@ interface SendSectionProps {
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
isFilterSource: boolean;
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
@@ -153,6 +163,7 @@ function SendSection({
|
||||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
@@ -163,29 +174,42 @@ function SendSection({
|
||||
<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="수정"
|
||||
/>
|
||||
isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
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}`}
|
||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingId(conn.id)}
|
||||
@@ -206,23 +230,131 @@ function SendSection({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새 연결 추가 */}
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 폼 (추가/수정 공용)
|
||||
// 단순 연결 폼 (이벤트 타입: "어디로" 1개만)
|
||||
// ========================================
|
||||
|
||||
interface ConnectionFormProps {
|
||||
interface SimpleConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function SimpleConnectionForm({
|
||||
component,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: SimpleConnectionFormProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
|
||||
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 handleSubmit = () => {
|
||||
if (!selectedTargetId) return;
|
||||
|
||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||
const srcLabel = component.label || component.id;
|
||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: "_auto",
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: "_auto",
|
||||
label: `${srcLabel} → ${tgtLabel}`,
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
}
|
||||
};
|
||||
|
||||
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={selectedTargetId}
|
||||
onValueChange={setSelectedTargetId}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedTargetId}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
@@ -232,7 +364,7 @@ interface ConnectionFormProps {
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
@@ -240,7 +372,7 @@ function ConnectionForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: ConnectionFormProps) {
|
||||
}: FilterConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
@@ -272,32 +404,26 @@ function ConnectionForm({
|
||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
// 이미 선택된 값이 있으면 건드리지 않음
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
// 1) 같은 key가 있으면 자동 매칭
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
// 2) receivable이 1개뿐이면 자동 선택
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
// 화면에 표시 중인 컬럼
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
@@ -324,7 +450,6 @@ function ConnectionForm({
|
||||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
@@ -388,7 +513,6 @@ function ConnectionForm({
|
||||
<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}>
|
||||
@@ -405,7 +529,6 @@ function ConnectionForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 컴포넌트 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
@@ -429,7 +552,6 @@ function ConnectionForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 방식 */}
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
@@ -448,7 +570,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
@@ -460,7 +581,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{/* 표시 컬럼 그룹 */}
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
@@ -482,7 +602,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전용 컬럼 그룹 */}
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
@@ -522,7 +641,6 @@ function ConnectionForm({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 필터 방식 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
@@ -540,7 +658,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -556,19 +673,17 @@ function ConnectionForm({
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용)
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ReceiveSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
incoming,
|
||||
}: ReceiveSectionProps) {
|
||||
@@ -576,28 +691,11 @@ function ReceiveSection({
|
||||
<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>
|
||||
<div className="space-y-1">
|
||||
{incoming.map((conn) => {
|
||||
const sourceComp = allComponents.find(
|
||||
(c) => c.id === conn.sourceComponent
|
||||
@@ -605,9 +703,9 @@ function ReceiveSection({
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center gap-2 rounded border bg-green-50/50 px-3 py-2 text-xs"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<ArrowRight className="h-3 w-3 text-green-500" />
|
||||
<span className="truncate">
|
||||
{sourceComp?.label || conn.sourceComponent}
|
||||
</span>
|
||||
@@ -617,7 +715,7 @@ function ReceiveSection({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||
연결된 소스가 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -651,5 +749,5 @@ function buildConnectionLabel(
|
||||
const colInfo = columns && columns.length > 0
|
||||
? ` [${columns.join(", ")}]`
|
||||
: "";
|
||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
||||
}
|
||||
|
||||
@@ -69,9 +69,21 @@ export default function PopViewerWithModals({
|
||||
() => layout.dataFlow?.connections ?? [],
|
||||
[layout.dataFlow?.connections]
|
||||
);
|
||||
|
||||
const componentTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (layout.components) {
|
||||
for (const comp of Object.values(layout.components)) {
|
||||
map.set(comp.id, comp.type);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [layout.components]);
|
||||
|
||||
useConnectionResolver({
|
||||
screenId,
|
||||
connections: stableConnections,
|
||||
componentTypes,
|
||||
});
|
||||
|
||||
// 모달 열기/닫기 이벤트 구독
|
||||
|
||||
Reference in New Issue
Block a user