From 84426b82cf045aff04642b361c62ef4d90679d34 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 12 Feb 2026 14:23:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-button):=20pop-button=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20MVP=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PopButtonComponent: 5가지 액션 타입(save/delete/api/modal/event) + 후속 액션 체이닝 - PopButtonConfigPanel: 프리셋 기반 설정(save/delete/logout/menu/modal-open/custom) - 확인 다이얼로그(ConfirmConfig) 선택 기능 - usePopEvent/useDataSource 훅 연동 - PopComponentType에 pop-button 추가, 팔레트 등록 Co-authored-by: Cursor --- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 3 +- .../registry/pop-components/pop-button.tsx | 1047 +++++++++++++++++ 4 files changed, 1058 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-button.tsx diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index a4bfc6ee..47993653 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: LayoutGrid, description: "테이블 데이터를 카드 형태로 표시", }, + { + type: "pop-button", + label: "버튼", + icon: MousePointerClick, + description: "액션 버튼 (저장/삭제/API/모달)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 8814fb3e..928ab506 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button"; /** * 데이터 흐름 정의 @@ -345,6 +345,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; + // navigate + targetScreenId?: string; + params?: Record; +} + +/** 드롭다운 모달 메뉴 항목 */ +export interface ModalMenuItem { + label: string; + screenId?: string; + action?: string; // 커스텀 이벤트명 +} + +/** 메인 액션 설정 */ +export interface ButtonMainAction { + type: ButtonActionType; + // save/delete 공통 + targetTable?: string; + // api + apiEndpoint?: string; + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; + // modal + modalMode?: ModalMode; + modalScreenId?: string; + modalTitle?: string; + modalItems?: ModalMenuItem[]; + // event + eventName?: string; + eventPayload?: Record; +} + +/** 프리셋 이름 */ +export type ButtonPreset = + | "save" + | "delete" + | "logout" + | "menu" + | "modal-open" + | "custom"; + +/** pop-button 전체 설정 */ +export interface PopButtonConfig { + label: string; + variant: ButtonVariant; + icon?: string; // Lucide 아이콘 이름 + iconOnly?: boolean; + preset: ButtonPreset; + confirm?: ConfirmConfig; + action: ButtonMainAction; + followUpActions?: FollowUpAction[]; +} + +// ======================================== +// 상수 +// ======================================== + +/** 메인 액션 타입 라벨 */ +const ACTION_TYPE_LABELS: Record = { + save: "저장", + delete: "삭제", + api: "API 호출", + modal: "모달 열기", + event: "이벤트 발행", +}; + +/** 후속 액션 타입 라벨 */ +const FOLLOWUP_TYPE_LABELS: Record = { + event: "이벤트 발행", + refresh: "새로고침", + navigate: "화면 이동", +}; + +/** variant 라벨 */ +const VARIANT_LABELS: Record = { + default: "기본 (Primary)", + secondary: "보조 (Secondary)", + outline: "외곽선 (Outline)", + destructive: "위험 (Destructive)", +}; + +/** 프리셋 라벨 */ +const PRESET_LABELS: Record = { + save: "저장", + delete: "삭제", + logout: "로그아웃", + menu: "메뉴 (드롭다운)", + "modal-open": "모달 열기", + custom: "직접 설정", +}; + +/** 모달 모드 라벨 */ +const MODAL_MODE_LABELS: Record = { + dropdown: "드롭다운", + fullscreen: "전체 모달", + "screen-ref": "화면 선택", +}; + +/** API 메서드 라벨 */ +const API_METHOD_LABELS: Record = { + GET: "GET", + POST: "POST", + PUT: "PUT", + DELETE: "DELETE", +}; + +/** 주요 Lucide 아이콘 목록 (설정 패널용) */ +const ICON_OPTIONS: { value: string; label: string }[] = [ + { value: "none", label: "없음" }, + { value: "Save", label: "저장 (Save)" }, + { value: "Trash2", label: "삭제 (Trash)" }, + { value: "LogOut", label: "로그아웃 (LogOut)" }, + { value: "Menu", label: "메뉴 (Menu)" }, + { value: "ExternalLink", label: "외부링크 (ExternalLink)" }, + { value: "Plus", label: "추가 (Plus)" }, + { value: "Check", label: "확인 (Check)" }, + { value: "X", label: "취소 (X)" }, + { value: "Edit", label: "수정 (Edit)" }, + { value: "Search", label: "검색 (Search)" }, + { value: "RefreshCw", label: "새로고침 (RefreshCw)" }, + { value: "Download", label: "다운로드 (Download)" }, + { value: "Upload", label: "업로드 (Upload)" }, + { value: "Send", label: "전송 (Send)" }, + { value: "Copy", label: "복사 (Copy)" }, + { value: "Settings", label: "설정 (Settings)" }, + { value: "ChevronDown", label: "아래 화살표 (ChevronDown)" }, +]; + +/** 프리셋별 기본 설정 */ +const PRESET_DEFAULTS: Record> = { + save: { + label: "저장", + variant: "default", + icon: "Save", + confirm: { enabled: false }, + action: { type: "save" }, + }, + delete: { + label: "삭제", + variant: "destructive", + icon: "Trash2", + confirm: { enabled: true, message: "" }, + action: { type: "delete" }, + }, + logout: { + label: "로그아웃", + variant: "outline", + icon: "LogOut", + confirm: { enabled: true, message: "로그아웃 하시겠습니까?" }, + action: { + type: "api", + apiEndpoint: "/api/auth/logout", + apiMethod: "POST", + }, + }, + menu: { + label: "메뉴", + variant: "secondary", + icon: "Menu", + confirm: { enabled: false }, + action: { type: "modal", modalMode: "dropdown" }, + }, + "modal-open": { + label: "열기", + variant: "outline", + icon: "ExternalLink", + confirm: { enabled: false }, + action: { type: "modal", modalMode: "fullscreen" }, + }, + custom: { + label: "버튼", + variant: "default", + icon: "none", + confirm: { enabled: false }, + action: { type: "save" }, + }, +}; + +/** 확인 다이얼로그 기본 메시지 (액션별) */ +const DEFAULT_CONFIRM_MESSAGES: Record = { + save: "저장하시겠습니까?", + delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + api: "실행하시겠습니까?", + modal: "열기하시겠습니까?", + event: "실행하시겠습니까?", +}; + +// ======================================== +// 헬퍼 함수 +// ======================================== + +/** 섹션 구분선 */ +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + +/** Lucide 아이콘 동적 렌더링 */ +function DynamicLucideIcon({ + name, + size = 16, + className, +}: { + name: string; + size?: number; + className?: string; +}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const IconComponent = (LucideIcons as any)[name]; + if (!IconComponent) return null; + return ; +} + +// ======================================== +// STEP 2: 메인 컴포넌트 +// ======================================== + +interface PopButtonComponentProps { + config?: PopButtonConfig; + label?: string; + isDesignMode?: boolean; +} + +export function PopButtonComponent({ + config, + label, + isDesignMode, +}: PopButtonComponentProps) { + const [showConfirm, setShowConfirm] = useState(false); + + // 이벤트 훅 (1차: screenId 빈 문자열 - 후속 통합에서 주입) + const { publish } = usePopEvent(""); + + // 데이터 훅 (save/delete용, tableName 없으면 자동 스킵) + const { save, remove, refetch } = useDataSource({ + tableName: config?.action?.targetTable || "", + }); + + // 확인 메시지 결정 + const getConfirmMessage = useCallback((): string => { + if (config?.confirm?.message) return config.confirm.message; + return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]; + }, [config?.confirm?.message, config?.action?.type]); + + // 메인 액션 실행 + const executeMainAction = useCallback(async (): Promise => { + const action = config?.action; + if (!action) return false; + + try { + switch (action.type) { + case "save": { + // sharedData에서 데이터 수집 (후속 통합에서 실제 구현) + const record: Record = {}; + const result = await save(record); + if (!result.success) { + toast.error(result.error || "저장 실패"); + return false; + } + toast.success("저장되었습니다"); + return true; + } + case "delete": { + // sharedData에서 ID 수집 (후속 통합에서 실제 구현) + const result = await remove(""); + if (!result.success) { + toast.error(result.error || "삭제 실패"); + return false; + } + toast.success("삭제되었습니다"); + return true; + } + case "api": { + // 1차: toast 알림만 (실제 API 호출은 후속) + toast.info( + `API 호출 예정: ${action.apiMethod || "POST"} ${action.apiEndpoint || "(미설정)"}` + ); + return true; + } + case "modal": { + // 1차: toast 알림만 (실제 모달은 후속) + toast.info( + `모달 열기 예정: ${MODAL_MODE_LABELS[action.modalMode || "fullscreen"]}` + ); + return true; + } + case "event": { + if (action.eventName) { + publish(action.eventName, action.eventPayload); + toast.success(`이벤트 발행: ${action.eventName}`); + } + return true; + } + default: + return false; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "액션 실행 실패"; + toast.error(message); + return false; + } + }, [config?.action, save, remove, publish]); + + // 후속 액션 순차 실행 + const executeFollowUpActions = useCallback(async () => { + const actions = config?.followUpActions; + if (!actions || actions.length === 0) return; + + for (const fa of actions) { + try { + switch (fa.type) { + case "event": + if (fa.eventName) { + publish(fa.eventName, fa.eventPayload); + } + break; + case "refresh": + publish("__refresh__"); + await refetch(); + break; + case "navigate": + if (fa.targetScreenId) { + window.location.href = `/pop/screens/${fa.targetScreenId}`; + return; // navigate 후 중단 + } + break; + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "후속 액션 실패"; + toast.error(message); + // 개별 실패 시 다음 진행 + } + } + }, [config?.followUpActions, publish, refetch]); + + // 클릭 핸들러 + const handleClick = useCallback(async () => { + // 디자인 모드: 실제 실행 안 함 + if (isDesignMode) { + toast.info( + `[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` + ); + return; + } + + // 확인 다이얼로그 필요 시 + if (config?.confirm?.enabled) { + setShowConfirm(true); + return; + } + + // 바로 실행 + const success = await executeMainAction(); + if (success) { + await executeFollowUpActions(); + } + }, [ + isDesignMode, + config?.confirm?.enabled, + config?.action?.type, + executeMainAction, + executeFollowUpActions, + ]); + + // 확인 후 실행 + const handleConfirmExecute = useCallback(async () => { + setShowConfirm(false); + const success = await executeMainAction(); + if (success) { + await executeFollowUpActions(); + } + }, [executeMainAction, executeFollowUpActions]); + + // 외형 + const buttonLabel = config?.label || label || "버튼"; + const variant = config?.variant || "default"; + const iconName = config?.icon || ""; + const isIconOnly = config?.iconOnly || false; + + return ( + <> +
+ +
+ + {/* 확인 다이얼로그 */} + + + + + 실행 확인 + + + {getConfirmMessage()} + + + + + 취소 + + + 확인 + + + + + + ); +} + +// ======================================== +// STEP 3: 설정 패널 +// ======================================== + +interface PopButtonConfigPanelProps { + config: PopButtonConfig; + onUpdate: (config: PopButtonConfig) => void; +} + +export function PopButtonConfigPanel({ + config, + onUpdate, +}: PopButtonConfigPanelProps) { + const isCustom = config?.preset === "custom"; + + // 프리셋 변경 핸들러 + const handlePresetChange = (preset: ButtonPreset) => { + const defaults = PRESET_DEFAULTS[preset]; + onUpdate({ + ...config, + preset, + label: defaults.label || config.label, + variant: defaults.variant || config.variant, + icon: defaults.icon ?? config.icon, + confirm: defaults.confirm || config.confirm, + action: (defaults.action as ButtonMainAction) || config.action, + // 후속 액션은 프리셋 변경 시 유지 + }); + }; + + // 메인 액션 업데이트 헬퍼 + const updateAction = (updates: Partial) => { + onUpdate({ + ...config, + action: { ...config.action, ...updates }, + }); + }; + + return ( +
+ {/* 프리셋 선택 */} + + + {!isCustom && ( +

+ 프리셋 변경 시 외형과 액션이 자동 설정됩니다 +

+ )} + + {/* 외형 설정 */} + +
+ {/* 라벨 */} +
+ + onUpdate({ ...config, label: e.target.value })} + placeholder="버튼 텍스트" + className="h-8 text-xs" + /> +
+ + {/* variant */} +
+ + +
+ + {/* 아이콘 */} +
+ + +
+ + {/* 아이콘 전용 모드 */} +
+ + onUpdate({ ...config, iconOnly: checked === true }) + } + /> + +
+
+ + {/* 메인 액션 */} + +
+ {/* 액션 타입 */} +
+ + + {!isCustom && ( +

+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +

+ )} +
+ + {/* 액션별 추가 설정 */} + +
+ + {/* 확인 다이얼로그 */} + +
+
+ + onUpdate({ + ...config, + confirm: { + ...config?.confirm, + enabled: checked === true, + }, + }) + } + /> + +
+ {config?.confirm?.enabled && ( +
+ + onUpdate({ + ...config, + confirm: { + ...config?.confirm, + enabled: true, + message: e.target.value, + }, + }) + } + placeholder="비워두면 기본 메시지 사용" + className="h-8 text-xs" + /> +

+ 기본:{" "} + {DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]} +

+
+ )} +
+ + {/* 후속 액션 */} + + + onUpdate({ ...config, followUpActions: actions }) + } + /> +
+ ); +} + +// ======================================== +// 액션 세부 필드 (타입별) +// ======================================== + +function ActionDetailFields({ + action, + onUpdate, + disabled, +}: { + action?: ButtonMainAction; + onUpdate: (updates: Partial) => void; + disabled?: boolean; +}) { + const actionType = action?.type || "save"; + + switch (actionType) { + case "save": + case "delete": + return ( +
+ + onUpdate({ targetTable: e.target.value })} + placeholder="테이블명 입력" + className="h-8 text-xs" + disabled={disabled} + /> +
+ ); + + case "api": + return ( +
+
+ + onUpdate({ apiEndpoint: e.target.value })} + placeholder="/api/..." + className="h-8 text-xs" + disabled={disabled} + /> +
+
+ + +
+
+ ); + + case "modal": + return ( +
+
+ + +
+ {action?.modalMode === "screen-ref" && ( +
+ + + onUpdate({ modalScreenId: e.target.value }) + } + placeholder="화면 ID" + className="h-8 text-xs" + disabled={disabled} + /> +
+ )} +
+ + onUpdate({ modalTitle: e.target.value })} + placeholder="모달 제목 (선택)" + className="h-8 text-xs" + disabled={disabled} + /> +
+
+ ); + + case "event": + return ( +
+
+ + onUpdate({ eventName: e.target.value })} + placeholder="예: data-saved, item-selected" + className="h-8 text-xs" + disabled={disabled} + /> +
+
+ ); + + default: + return null; + } +} + +// ======================================== +// 후속 액션 편집기 +// ======================================== + +function FollowUpActionsEditor({ + actions, + onUpdate, +}: { + actions: FollowUpAction[]; + onUpdate: (actions: FollowUpAction[]) => void; +}) { + // 추가 + const handleAdd = () => { + onUpdate([...actions, { type: "event" }]); + }; + + // 삭제 + const handleRemove = (index: number) => { + onUpdate(actions.filter((_, i) => i !== index)); + }; + + // 수정 + const handleUpdate = (index: number, updates: Partial) => { + const newActions = [...actions]; + newActions[index] = { ...newActions[index], ...updates }; + onUpdate(newActions); + }; + + return ( +
+ {actions.length === 0 && ( +

+ 메인 액션 성공 후 순차 실행할 후속 동작 +

+ )} + + {actions.map((fa, idx) => ( +
+
+ + 후속 {idx + 1} + + +
+ + {/* 타입 선택 */} + + + {/* 타입별 추가 입력 */} + {fa.type === "event" && ( + + handleUpdate(idx, { eventName: e.target.value }) + } + placeholder="이벤트명" + className="h-7 text-xs" + /> + )} + {fa.type === "navigate" && ( + + handleUpdate(idx, { targetScreenId: e.target.value }) + } + placeholder="화면 ID" + className="h-7 text-xs" + /> + )} +
+ ))} + + +
+ ); +} + +// ======================================== +// STEP 4: 미리보기 + 레지스트리 등록 +// ======================================== + +function PopButtonPreviewComponent({ + config, +}: { + config?: PopButtonConfig; +}) { + const buttonLabel = config?.label || "버튼"; + const variant = config?.variant || "default"; + const iconName = config?.icon || ""; + const isIconOnly = config?.iconOnly || false; + + return ( +
+ +
+ ); +} + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-button", + name: "버튼", + description: "액션 버튼 (저장/삭제/API/모달/이벤트)", + category: "action", + icon: "MousePointerClick", + component: PopButtonComponent, + configPanel: PopButtonConfigPanel, + preview: PopButtonPreviewComponent, + defaultProps: { + label: "버튼", + variant: "default", + preset: "custom", + confirm: { enabled: false }, + action: { type: "save" }, + } as PopButtonConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +});