feat(pop-search): 연결 탭 일관성 통합 + 필터 설정을 상세설정으로 이동
검색 컴포넌트의 연결 탭이 다른 컴포넌트(입력, 카드리스트)와 달리
필터 설정이 연결 폼에 포함되어 있던 비일관성을 해결한다.
- ConnectionEditor: FilterConnectionForm 제거, 모든 컴포넌트가
SimpleConnectionForm 사용하도록 통합
- PopSearchConfig: 상세설정 탭에 FilterConnectionSection 추가
(text/select/date-preset/modal 타입별 통합)
- FilterConnectionSection: 연결된 대상 컴포넌트의 테이블 컬럼을
API 조회하여 체크박스 기반 복수 선택 UI 제공
("카드에서 표시 중" / "기타 컬럼" 그룹 구분)
- types: SearchFilterMode, filterColumns 타입 추가
- PopSearchComponent: filterColumns 배열을 이벤트 payload에 포함
- useConnectionResolver: filterColumns를 targetColumns로 전달,
auto-match에서 filter_value 타입 매칭 + filterConfig 자동 추론
This commit is contained in:
parent
12a8290873
commit
47384e1c2b
|
|
@ -1,11 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-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,
|
||||
|
|
@ -19,9 +17,7 @@ import {
|
|||
} from "../types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ComponentConnectionMeta,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -36,15 +32,6 @@ 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
|
||||
// ========================================
|
||||
|
|
@ -84,17 +71,13 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
<SendSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
|
|
@ -112,47 +95,14 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
function extractDisplayColumns(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;
|
||||
}
|
||||
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
||||
return ds?.tableName || "";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 보내기 섹션
|
||||
// ========================================
|
||||
|
||||
interface SendSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
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;
|
||||
|
|
@ -160,10 +110,8 @@ interface SendSectionProps {
|
|||
|
||||
function SendSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
|
|
@ -180,32 +128,17 @@ function SendSection({
|
|||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
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="수정"
|
||||
/>
|
||||
)
|
||||
<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">
|
||||
|
|
@ -230,22 +163,12 @@ function SendSection({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -350,328 +273,6 @@ function SimpleConnectionForm({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: FilterConnectionFormProps) {
|
||||
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;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
||||
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tableName) {
|
||||
setAllDbColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setDbColumnsLoading(true);
|
||||
getTableColumns(tableName).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data?.columns) {
|
||||
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
||||
} else {
|
||||
setAllDbColumns([]);
|
||||
}
|
||||
setDbColumnsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
[allDbColumns, displaySet]
|
||||
);
|
||||
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
||||
|
||||
const toggleColumn = (col: string) => {
|
||||
setFilterColumns((prev) =>
|
||||
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||
|
||||
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: selectedOutput,
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: selectedTargetInput,
|
||||
filterConfig:
|
||||
!isEvent && 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 && !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>
|
||||
|
||||
{dbColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
||||
</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>
|
||||
{displayColumns.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>
|
||||
)}
|
||||
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="my-1 h-px bg-gray-200" />
|
||||
)}
|
||||
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
||||
{dataOnlyColumns.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 text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
|
@ -722,32 +323,3 @@ function ReceiveSection({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유틸
|
||||
// ========================================
|
||||
|
||||
function isEventTypeConnection(
|
||||
sourceMeta: ComponentConnectionMeta | undefined,
|
||||
outputKey: string,
|
||||
targetMeta: ComponentConnectionMeta | null | undefined,
|
||||
inputKey: string,
|
||||
): boolean {
|
||||
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
|
||||
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
|
||||
return sourceItem?.type === "event" || targetItem?.type === "event";
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
|
|||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ConnectionMetaItem,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
interface UseConnectionResolverOptions {
|
||||
|
|
@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
|
|||
componentTypes?: Map<string, string>;
|
||||
}
|
||||
|
||||
interface AutoMatchPair {
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
isFilter: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다.
|
||||
* 규칙: category="event"이고 key가 동일한 쌍
|
||||
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다.
|
||||
* 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭)
|
||||
* 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭)
|
||||
*/
|
||||
function getAutoMatchPairs(
|
||||
sourceType: string,
|
||||
targetType: string
|
||||
): { sourceKey: string; targetKey: string }[] {
|
||||
): AutoMatchPair[] {
|
||||
const sourceDef = PopComponentRegistry.getComponent(sourceType);
|
||||
const targetDef = PopComponentRegistry.getComponent(targetType);
|
||||
|
||||
|
|
@ -44,14 +50,15 @@ function getAutoMatchPairs(
|
|||
return [];
|
||||
}
|
||||
|
||||
const pairs: { sourceKey: string; targetKey: string }[] = [];
|
||||
const pairs: AutoMatchPair[] = [];
|
||||
|
||||
for (const s of sourceDef.connectionMeta.sendable) {
|
||||
if (s.category !== "event") continue;
|
||||
for (const r of targetDef.connectionMeta.receivable) {
|
||||
if (r.category !== "event") continue;
|
||||
if (s.key === r.key) {
|
||||
pairs.push({ sourceKey: s.key, targetKey: r.key });
|
||||
if (s.category === "event" && r.category === "event" && s.key === r.key) {
|
||||
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
|
||||
}
|
||||
if (s.type === "filter_value" && r.type === "filter_value") {
|
||||
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,10 +100,24 @@ export function useConnectionResolver({
|
|||
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
if (pair.isFilter) {
|
||||
const data = payload as Record<string, unknown> | null;
|
||||
const fieldName = data?.fieldName as string | undefined;
|
||||
const filterColumns = data?.filterColumns as string[] | undefined;
|
||||
const filterMode = (data?.filterMode as string) || "contains";
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
filterConfig: fieldName
|
||||
? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode }
|
||||
: conn.filterConfig,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
} else {
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
|
@ -121,13 +142,22 @@ export function useConnectionResolver({
|
|||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||
|
||||
const enrichedPayload = {
|
||||
value: payload,
|
||||
filterConfig: conn.filterConfig,
|
||||
_connectionId: conn.id,
|
||||
};
|
||||
let resolvedFilterConfig = conn.filterConfig;
|
||||
if (!resolvedFilterConfig) {
|
||||
const data = payload as Record<string, unknown> | null;
|
||||
const fieldName = data?.fieldName as string | undefined;
|
||||
const filterColumns = data?.filterColumns as string[] | undefined;
|
||||
if (fieldName) {
|
||||
const filterMode = (data?.filterMode as string) || "contains";
|
||||
resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" };
|
||||
}
|
||||
}
|
||||
|
||||
publish(targetEvent, enrichedPayload);
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
filterConfig: resolvedFilterConfig,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,9 +62,11 @@ export function PopSearchComponent({
|
|||
const [modalDisplayText, setModalDisplayText] = useState("");
|
||||
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
|
||||
|
||||
const fieldKey = config.fieldName || componentId || "search";
|
||||
const normalizedType = normalizeInputType(config.inputType as string);
|
||||
const isModalType = normalizedType === "modal";
|
||||
const fieldKey = isModalType
|
||||
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
|
||||
: (config.fieldName || componentId || "search");
|
||||
|
||||
const emitFilterChanged = useCallback(
|
||||
(newValue: unknown) => {
|
||||
|
|
@ -72,15 +74,18 @@ export function PopSearchComponent({
|
|||
setSharedData(`search_${fieldKey}`, newValue);
|
||||
|
||||
if (componentId) {
|
||||
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
||||
publish(`__comp_output__${componentId}__filter_value`, {
|
||||
fieldName: fieldKey,
|
||||
filterColumns,
|
||||
value: newValue,
|
||||
filterMode: config.filterMode || "contains",
|
||||
});
|
||||
}
|
||||
|
||||
publish("filter_changed", { [fieldKey]: newValue });
|
||||
},
|
||||
[fieldKey, publish, setSharedData, componentId]
|
||||
[fieldKey, publish, setSharedData, componentId, config.filterMode, config.filterColumns]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import type {
|
||||
PopSearchConfig,
|
||||
SearchInputType,
|
||||
SearchFilterMode,
|
||||
DatePresetOption,
|
||||
ModalSelectConfig,
|
||||
ModalDisplayStyle,
|
||||
|
|
@ -38,6 +39,7 @@ import type {
|
|||
} from "./types";
|
||||
import {
|
||||
SEARCH_INPUT_TYPE_LABELS,
|
||||
SEARCH_FILTER_MODE_LABELS,
|
||||
DATE_PRESET_LABELS,
|
||||
MODAL_DISPLAY_STYLE_LABELS,
|
||||
MODAL_SEARCH_MODE_LABELS,
|
||||
|
|
@ -69,9 +71,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
|||
interface ConfigPanelProps {
|
||||
config: PopSearchConfig | undefined;
|
||||
onUpdate: (config: PopSearchConfig) => void;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||
const cfg: PopSearchConfig = {
|
||||
|
|
@ -110,7 +115,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|||
</div>
|
||||
|
||||
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
|
||||
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
|
||||
{step === 1 && <StepDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button
|
||||
|
|
@ -145,6 +150,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|||
interface StepProps {
|
||||
cfg: PopSearchConfig;
|
||||
update: (partial: Partial<PopSearchConfig>) => void;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
function StepBasicSettings({ cfg, update }: StepProps) {
|
||||
|
|
@ -216,6 +224,7 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -224,16 +233,16 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
|||
// STEP 2: 타입별 상세 설정
|
||||
// ========================================
|
||||
|
||||
function StepDetailSettings({ cfg, update }: StepProps) {
|
||||
function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||
const normalized = normalizeInputType(cfg.inputType as string);
|
||||
switch (normalized) {
|
||||
case "text":
|
||||
case "number":
|
||||
return <TextDetailSettings cfg={cfg} update={update} />;
|
||||
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "select":
|
||||
return <SelectDetailSettings cfg={cfg} update={update} />;
|
||||
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "date-preset":
|
||||
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
||||
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "modal":
|
||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||
case "toggle":
|
||||
|
|
@ -255,11 +264,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 공통: 필터 연결 설정 섹션
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionSectionProps {
|
||||
cfg: PopSearchConfig;
|
||||
update: (partial: Partial<PopSearchConfig>) => void;
|
||||
showFieldName: boolean;
|
||||
fixedFilterMode?: SearchFilterMode;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
interface ConnectedComponentInfo {
|
||||
tableNames: string[];
|
||||
displayedColumns: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다.
|
||||
*/
|
||||
function getConnectedComponentInfo(
|
||||
componentId?: string,
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
|
||||
): ConnectedComponentInfo {
|
||||
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
|
||||
if (!componentId || !connections || !allComponents) return empty;
|
||||
|
||||
const targetIds = connections
|
||||
.filter((c) => c.sourceComponent === componentId)
|
||||
.map((c) => c.targetComponent);
|
||||
|
||||
const tableNames = new Set<string>();
|
||||
const displayedColumns = new Set<string>();
|
||||
|
||||
for (const tid of targetIds) {
|
||||
const comp = allComponents.find((c) => c.id === tid);
|
||||
if (!comp?.config) continue;
|
||||
const compCfg = comp.config as Record<string, any>;
|
||||
|
||||
const tn = compCfg.dataSource?.tableName;
|
||||
if (tn) tableNames.add(tn);
|
||||
|
||||
// pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
|
||||
const tpl = compCfg.cardTemplate;
|
||||
if (tpl) {
|
||||
if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
|
||||
if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
|
||||
if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
|
||||
if (Array.isArray(tpl.body?.fields)) {
|
||||
for (const f of tpl.body.fields) {
|
||||
if (f.columnName) displayedColumns.add(f.columnName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pop-string-list: selectedColumns / listColumns
|
||||
if (Array.isArray(compCfg.selectedColumns)) {
|
||||
for (const col of compCfg.selectedColumns) displayedColumns.add(col);
|
||||
}
|
||||
if (Array.isArray(compCfg.listColumns)) {
|
||||
for (const lc of compCfg.listColumns) {
|
||||
if (lc.columnName) displayedColumns.add(lc.columnName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tableNames: Array.from(tableNames), displayedColumns };
|
||||
}
|
||||
|
||||
function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
|
||||
const connInfo = useMemo(
|
||||
() => getConnectedComponentInfo(componentId, connections, allComponents),
|
||||
[componentId, connections, allComponents],
|
||||
);
|
||||
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
const connectedTablesKey = connInfo.tableNames.join(",");
|
||||
useEffect(() => {
|
||||
if (connInfo.tableNames.length === 0) {
|
||||
setTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setColumnsLoading(true);
|
||||
|
||||
Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
|
||||
.then((results) => {
|
||||
if (cancelled) return;
|
||||
const allCols: ColumnTypeInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const res of results) {
|
||||
if (res.success && res.data?.columns) {
|
||||
for (const col of res.data.columns) {
|
||||
if (!seen.has(col.columnName)) {
|
||||
seen.add(col.columnName);
|
||||
allCols.push(col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setTargetColumns(allCols);
|
||||
})
|
||||
.finally(() => { if (!cancelled) setColumnsLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const hasConnection = connInfo.tableNames.length > 0;
|
||||
|
||||
const { displayedCols, otherCols } = useMemo(() => {
|
||||
if (connInfo.displayedColumns.size === 0) {
|
||||
return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
|
||||
}
|
||||
const displayed: ColumnTypeInfo[] = [];
|
||||
const others: ColumnTypeInfo[] = [];
|
||||
for (const col of targetColumns) {
|
||||
if (connInfo.displayedColumns.has(col.columnName)) {
|
||||
displayed.push(col);
|
||||
} else {
|
||||
others.push(col);
|
||||
}
|
||||
}
|
||||
return { displayedCols: displayed, otherCols: others };
|
||||
}, [targetColumns, connInfo.displayedColumns]);
|
||||
|
||||
const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
|
||||
|
||||
const toggleFilterColumn = (colName: string) => {
|
||||
const current = new Set(selectedFilterCols);
|
||||
if (current.has(colName)) {
|
||||
current.delete(colName);
|
||||
} else {
|
||||
current.add(colName);
|
||||
}
|
||||
const next = Array.from(current);
|
||||
update({
|
||||
filterColumns: next,
|
||||
fieldName: next[0] || "",
|
||||
});
|
||||
};
|
||||
|
||||
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`filter_col_${col.columnName}`}
|
||||
checked={selectedFilterCols.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleFilterColumn(col.columnName)}
|
||||
/>
|
||||
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 border-t pt-3">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">필터 연결 설정</span>
|
||||
</div>
|
||||
|
||||
{!hasConnection && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
연결 탭에서 대상 컴포넌트를 먼저 연결해주세요.
|
||||
연결된 리스트의 컬럼 목록이 여기에 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConnection && showFieldName && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">
|
||||
필터 대상 컬럼 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{columnsLoading ? (
|
||||
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 로딩...
|
||||
</div>
|
||||
) : targetColumns.length > 0 ? (
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
|
||||
{displayedCols.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-primary">카드에서 표시 중</p>
|
||||
{displayedCols.map(renderColumnCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
{displayedCols.length > 0 && otherCols.length > 0 && (
|
||||
<div className="border-t" />
|
||||
)}
|
||||
{otherCols.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-muted-foreground">기타 컬럼</p>
|
||||
{otherCols.map(renderColumnCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 테이블에서 컬럼을 찾을 수 없습니다
|
||||
</p>
|
||||
)}
|
||||
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedFilterCols.length > 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다
|
||||
</p>
|
||||
)}
|
||||
{selectedFilterCols.length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fixedFilterMode ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터 방식</Label>
|
||||
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
|
||||
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터 방식</Label>
|
||||
<Select
|
||||
value={cfg.filterMode || "contains"}
|
||||
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SEARCH_FILTER_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 리스트에 값을 보낼 때 적용되는 매칭 방식
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// text/number 상세 설정
|
||||
// ========================================
|
||||
|
||||
function TextDetailSettings({ cfg, update }: StepProps) {
|
||||
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -285,6 +561,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
|||
/>
|
||||
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
||||
</div>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -293,7 +571,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
|||
// select 상세 설정
|
||||
// ========================================
|
||||
|
||||
function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||
const options = cfg.options || [];
|
||||
|
||||
const addOption = () => {
|
||||
|
|
@ -329,6 +607,8 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
|||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -337,7 +617,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
|||
// date-preset 상세 설정
|
||||
// ========================================
|
||||
|
||||
function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||
function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
|
||||
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
||||
|
||||
|
|
@ -366,6 +646,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
|||
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -694,6 +976,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
|||
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export type ModalDisplayStyle = "table" | "icon";
|
|||
/** 모달 검색 방식 */
|
||||
export type ModalSearchMode = "contains" | "starts-with" | "equals";
|
||||
|
||||
/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
|
||||
export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
|
||||
|
||||
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
|
||||
export type ModalFilterTab = "korean" | "alphabet";
|
||||
|
||||
|
|
@ -93,6 +96,12 @@ export interface PopSearchConfig {
|
|||
|
||||
// 스타일
|
||||
labelPosition?: "top" | "left";
|
||||
|
||||
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
|
||||
filterMode?: SearchFilterMode;
|
||||
|
||||
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
|
||||
filterColumns?: string[];
|
||||
}
|
||||
|
||||
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
|
||||
|
|
@ -147,6 +156,14 @@ export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
|
|||
alphabet: "ABC",
|
||||
};
|
||||
|
||||
/** 검색 필터 방식 라벨 (설정 패널용) */
|
||||
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
|
||||
contains: "포함",
|
||||
equals: "일치",
|
||||
starts_with: "시작",
|
||||
range: "범위",
|
||||
};
|
||||
|
||||
/** 한글 초성 추출 */
|
||||
const KOREAN_CONSONANTS = [
|
||||
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
||||
|
|
|
|||
Loading…
Reference in New Issue