diff --git a/frontend/components/dataflow/node-editor/editors/VariableTagEditor.tsx b/frontend/components/dataflow/node-editor/editors/VariableTagEditor.tsx new file mode 100644 index 00000000..7556e248 --- /dev/null +++ b/frontend/components/dataflow/node-editor/editors/VariableTagEditor.tsx @@ -0,0 +1,309 @@ +"use client"; + +/** + * 변수 태그 에디터 컴포넌트 + * TipTap 기반으로 본문 내에서 변수를 시각적 태그로 표시하고 삽입할 수 있는 에디터 + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import { + VariableTagExtension, + textToEditorContent, + editorContentToText, +} from "./VariableTagExtension"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Plus, Variable, X, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// 변수 정보 타입 +export interface VariableInfo { + name: string; // 실제 변수명 (예: customerName) + displayName: string; // 표시명 (예: 고객명) + type?: string; // 데이터 타입 (예: string, number) + description?: string; // 설명 +} + +interface VariableTagEditorProps { + value: string; // 현재 값 (예: "안녕하세요, {{customerName}} 님") + onChange: (value: string) => void; // 값 변경 콜백 + variables: VariableInfo[]; // 사용 가능한 변수 목록 + placeholder?: string; + className?: string; + minHeight?: string; + disabled?: boolean; +} + +export function VariableTagEditor({ + value, + onChange, + variables, + placeholder = "내용을 입력하세요...", + className, + minHeight = "150px", + disabled = false, +}: VariableTagEditorProps) { + const [isVariablePopoverOpen, setIsVariablePopoverOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + // 변수명 → 표시명 맵 생성 + const variableMap = useMemo(() => { + const map: Record = {}; + variables.forEach((v) => { + map[v.name] = v.displayName; + }); + return map; + }, [variables]); + + // 필터된 변수 목록 + const filteredVariables = useMemo(() => { + if (!searchQuery) return variables; + const query = searchQuery.toLowerCase(); + return variables.filter( + (v) => + v.name.toLowerCase().includes(query) || + v.displayName.toLowerCase().includes(query) + ); + }, [variables, searchQuery]); + + // TipTap 에디터 초기화 + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // 불필요한 기능 비활성화 + heading: false, + bulletList: false, + orderedList: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + }), + VariableTagExtension, + Placeholder.configure({ + placeholder, + emptyEditorClass: "is-editor-empty", + }), + ], + content: textToEditorContent(value, variableMap), + editable: !disabled, + onUpdate: ({ editor }) => { + const json = editor.getJSON(); + const text = editorContentToText(json); + onChange(text); + }, + editorProps: { + attributes: { + class: cn( + "prose prose-sm max-w-none focus:outline-none", + "min-h-[100px] p-3", + disabled && "opacity-50 cursor-not-allowed" + ), + }, + }, + }); + + // 외부 value 변경 시 에디터 동기화 + useEffect(() => { + if (editor && !editor.isFocused) { + const currentText = editorContentToText(editor.getJSON()); + if (currentText !== value) { + editor.commands.setContent(textToEditorContent(value, variableMap)); + } + } + }, [value, editor, variableMap]); + + // 변수 삽입 + const insertVariable = useCallback( + (variable: VariableInfo) => { + if (!editor) return; + + editor + .chain() + .focus() + .insertContent({ + type: "variableTag", + attrs: { + variableName: variable.name, + displayName: variable.displayName, + }, + }) + .run(); + + setIsVariablePopoverOpen(false); + setSearchQuery(""); + }, + [editor] + ); + + // @ 키 입력 시 변수 팝업 표시 + useEffect(() => { + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "@" || (event.key === "/" && !event.shiftKey)) { + event.preventDefault(); + setIsVariablePopoverOpen(true); + } + }; + + const editorElement = editor.view.dom; + editorElement.addEventListener("keydown", handleKeyDown); + + return () => { + editorElement.removeEventListener("keydown", handleKeyDown); + }; + }, [editor]); + + if (!editor) { + return null; + } + + return ( +
+ {/* 툴바 */} +
+
+ + + + + + + + + 검색 결과가 없습니다 + + {filteredVariables.map((variable) => ( + insertVariable(variable)} + className="cursor-pointer" + > +
+
+ + {variable.displayName} + + + {variable.name} + +
+ {variable.description && ( + + {variable.description} + + )} +
+
+ ))} +
+
+
+
+
+
+ +
+ @ 또는{" "} + / 로 변수 삽입 +
+
+ + {/* 에디터 영역 */} +
+ +
+ + {/* 스타일 */} + +
+ ); +} + +export default VariableTagEditor; + diff --git a/frontend/components/dataflow/node-editor/editors/VariableTagExtension.ts b/frontend/components/dataflow/node-editor/editors/VariableTagExtension.ts new file mode 100644 index 00000000..487e55c0 --- /dev/null +++ b/frontend/components/dataflow/node-editor/editors/VariableTagExtension.ts @@ -0,0 +1,210 @@ +/** + * TipTap 변수 태그 확장 + * 본문 내에서 {{변수명}} 형태의 변수를 시각적 태그로 표시 + */ + +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +// 변수 태그 노드 타입 +export interface VariableTagOptions { + HTMLAttributes: Record; +} + +// 변수 태그 속성 +export interface VariableTagAttributes { + variableName: string; // 실제 변수명 (예: customerName) + displayName: string; // 표시명 (예: 고객명) +} + +/** + * 변수 태그 TipTap 확장 + */ +export const VariableTagExtension = Node.create({ + name: "variableTag", + + group: "inline", + + inline: true, + + // atom: true로 설정하면 커서가 태그 내부로 들어가지 않음 + atom: true, + + // 선택 가능하도록 설정 + selectable: true, + + // 드래그 가능하도록 설정 + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + variableName: { + default: "", + parseHTML: (element) => element.getAttribute("data-variable-name"), + renderHTML: (attributes) => ({ + "data-variable-name": attributes.variableName, + }), + }, + displayName: { + default: "", + parseHTML: (element) => element.getAttribute("data-display-name"), + renderHTML: (attributes) => ({ + "data-display-name": attributes.displayName, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-type="variable-tag"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": "variable-tag", + class: "variable-tag", + }), + HTMLAttributes["data-display-name"] || HTMLAttributes["data-variable-name"], + ]; + }, + + // 키보드 명령어 추가 (Backspace로 삭제) + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({ tr, state }) => { + let isVariableTag = false; + const { selection } = state; + const { empty, anchor } = selection; + + if (!empty) { + return false; + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isVariableTag = true; + tr.delete(pos, pos + node.nodeSize); + return false; + } + }); + + return isVariableTag; + }), + }; + }, +}); + +/** + * 텍스트를 에디터 JSON으로 변환 + * "안녕하세요, {{customerName}} 님" → TipTap JSON + */ +export function textToEditorContent( + text: string, + variableMap: Record // { customerName: "고객명" } +): any { + if (!text) { + return { + type: "doc", + content: [{ type: "paragraph" }], + }; + } + + const regex = /\{\{(\w+)\}\}/g; + const content: any[] = []; + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + // 변수 앞의 일반 텍스트 + if (match.index > lastIndex) { + const beforeText = text.slice(lastIndex, match.index); + if (beforeText) { + content.push({ type: "text", text: beforeText }); + } + } + + // 변수 태그 + const variableName = match[1]; + const displayName = variableMap[variableName] || variableName; + content.push({ + type: "variableTag", + attrs: { + variableName, + displayName, + }, + }); + + lastIndex = regex.lastIndex; + } + + // 마지막 텍스트 + if (lastIndex < text.length) { + content.push({ type: "text", text: text.slice(lastIndex) }); + } + + // content가 비어있으면 빈 paragraph 추가 + if (content.length === 0) { + return { + type: "doc", + content: [{ type: "paragraph" }], + }; + } + + return { + type: "doc", + content: [ + { + type: "paragraph", + content, + }, + ], + }; +} + +/** + * 에디터 JSON을 텍스트로 변환 + * TipTap JSON → "안녕하세요, {{customerName}} 님" + */ +export function editorContentToText(json: any): string { + if (!json || !json.content) { + return ""; + } + + let result = ""; + + const processNode = (node: any) => { + if (node.type === "text") { + result += node.text || ""; + } else if (node.type === "variableTag") { + result += `{{${node.attrs?.variableName || ""}}}`; + } else if (node.type === "paragraph") { + if (node.content) { + node.content.forEach(processNode); + } + result += "\n"; + } else if (node.type === "hardBreak") { + result += "\n"; + } else if (node.content) { + node.content.forEach(processNode); + } + }; + + json.content.forEach(processNode); + + // 마지막 줄바꿈 제거 + return result.replace(/\n$/, ""); +} + diff --git a/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx index ea8e05dc..4efc5b6a 100644 --- a/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx @@ -2,16 +2,16 @@ /** * 메일 발송 액션 노드 - * SMTP를 통해 이메일을 발송하는 노드 + * 등록된 메일 계정을 선택하여 이메일을 발송하는 노드 */ import { memo } from "react"; import { Handle, Position, NodeProps } from "reactflow"; -import { Mail, Server } from "lucide-react"; +import { Mail, User, CheckCircle } from "lucide-react"; import type { EmailActionNodeData } from "@/types/node-editor"; export const EmailActionNode = memo(({ data, selected }: NodeProps) => { - const hasSmtpConfig = data.smtpConfig?.host && data.smtpConfig?.port; + const hasAccount = !!data.accountId; const hasRecipient = data.to && data.to.trim().length > 0; const hasSubject = data.subject && data.subject.trim().length > 0; @@ -38,16 +38,17 @@ export const EmailActionNode = memo(({ data, selected }: NodeProps - {/* SMTP 설정 상태 */} + {/* 발송 계정 상태 */}
- + - {hasSmtpConfig ? ( - - {data.smtpConfig.host}:{data.smtpConfig.port} + {hasAccount ? ( + + + 계정 선택됨 ) : ( - SMTP 설정 필요 + 발송 계정 선택 필요 )}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx index 211dc6db..24c5cdc1 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx @@ -3,9 +3,10 @@ /** * 메일 발송 노드 속성 편집 * - 메일관리에서 등록한 계정을 선택하여 발송 + * - 변수 태그 에디터로 본문 편집 */ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -18,6 +19,7 @@ import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { getMailAccounts, type MailAccount } from "@/lib/api/mail"; import type { EmailActionNodeData } from "@/types/node-editor"; +import { VariableTagEditor, type VariableInfo } from "../../editors/VariableTagEditor"; interface EmailActionPropertiesProps { nodeId: string; @@ -25,13 +27,92 @@ interface EmailActionPropertiesProps { } export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) { - const { updateNode } = useFlowEditorStore(); + const { updateNode, nodes, edges } = useFlowEditorStore(); // 메일 계정 목록 const [mailAccounts, setMailAccounts] = useState([]); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [accountError, setAccountError] = useState(null); + // 🆕 플로우에서 사용 가능한 변수 목록 계산 + const availableVariables = useMemo(() => { + const variables: VariableInfo[] = []; + + // 기본 시스템 변수 + variables.push( + { name: "timestamp", displayName: "현재 시간", description: "메일 발송 시점의 타임스탬프" }, + { name: "sourceData", displayName: "소스 데이터", description: "전체 소스 데이터 (JSON)" } + ); + + // 현재 노드에 연결된 소스 노드들에서 필드 정보 수집 + const incomingEdges = edges.filter((e) => e.target === nodeId); + + for (const edge of incomingEdges) { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) continue; + + const nodeData = sourceNode.data as any; + + // 테이블 소스 노드인 경우 + if (sourceNode.type === "tableSource" && nodeData.fields) { + const tableName = nodeData.tableName || "테이블"; + nodeData.fields.forEach((field: any) => { + variables.push({ + name: field.name, + displayName: field.displayName || field.label || field.name, + type: field.type, + description: `${tableName} 테이블의 필드`, + }); + }); + } + + // 외부 DB 소스 노드인 경우 + if (sourceNode.type === "externalDBSource" && nodeData.fields) { + const tableName = nodeData.tableName || "외부 테이블"; + nodeData.fields.forEach((field: any) => { + variables.push({ + name: field.name, + displayName: field.displayName || field.label || field.name, + type: field.type, + description: `${tableName} (외부 DB) 필드`, + }); + }); + } + + // REST API 소스 노드인 경우 + if (sourceNode.type === "restAPISource" && nodeData.responseFields) { + nodeData.responseFields.forEach((field: any) => { + variables.push({ + name: field.name, + displayName: field.displayName || field.label || field.name, + type: field.type, + description: "REST API 응답 필드", + }); + }); + } + + // 데이터 변환 노드인 경우 - 출력 필드 추가 + if (sourceNode.type === "dataTransform" && nodeData.transformations) { + nodeData.transformations.forEach((transform: any) => { + if (transform.targetField) { + variables.push({ + name: transform.targetField, + displayName: transform.targetField, + description: "데이터 변환 결과 필드", + }); + } + }); + } + } + + // 중복 제거 + const uniqueVariables = variables.filter( + (v, index, self) => index === self.findIndex((t) => t.name === v.name) + ); + + return uniqueVariables; + }, [nodes, edges, nodeId]); + // 로컬 상태 const [displayName, setDisplayName] = useState(data.displayName || "메일 발송"); @@ -473,31 +554,71 @@ export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesPro - 텍스트 - HTML + 텍스트 (변수 태그 에디터) + HTML (직접 입력)
-