feat(pop-button): 제어 실행 기능 추가 + 연결 데이터 UX 개선 + 필터 UI 개선

POP 버튼 컴포넌트에서 백엔드 제어관리(node_flows)를 직접 실행할 수
있도록 "제어 실행" 작업 유형을 추가하고, 데이터 수정 시 연결된
컴포넌트 기반으로 필드를 선택하는 UX로 개선한다.
[제어 실행 (custom-event 확장)]
- ButtonTask에 flowId/flowName 필드 추가
- ControlFlowTaskForm: Combobox(Popover+Command)로 검색/선택 UI
- executePopAction: flowId 기반 POST /dataflow/node-flows/:flowId/execute
- 기존 eventName 발행 메커니즘은 폴백으로 유지
[연결 데이터 UX 개선]
- extractCardFields -> extractConnectedFields 리팩토링
  (connections 기반 연결 컴포넌트에서만 필드 추출)
- pop-card-list/pop-field/pop-search 타입별 필드 추출 로직
- 시스템 필드(__cart_quantity 등)에 한글 라벨 부여
- UI 라벨: "화면 데이터" -> "연결된 데이터"
[pop-card-list 필터 UI]
- 필터 조건 레이아웃을 가로 -> 세로 스택으로 변경
- 조건 번호 표시 + 입력 필드 높이 확대
[버그 수정]
- apiClient baseURL 이중 /api 경로 수정
- 응답 필드명 camelCase 통일
This commit is contained in:
SeongHyun Kim 2026-03-07 09:56:58 +09:00
parent 20ad1d6829
commit 62e11127a7
3 changed files with 294 additions and 125 deletions

View File

@ -322,7 +322,9 @@ export async function executeTaskList(
} }
case "custom-event": case "custom-event":
if (task.eventName) { if (task.flowId) {
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
} else if (task.eventName) {
publish(task.eventName, task.eventPayload ?? {}); publish(task.eventName, task.eventPayload ?? {});
} }
break; break;

View File

@ -23,6 +23,19 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopAction } from "@/hooks/pop/usePopAction"; import { usePopAction } from "@/hooks/pop/usePopAction";
import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction"; import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction";
@ -51,6 +64,7 @@ import {
PackageCheck, PackageCheck,
ChevronRight, ChevronRight,
GripVertical, GripVertical,
ChevronsUpDown,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -227,9 +241,11 @@ export interface ButtonTask {
apiEndpoint?: string; apiEndpoint?: string;
apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
// custom-event // custom-event (제어 실행)
eventName?: string; eventName?: string;
eventPayload?: Record<string, unknown>; eventPayload?: Record<string, unknown>;
flowId?: number;
flowName?: string;
} }
/** 빠른 시작 템플릿 */ /** 빠른 시작 템플릿 */
@ -345,7 +361,7 @@ export const TASK_TYPE_LABELS: Record<ButtonTaskType, string> = {
"close-modal": "모달 닫기", "close-modal": "모달 닫기",
"refresh": "새로고침", "refresh": "새로고침",
"api-call": "API 호출", "api-call": "API 호출",
"custom-event": "커스텀 이벤트", "custom-event": "제어 실행",
}; };
/** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */ /** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */
@ -1267,39 +1283,80 @@ interface PopButtonConfigPanelProps {
componentId?: string; componentId?: string;
} }
/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */ /** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */
function extractCardFields( function extractConnectedFields(
componentId?: string,
connections?: PopButtonConfigPanelProps["connections"],
allComponents?: PopButtonConfigPanelProps["allComponents"], allComponents?: PopButtonConfigPanelProps["allComponents"],
): { value: string; label: string; source: string }[] { ): { value: string; label: string; source: string }[] {
if (!allComponents) return []; if (!componentId || !connections || !allComponents) return [];
const targetIds = connections
.filter((c) => c.sourceComponent === componentId || c.targetComponent === componentId)
.map((c) => (c.sourceComponent === componentId ? c.targetComponent : c.sourceComponent));
const uniqueIds = [...new Set(targetIds)];
if (uniqueIds.length === 0) return [];
const fields: { value: string; label: string; source: string }[] = []; const fields: { value: string; label: string; source: string }[] = [];
for (const comp of allComponents) { for (const tid of uniqueIds) {
if (comp.type !== "pop-card-list" || !comp.config) continue; const comp = allComponents.find((c) => c.id === tid);
const tpl = (comp.config as Record<string, unknown>).cardTemplate as if (!comp?.config) continue;
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } const cfg = comp.config as Record<string, unknown>;
| undefined; const compLabel = (comp as Record<string, unknown>).label as string || comp.type || tid;
if (!tpl) continue;
if (tpl.header?.codeField) { if (comp.type === "pop-card-list") {
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" }); const tpl = cfg.cardTemplate as
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
| undefined;
if (tpl) {
if (tpl.header?.codeField) {
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: compLabel });
}
if (tpl.header?.titleField) {
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: compLabel });
}
for (const f of tpl.body?.fields ?? []) {
if (f.valueType === "column" && f.columnName) {
fields.push({ value: f.columnName, label: f.label || f.columnName, source: compLabel });
} else if (f.valueType === "formula" && f.label) {
fields.push({ value: `__formula_${f.id || f.label}`, label: f.label, source: `${compLabel} (수식)` });
}
}
}
fields.push({ value: "__cart_quantity", label: "사용자 입력 수량", source: `${compLabel} (장바구니)` });
fields.push({ value: "__cart_row_key", label: "선택한 카드의 원본 키", source: `${compLabel} (장바구니)` });
fields.push({ value: "__cart_id", label: "장바구니 항목 ID", source: `${compLabel} (장바구니)` });
} }
if (tpl.header?.titleField) {
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" }); if (comp.type === "pop-field") {
} const sections = cfg.sections as Array<{
for (const f of tpl.body?.fields ?? []) { fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
if (f.valueType === "column" && f.columnName) { }> | undefined;
fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" }); if (Array.isArray(sections)) {
} else if (f.valueType === "formula" && f.label) { for (const section of sections) {
const formulaKey = `__formula_${f.id || f.label}`; for (const f of section.fields ?? []) {
fields.push({ value: formulaKey, label: f.label, source: "수식" }); if (f.fieldName) {
fields.push({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel });
}
}
}
} }
} }
// 시스템 필드 추가 if (comp.type === "pop-search") {
fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" }); const filterCols = cfg.filterColumns as string[] | undefined;
fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" }); const modalCfg = cfg.modalConfig as { valueField?: string } | undefined;
fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" }); if (Array.isArray(filterCols) && filterCols.length > 0) {
for (const col of filterCols) {
fields.push({ value: col, label: col, source: compLabel });
}
} else if (modalCfg?.valueField) {
fields.push({ value: modalCfg.valueField, label: modalCfg.valueField, source: compLabel });
} else if (cfg.fieldName && typeof cfg.fieldName === "string") {
fields.push({ value: cfg.fieldName, label: (cfg.placeholder as string) || cfg.fieldName, source: compLabel });
}
}
} }
return fields; return fields;
@ -1309,9 +1366,14 @@ export function PopButtonConfigPanel({
config, config,
onUpdate, onUpdate,
allComponents, allComponents,
connections,
componentId,
}: PopButtonConfigPanelProps) { }: PopButtonConfigPanelProps) {
const v2 = useMemo(() => migrateButtonConfig(config), [config]); const v2 = useMemo(() => migrateButtonConfig(config), [config]);
const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]); const cardFields = useMemo(
() => extractConnectedFields(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const updateV2 = useCallback( const updateV2 = useCallback(
(partial: Partial<PopButtonConfigV2>) => { (partial: Partial<PopButtonConfigV2>) => {
@ -1560,7 +1622,7 @@ function buildTaskSummary(task: ButtonTask): string {
case "api-call": case "api-call":
return task.apiEndpoint || ""; return task.apiEndpoint || "";
case "custom-event": case "custom-event":
return task.eventName || ""; return task.flowName || task.eventName || "";
default: default:
return ""; return "";
} }
@ -1829,15 +1891,10 @@ function TaskDetailForm({
case "custom-event": case "custom-event":
return ( return (
<div className="space-y-2"> <ControlFlowTaskForm
<Label className="text-xs font-medium"></Label> task={task}
<Input onUpdate={onUpdate}
value={task.eventName || ""} />
onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected"
className="h-8 text-xs"
/>
</div>
); );
case "refresh": case "refresh":
@ -1897,7 +1954,7 @@ function buildUpdateSummaryText(task: ButtonTask): string | null {
} }
const val = task.valueSource === "linked" const val = task.valueSource === "linked"
? `화면 데이터(${task.sourceField || "?"})` ? `연결 데이터(${task.sourceField || "?"})`
: `"${task.fixedValue || "?"}"`; : `"${task.fixedValue || "?"}"`;
return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`; return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`;
} }
@ -2003,7 +2060,7 @@ function DataUpdateTaskForm({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="fixed" className="text-xs"> </SelectItem> <SelectItem value="fixed" className="text-xs"> </SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem> <SelectItem value="linked" className="text-xs"> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -2022,19 +2079,19 @@ function DataUpdateTaskForm({
{task.valueSource === "linked" && ( {task.valueSource === "linked" && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label> <Label className="text-xs font-medium"> ?</Label>
{cardFields.length > 0 ? ( {cardFields.length > 0 ? (
<Select <Select
value={task.sourceField || ""} value={task.sourceField || ""}
onValueChange={(v) => onUpdate({ sourceField: v })} onValueChange={(v) => onUpdate({ sourceField: v })}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" /> <SelectValue placeholder="데이터 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{cardFields.map((f) => ( {cardFields.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-xs"> <SelectItem key={f.value} value={f.value} className="text-xs">
<div className="flex items-center gap-2"> <div className="flex flex-col">
<span>{f.label}</span> <span>{f.label}</span>
<span className="text-[10px] text-muted-foreground">{f.source}</span> <span className="text-[10px] text-muted-foreground">{f.source}</span>
</div> </div>
@ -2052,8 +2109,8 @@ function DataUpdateTaskForm({
)} )}
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{cardFields.length > 0 {cardFields.length > 0
? "카드에 표시되는 데이터 중 하나를 선택합니다" ? "연결된 컴포넌트의 데이터 중 하나를 선택합니다"
: "카드 컴포넌트가 없으면 직접 입력해주세요"} : "연결된 컴포넌트가 없습니다. 연결 탭에서 먼저 연결해주세요"}
</p> </p>
</div> </div>
)} )}
@ -2924,6 +2981,107 @@ function SingleRuleEditor({
); );
} }
// ========================================
// 제어 실행 작업 폼 (custom-event -> 제어 플로우)
// ========================================
function ControlFlowTaskForm({
task,
onUpdate,
}: {
task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void;
}) {
const [flows, setFlows] = useState<{ flowId: number; flowName: string; flowDescription?: string }[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
setLoading(true);
apiClient
.get("/dataflow/node-flows")
.then((res) => {
const data = res.data?.data ?? res.data ?? [];
if (Array.isArray(data)) {
setFlows(data);
}
})
.catch(() => setFlows([]))
.finally(() => setLoading(false));
}, []);
const selectedFlow = flows.find((f) => f.flowId === task.flowId);
return (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{loading ? (
<p className="text-[11px] text-muted-foreground"> ...</p>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
>
{selectedFlow ? selectedFlow.flowName : "플로우를 선택하세요"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="플로우 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="p-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{flows.map((f) => (
<CommandItem
key={f.flowId}
value={f.flowName}
onSelect={() => {
onUpdate({
flowId: f.flowId,
flowName: f.flowName,
eventName: `__node_flow_${f.flowId}`,
});
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
task.flowId === f.flowId ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{f.flowName}</span>
{f.flowDescription && (
<span className="text-[10px] text-muted-foreground">
{f.flowDescription}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
);
}
// 레지스트리 등록 // 레지스트리 등록
PopComponentRegistry.registerComponent({ PopComponentRegistry.registerComponent({
id: "pop-button", id: "pop-button",

View File

@ -2039,16 +2039,29 @@ function FilterSettingsSection({
{filters.map((filter, index) => ( {filters.map((filter, index) => (
<div <div
key={index} key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5" className="space-y-1.5 rounded-md border bg-card p-2"
> >
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Select <Select
value={filter.column || ""} value={filter.column || ""}
onValueChange={(val) => onValueChange={(val) =>
updateFilter(index, { ...filter, column: val }) updateFilter(index, { ...filter, column: val })
} }
> >
<SelectTrigger className="h-7 text-xs flex-1"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼" /> <SelectValue placeholder="컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{columns.map((col) => ( {columns.map((col) => (
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex items-center gap-2">
<Select <Select
value={filter.operator} value={filter.operator}
onValueChange={(val) => onValueChange={(val) =>
updateFilter(index, { updateFilter(index, {
...filter, ...filter,
operator: val as FilterOperator, operator: val as FilterOperator,
}) })
} }
> >
<SelectTrigger className="h-7 w-16 text-xs"> <SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{operators.map((op) => ( {operators.map((op) => (
<SelectItem key={op.value} value={op.value}> <SelectItem key={op.value} value={op.value}>
{op.label} {op.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Input
<Input value={filter.value}
value={filter.value} onChange={(e) =>
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })
updateFilter(index, { ...filter, value: e.target.value }) }
} placeholder="값 입력"
placeholder="값" className="h-8 flex-1 text-xs"
className="h-7 flex-1 text-xs" />
/> </div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
))} ))}
</div> </div>
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filters.map((filter, index) => ( {filters.map((filter, index) => (
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5"> <div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
<div className="flex-1"> <div className="flex items-center justify-between">
<GroupedColumnSelect <span className="text-[10px] font-medium text-muted-foreground">
columnGroups={columnGroups} {index + 1}
value={filter.column || undefined} </span>
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })} <Button
placeholder="컬럼 선택" variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
/>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/> />
</div> </div>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
))} ))}
</div> </div>