feat: add document naming dialog before creation

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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-11 12:03:05 +02:00
parent 3a64d37f02
commit c1a2d926cd
9 changed files with 343 additions and 29 deletions

View file

@ -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<Actor | null>(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 */}
<ToastContainer />
{/* New Document Dialog */}
{NewDocumentDialog}
</div>
);
}

View file

@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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: <InfoIcon className="text-blue-600" sx={{ fontSize: 48 }} />,
confirmClass: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
},
error: {
icon: <ErrorIcon className="text-red-600" sx={{ fontSize: 48 }} />,
confirmClass: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
},
};
const config = severityConfig[severity];
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleCancel}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Content */}
<div className="p-6">
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0">{config.icon}</div>
{/* Text Content */}
<div className="flex-1 pt-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{title}
</h3>
{message && (
<p className="text-sm text-gray-600 mb-3 whitespace-pre-wrap">
{message}
</p>
)}
{/* Input Field */}
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => {
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 && (
<p className="mt-2 text-sm text-red-600">
{error}
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 rounded-b-lg flex justify-end space-x-3">
<button
onClick={handleCancel}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
{cancelLabel}
</button>
<button
onClick={handleConfirm}
className={`px-4 py-2 text-white text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 ${config.confirmClass}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
export default InputDialog;

View file

@ -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 (
<>
<EmptyState
onNewDocument={() => createDocument()}
onNewDocument={handleNewDocument}
onOpenDocumentManager={() => {
// This will be handled by the parent component
// We'll trigger it via a custom event
window.dispatchEvent(new CustomEvent("openDocumentManager"));
}}
/>
{NewDocumentDialog}
</>
);
}

View file

@ -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<MenuBarProps> = ({ 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<HTMLDivElement>(null);
const { confirm, ConfirmDialogComponent } = useConfirm();
const { getShortcutLabel } = useShortcutLabels();
@ -72,9 +75,14 @@ const MenuBar: React.FC<MenuBarProps> = ({ 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<MenuBarProps> = ({ 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<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, o
onClose={() => setShowEdgeConfig(false)}
/>
{/* Input Dialogs */}
<InputDialog
isOpen={showNewDocDialog}
title="New Document"
message="Enter a name for the new document:"
placeholder="e.g., Team Analysis, Project Relationships..."
defaultValue="Untitled Analysis"
confirmLabel="Create"
onConfirm={handleConfirmNewDocument}
onCancel={() => setShowNewDocDialog(false)}
validateInput={(value) => {
if (!value.trim()) return 'Document name cannot be empty';
return null;
}}
/>
<InputDialog
isOpen={showNewFromTemplateDialog}
title="New Document from Template"
message="Enter a name for the new document:"
placeholder="e.g., Team Analysis, Project Relationships..."
defaultValue="Untitled Analysis"
confirmLabel="Create"
onConfirm={handleConfirmNewFromTemplate}
onCancel={() => setShowNewFromTemplateDialog(false)}
validateInput={(value) => {
if (!value.trim()) return 'Document name cannot be empty';
return null;
}}
/>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</>

View file

@ -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) => {
<div className="flex flex-col gap-3 px-6 py-4 border-b border-gray-200 bg-gray-50">
<div className="flex gap-3 flex-wrap">
<button
onClick={handleNewDocument}
onClick={handleNewDocumentClick}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<AddIcon sx={{ fontSize: 20 }} />
@ -219,7 +219,7 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => {
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<p className="text-lg mb-4">No documents yet</p>
<button
onClick={handleNewDocument}
onClick={handleNewDocumentClick}
className="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Create your first document
@ -273,6 +273,9 @@ const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => {
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
{/* New Document Dialog */}
{NewDocumentDialog}
</>
);
};

View file

@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import { useCreateDocument } from '../../hooks/useCreateDocument';
import Tab from './Tab';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
@ -29,13 +30,14 @@ const DocumentTabs = () => {
switchToDocument,
closeDocument,
renameDocument,
createDocument,
reorderDocuments,
duplicateDocument,
exportDocument,
deleteDocument,
} = useWorkspaceStore();
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
@ -67,10 +69,6 @@ const DocumentTabs = () => {
[renameDocument]
);
const handleNewDocument = useCallback(() => {
createDocument();
}, [createDocument]);
const handleDragStart = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
@ -220,6 +218,9 @@ const DocumentTabs = () => {
onClose={() => setContextMenu(null)}
/>
)}
{/* New Document Dialog */}
{NewDocumentDialog}
</div>
);
};

View file

@ -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 (
* <>
* <button onClick={handleNewDocument}>New Document</button>
* {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 = (
<InputDialog
isOpen={showDialog}
title="New Document"
message="Enter a name for the new document:"
placeholder="e.g., Team Analysis, Project Relationships..."
defaultValue="Untitled Analysis"
confirmLabel="Create"
onConfirm={handleConfirm}
onCancel={handleCancel}
validateInput={(value) => {
if (!value.trim()) return 'Document name cannot be empty';
return null;
}}
/>
);
return {
handleNewDocument,
NewDocumentDialog,
};
}

View file

@ -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,
]);