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":
if (task.eventName) {
if (task.flowId) {
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
} else if (task.eventName) {
publish(task.eventName, task.eventPayload ?? {});
}
break;

View File

@ -23,6 +23,19 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} 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 { usePopAction } from "@/hooks/pop/usePopAction";
import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction";
@ -51,6 +64,7 @@ import {
PackageCheck,
ChevronRight,
GripVertical,
ChevronsUpDown,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -227,9 +241,11 @@ export interface ButtonTask {
apiEndpoint?: string;
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
// custom-event
// custom-event (제어 실행)
eventName?: string;
eventPayload?: Record<string, unknown>;
flowId?: number;
flowName?: string;
}
/** 빠른 시작 템플릿 */
@ -345,7 +361,7 @@ export const TASK_TYPE_LABELS: Record<ButtonTaskType, string> = {
"close-modal": "모달 닫기",
"refresh": "새로고침",
"api-call": "API 호출",
"custom-event": "커스텀 이벤트",
"custom-event": "제어 실행",
};
/** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */
@ -1267,39 +1283,80 @@ interface PopButtonConfigPanelProps {
componentId?: string;
}
/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */
function extractCardFields(
/** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */
function extractConnectedFields(
componentId?: string,
connections?: PopButtonConfigPanelProps["connections"],
allComponents?: PopButtonConfigPanelProps["allComponents"],
): { 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 }[] = [];
for (const comp of allComponents) {
if (comp.type !== "pop-card-list" || !comp.config) continue;
const tpl = (comp.config as Record<string, unknown>).cardTemplate as
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
| undefined;
if (!tpl) continue;
for (const tid of uniqueIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const cfg = comp.config as Record<string, unknown>;
const compLabel = (comp as Record<string, unknown>).label as string || comp.type || tid;
if (tpl.header?.codeField) {
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" });
if (comp.type === "pop-card-list") {
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: "헤더 제목" });
}
for (const f of tpl.body?.fields ?? []) {
if (f.valueType === "column" && f.columnName) {
fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" });
} else if (f.valueType === "formula" && f.label) {
const formulaKey = `__formula_${f.id || f.label}`;
fields.push({ value: formulaKey, label: f.label, source: "수식" });
if (comp.type === "pop-field") {
const sections = cfg.sections as Array<{
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
}> | undefined;
if (Array.isArray(sections)) {
for (const section of sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) {
fields.push({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel });
}
}
}
}
}
// 시스템 필드 추가
fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" });
fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" });
fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" });
if (comp.type === "pop-search") {
const filterCols = cfg.filterColumns as string[] | undefined;
const modalCfg = cfg.modalConfig as { valueField?: string } | undefined;
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;
@ -1309,9 +1366,14 @@ export function PopButtonConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopButtonConfigPanelProps) {
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(
(partial: Partial<PopButtonConfigV2>) => {
@ -1560,7 +1622,7 @@ function buildTaskSummary(task: ButtonTask): string {
case "api-call":
return task.apiEndpoint || "";
case "custom-event":
return task.eventName || "";
return task.flowName || task.eventName || "";
default:
return "";
}
@ -1829,15 +1891,10 @@ function TaskDetailForm({
case "custom-event":
return (
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={task.eventName || ""}
onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected"
className="h-8 text-xs"
/>
</div>
<ControlFlowTaskForm
task={task}
onUpdate={onUpdate}
/>
);
case "refresh":
@ -1897,7 +1954,7 @@ function buildUpdateSummaryText(task: ButtonTask): string | null {
}
const val = task.valueSource === "linked"
? `화면 데이터(${task.sourceField || "?"})`
? `연결 데이터(${task.sourceField || "?"})`
: `"${task.fixedValue || "?"}"`;
return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`;
}
@ -2003,7 +2060,7 @@ function DataUpdateTaskForm({
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed" className="text-xs"> </SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
@ -2022,19 +2079,19 @@ function DataUpdateTaskForm({
{task.valueSource === "linked" && (
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label>
<Label className="text-xs font-medium"> ?</Label>
{cardFields.length > 0 ? (
<Select
value={task.sourceField || ""}
onValueChange={(v) => onUpdate({ sourceField: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
<SelectValue placeholder="데이터 선택" />
</SelectTrigger>
<SelectContent>
{cardFields.map((f) => (
<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 className="text-[10px] text-muted-foreground">{f.source}</span>
</div>
@ -2052,8 +2109,8 @@ function DataUpdateTaskForm({
)}
<p className="text-[11px] text-muted-foreground">
{cardFields.length > 0
? "카드에 표시되는 데이터 중 하나를 선택합니다"
: "카드 컴포넌트가 없으면 직접 입력해주세요"}
? "연결된 컴포넌트의 데이터 중 하나를 선택합니다"
: "연결된 컴포넌트가 없습니다. 연결 탭에서 먼저 연결해주세요"}
</p>
</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({
id: "pop-button",

View File

@ -2039,16 +2039,29 @@ function FilterSettingsSection({
{filters.map((filter, index) => (
<div
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
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{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 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>
{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>
))}
</div>
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
<div className="flex-1">
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
<div key={index} 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>
<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>
<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>