From c1a2d926cd33cdf0c39e7e7d2f4574198c3bc790 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 11 Oct 2025 12:03:05 +0200 Subject: [PATCH] feat: add document naming dialog before creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a user-friendly dialog that prompts for document names before creating new documents, replacing the default "Untitled Analysis" behavior. Features: - InputDialog component for text input with validation - useCreateDocument hook to centralize naming logic - Pre-filled default value "Untitled Analysis" - Validation to prevent empty document names - Helpful placeholder text with examples - Keyboard shortcuts (Enter/Escape) - Auto-focus and select input field Updated all document creation entry points: - Menu Bar: "New Document" and "New from Template" - Document Tabs: "+" button - Document Manager: "New Document" button - Empty State: "New Document" button - Keyboard shortcut: Ctrl+N This provides a consistent UX across the application and reduces code duplication by using a single reusable hook. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md | 0 src/App.tsx | 6 + src/components/Common/InputDialog.tsx | 190 ++++++++++++++++++ src/components/Editor/GraphEditor.tsx | 23 ++- src/components/Menu/MenuBar.tsx | 54 ++++- src/components/Workspace/DocumentManager.tsx | 17 +- src/components/Workspace/DocumentTabs.tsx | 11 +- src/hooks/useCreateDocument.tsx | 65 ++++++ src/hooks/useGlobalShortcuts.ts | 6 +- 9 files changed, 343 insertions(+), 29 deletions(-) rename DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md => docs/DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md (100%) create mode 100644 src/components/Common/InputDialog.tsx create mode 100644 src/hooks/useCreateDocument.tsx diff --git a/DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md b/docs/DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md similarity index 100% rename from DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md rename to docs/DIRECTIONAL_RELATIONSHIPS_UX_SPEC.md diff --git a/src/App.tsx b/src/App.tsx index 8f4e111..7239975 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useWorkspaceStore } from "./stores/workspaceStore"; import { usePanelStore } from "./stores/panelStore"; +import { useCreateDocument } from "./hooks/useCreateDocument"; import type { Actor, Relation } from "./types"; import type { ExportOptions } from "./utils/graphExport"; @@ -41,6 +42,7 @@ function AppContent() { const { undo, redo } = useDocumentHistory(); const { activeDocumentId } = useWorkspaceStore(); const { leftPanelVisible, rightPanelVisible } = usePanelStore(); + const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); const [showDocumentManager, setShowDocumentManager] = useState(false); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [selectedNode, setSelectedNode] = useState(null); @@ -80,6 +82,7 @@ function AppContent() { useGlobalShortcuts({ onUndo: undo, onRedo: redo, + onNewDocument: handleNewDocument, onOpenDocumentManager: () => setShowDocumentManager(true), onOpenHelp: () => setShowKeyboardHelp(true), onFitView: handleFitView, @@ -212,6 +215,9 @@ function AppContent() { {/* Toast Notifications */} + + {/* New Document Dialog */} + {NewDocumentDialog} ); } diff --git a/src/components/Common/InputDialog.tsx b/src/components/Common/InputDialog.tsx new file mode 100644 index 0000000..241d08a --- /dev/null +++ b/src/components/Common/InputDialog.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState, useRef } from 'react'; +import InfoIcon from '@mui/icons-material/Info'; +import ErrorIcon from '@mui/icons-material/Error'; + +/** + * InputDialog - Input prompt dialog + * + * A modal dialog component for getting text input from the user. + * + * Features: + * - Customizable title and message + * - Optional placeholder text + * - Input validation + * - Keyboard support (Enter to confirm, Escape to cancel) + * - Auto-focus on input field + * - Backdrop click to cancel + */ + +export type InputDialogSeverity = 'info' | 'error'; + +interface Props { + isOpen: boolean; + title: string; + message?: string; + placeholder?: string; + defaultValue?: string; + confirmLabel?: string; + cancelLabel?: string; + severity?: InputDialogSeverity; + validateInput?: (value: string) => string | null; // Returns error message or null if valid + onConfirm: (value: string) => void; + onCancel: () => void; +} + +const InputDialog = ({ + isOpen, + title, + message, + placeholder = '', + defaultValue = '', + confirmLabel = 'OK', + cancelLabel = 'Cancel', + severity = 'info', + validateInput, + onConfirm, + onCancel, +}: Props) => { + const [value, setValue] = useState(defaultValue); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + // Reset value and error when dialog opens/closes + useEffect(() => { + if (isOpen) { + setValue(defaultValue); + setError(null); + // Focus input field after a short delay to ensure it's rendered + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + } + }, [isOpen, defaultValue]); + + const handleConfirm = () => { + // Validate input if validator provided + if (validateInput) { + const validationError = validateInput(value); + if (validationError) { + setError(validationError); + return; + } + } + + onConfirm(value); + }; + + const handleCancel = () => { + setError(null); + onCancel(); + }; + + // Handle keyboard shortcuts + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, value]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!isOpen) return null; + + // Severity-based styling + const severityConfig = { + info: { + icon: , + confirmClass: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500', + }, + error: { + icon: , + confirmClass: 'bg-red-600 hover:bg-red-700 focus:ring-red-500', + }, + }; + + const config = severityConfig[severity]; + + return ( +
+
e.stopPropagation()} + > + {/* Content */} +
+
+ {/* Icon */} +
{config.icon}
+ + {/* Text Content */} +
+

+ {title} +

+ {message && ( +

+ {message} +

+ )} + + {/* Input Field */} + { + setValue(e.target.value); + if (error) setError(null); // Clear error on change + }} + placeholder={placeholder} + className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 ${ + error + ? 'border-red-300 focus:ring-red-500' + : 'border-gray-300 focus:ring-blue-500' + }`} + /> + + {/* Error Message */} + {error && ( +

+ {error} +

+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default InputDialog; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 98b4c99..a8cb91b 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -26,6 +26,7 @@ import { useDocumentHistory } from "../../hooks/useDocumentHistory"; import { useEditorStore } from "../../stores/editorStore"; import { useActiveDocument } from "../../stores/workspace/useActiveDocument"; import { useWorkspaceStore } from "../../stores/workspaceStore"; +import { useCreateDocument } from "../../hooks/useCreateDocument"; import CustomNode from "../Nodes/CustomNode"; import CustomEdge from "../Edges/CustomEdge"; import ContextMenu from "./ContextMenu"; @@ -63,7 +64,8 @@ interface GraphEditorProps { const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => { // Sync with workspace active document const { activeDocumentId } = useActiveDocument(); - const { saveViewport, getViewport, createDocument } = useWorkspaceStore(); + const { saveViewport, getViewport } = useWorkspaceStore(); + const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); // Graph export functionality const { exportPNG, exportSVG } = useGraphExport(); @@ -555,14 +557,17 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq // Show empty state when no document is active if (!activeDocumentId) { return ( - createDocument()} - onOpenDocumentManager={() => { - // This will be handled by the parent component - // We'll trigger it via a custom event - window.dispatchEvent(new CustomEvent("openDocumentManager")); - }} - /> + <> + { + // This will be handled by the parent component + // We'll trigger it via a custom event + window.dispatchEvent(new CustomEvent("openDocumentManager")); + }} + /> + {NewDocumentDialog} + ); } diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 6525dfc..bc41400 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -5,6 +5,7 @@ import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import DocumentManager from '../Workspace/DocumentManager'; import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; +import InputDialog from '../Common/InputDialog'; import { useConfirm } from '../../hooks/useConfirm'; import { useShortcutLabels } from '../../hooks/useShortcutLabels'; import type { ExportOptions } from '../../utils/graphExport'; @@ -31,6 +32,8 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll, o const [showDocumentManager, setShowDocumentManager] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false); + const [showNewDocDialog, setShowNewDocDialog] = useState(false); + const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); const menuRef = useRef(null); const { confirm, ConfirmDialogComponent } = useConfirm(); const { getShortcutLabel } = useShortcutLabels(); @@ -72,9 +75,14 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll, o }, []); const handleNewDocument = useCallback(() => { - createDocument(); + setShowNewDocDialog(true); closeMenu(); - }, [createDocument, closeMenu]); + }, [closeMenu]); + + const handleConfirmNewDocument = useCallback((title: string) => { + createDocument(title); + setShowNewDocDialog(false); + }, [createDocument]); const handleNewDocumentFromTemplate = useCallback(() => { if (!activeDocumentId) { @@ -82,12 +90,18 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll, o closeMenu(); return; } - const newDocId = createDocumentFromTemplate(activeDocumentId); + setShowNewFromTemplateDialog(true); + closeMenu(); + }, [activeDocumentId, closeMenu]); + + const handleConfirmNewFromTemplate = useCallback((title: string) => { + if (!activeDocumentId) return; + const newDocId = createDocumentFromTemplate(activeDocumentId, title); if (newDocId) { switchToDocument(newDocId); } - closeMenu(); - }, [createDocumentFromTemplate, activeDocumentId, switchToDocument, closeMenu]); + setShowNewFromTemplateDialog(false); + }, [createDocumentFromTemplate, activeDocumentId, switchToDocument]); const handleOpenDocumentManager = useCallback(() => { setShowDocumentManager(true); @@ -404,6 +418,36 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll, o onClose={() => setShowEdgeConfig(false)} /> + {/* Input Dialogs */} + setShowNewDocDialog(false)} + validateInput={(value) => { + if (!value.trim()) return 'Document name cannot be empty'; + return null; + }} + /> + setShowNewFromTemplateDialog(false)} + validateInput={(value) => { + if (!value.trim()) return 'Document name cannot be empty'; + return null; + }} + /> + {/* Confirmation Dialog */} {ConfirmDialogComponent} diff --git a/src/components/Workspace/DocumentManager.tsx b/src/components/Workspace/DocumentManager.tsx index 9ee27f8..e74bcb9 100644 --- a/src/components/Workspace/DocumentManager.tsx +++ b/src/components/Workspace/DocumentManager.tsx @@ -7,6 +7,7 @@ import FolderZipIcon from '@mui/icons-material/FolderZip'; import SearchIcon from '@mui/icons-material/Search'; import { useWorkspaceStore } from '../../stores/workspaceStore'; import { useConfirm } from '../../hooks/useConfirm'; +import { useCreateDocument } from '../../hooks/useCreateDocument'; import DocumentCard from './DocumentCard'; /** @@ -29,7 +30,6 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => { const { documentMetadata, documentOrder, - createDocument, switchToDocument, duplicateDocument, exportDocument, @@ -41,6 +41,7 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => { } = useWorkspaceStore(); const { confirm, ConfirmDialogComponent } = useConfirm(); + const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); const [searchQuery, setSearchQuery] = useState(''); // Get all document IDs from metadata (includes both open and closed documents) @@ -68,11 +69,10 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => { }); }, [allDocumentIds, documentMetadata, searchQuery]); - const handleNewDocument = useCallback(() => { - const newDocId = createDocument(); - switchToDocument(newDocId); + const handleNewDocumentClick = useCallback(() => { + handleNewDocument(); onClose(); - }, [createDocument, switchToDocument, onClose]); + }, [handleNewDocument, onClose]); const handleImportDocument = useCallback(async () => { const newDocId = await importDocumentFromFile(); @@ -158,7 +158,7 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => {
); }; diff --git a/src/hooks/useCreateDocument.tsx b/src/hooks/useCreateDocument.tsx new file mode 100644 index 0000000..0de00b0 --- /dev/null +++ b/src/hooks/useCreateDocument.tsx @@ -0,0 +1,65 @@ +import { useState, useCallback } from 'react'; +import { useWorkspaceStore } from '../stores/workspaceStore'; +import InputDialog from '../components/Common/InputDialog'; + +/** + * useCreateDocument Hook + * + * Provides a consistent document creation flow with naming dialog + * across the application. Returns both the handler function and + * the dialog component to render. + * + * Usage: + * ```tsx + * const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); + * + * return ( + * <> + * + * {NewDocumentDialog} + * + * ); + * ``` + */ +export function useCreateDocument() { + const [showDialog, setShowDialog] = useState(false); + const { createDocument } = useWorkspaceStore(); + + const handleNewDocument = useCallback(() => { + setShowDialog(true); + }, []); + + const handleConfirm = useCallback( + (title: string) => { + createDocument(title); + setShowDialog(false); + }, + [createDocument] + ); + + const handleCancel = useCallback(() => { + setShowDialog(false); + }, []); + + const NewDocumentDialog = ( + { + if (!value.trim()) return 'Document name cannot be empty'; + return null; + }} + /> + ); + + return { + handleNewDocument, + NewDocumentDialog, + }; +} diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index 9d92991..b185f81 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -11,6 +11,7 @@ import type { KeyboardShortcut } from "./useKeyboardShortcutManager"; */ interface UseGlobalShortcutsOptions { + onNewDocument?: () => void; onOpenDocumentManager?: () => void; onUndo?: () => void; onRedo?: () => void; @@ -26,7 +27,6 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) { activeDocumentId, switchToDocument, closeDocument, - createDocument, saveDocument, } = useWorkspaceStore(); @@ -38,8 +38,9 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) { description: "New Document", key: "n", ctrl: true, - handler: () => createDocument(), + handler: () => options.onNewDocument?.(), category: "Document Management", + enabled: !!options.onNewDocument, }, { id: "open-document-manager", @@ -201,7 +202,6 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) { activeDocumentId, switchToDocument, closeDocument, - createDocument, saveDocument, options, ]);