mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
feat: redesign relation type configuration with improved UX
Updates the relation type configuration to match the new actor type UX with a modern two-column layout and streamlined workflows. Changes to relation type configuration: - Two-column layout: quick add (left) + management list (right) - Inline editing replaces the right column when editing - Single-row layout for name, color, and line style fields - Line style preview with visual feedback - White background cards with click-to-edit interaction - Always-visible duplicate and delete buttons - Full-width edit mode for better focus - Toast notifications for all actions - Keyboard shortcuts (Cmd/Ctrl+Enter to add/save, Esc to cancel) - Removed old EdgeTypeForm in favor of modular components Changes to actor type configuration: - Updated TypeManagementList to match EdgeTypeManagementList styling - White background cards with click-to-edit - Always-visible action buttons (no hover-to-reveal) - Simplified implementation (removed complex menu states) - Consistent appearance across both type configurations New components: - EdgeTypeFormFields: Reusable form with compact layout - EditEdgeTypeInline: Inline editing component - QuickAddEdgeTypeForm: Quick add interface - EdgeTypeManagementList: Scrollable list with actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bc6ffb5bc3
commit
a4db401ff7
12 changed files with 2837 additions and 494 deletions
1838
docs/UX_ANALYSIS_ACTOR_TYPE_SETTINGS.md
Normal file
1838
docs/UX_ANALYSIS_ACTOR_TYPE_SETTINGS.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,21 @@
|
|||
import { useState } from 'react';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import EdgeTypeForm from './EdgeTypeForm';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import QuickAddEdgeTypeForm from './QuickAddEdgeTypeForm';
|
||||
import EdgeTypeManagementList from './EdgeTypeManagementList';
|
||||
import EditEdgeTypeInline from './EditEdgeTypeInline';
|
||||
import type { EdgeTypeConfig, EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
* EdgeTypeConfig - Modal for managing relation/edge types
|
||||
*
|
||||
* Features:
|
||||
* - Add new edge types with custom name, color, and style
|
||||
* - Edit existing edge types
|
||||
* - Delete edge types
|
||||
* - Style selector (solid, dashed, dotted)
|
||||
* - Two-column layout: quick add (left) + management/edit (right)
|
||||
* - Inline editing replaces right column
|
||||
* - Compact card-based management list
|
||||
* - Toast notifications for actions
|
||||
* - Full keyboard accessibility
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,51 +26,38 @@ interface Props {
|
|||
const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
||||
const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
const [newTypeName, setNewTypeName] = useState('');
|
||||
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
|
||||
const [newTypeStyle, setNewTypeStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
||||
const [newTypeDirectionality, setNewTypeDirectionality] = useState<EdgeDirectionality>('directed');
|
||||
const [editingType, setEditingType] = useState<EdgeTypeConfig | null>(null);
|
||||
|
||||
// Editing state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = useState('');
|
||||
const [editColor, setEditColor] = useState('');
|
||||
const [editStyle, setEditStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
||||
const [editDirectionality, setEditDirectionality] = useState<EdgeDirectionality>('directed');
|
||||
|
||||
const handleAddType = () => {
|
||||
if (!newTypeName.trim()) {
|
||||
alert('Please enter a name for the relation type');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
|
||||
const handleAddType = (type: {
|
||||
label: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality: EdgeDirectionality;
|
||||
}) => {
|
||||
const id = type.label.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
// Check if ID already exists
|
||||
if (edgeTypes.some(et => et.id === id)) {
|
||||
alert('A relation type with this name already exists');
|
||||
showToast('A relation type with this name already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newType: EdgeTypeConfig = {
|
||||
id,
|
||||
label: newTypeName.trim(),
|
||||
color: newTypeColor,
|
||||
style: newTypeStyle,
|
||||
defaultDirectionality: newTypeDirectionality,
|
||||
label: type.label,
|
||||
color: type.color,
|
||||
style: type.style,
|
||||
defaultDirectionality: type.defaultDirectionality,
|
||||
};
|
||||
|
||||
addEdgeType(newType);
|
||||
|
||||
// Reset form
|
||||
setNewTypeName('');
|
||||
setNewTypeColor('#6366f1');
|
||||
setNewTypeStyle('solid');
|
||||
setNewTypeDirectionality('directed');
|
||||
showToast(`Relation type "${type.label}" created`, 'success');
|
||||
};
|
||||
|
||||
const handleDeleteType = async (id: string) => {
|
||||
const type = edgeTypes.find(t => t.id === id);
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Relation Type',
|
||||
message: 'Are you sure you want to delete this relation type? This action cannot be undone.',
|
||||
|
|
@ -75,176 +66,144 @@ const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
|||
});
|
||||
if (confirmed) {
|
||||
deleteEdgeType(id);
|
||||
showToast(`Relation type "${type?.label}" deleted`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditType = (type: EdgeTypeConfig) => {
|
||||
setEditingId(type.id);
|
||||
setEditLabel(type.label);
|
||||
setEditColor(type.color);
|
||||
setEditStyle(type.style || 'solid');
|
||||
setEditDirectionality(type.defaultDirectionality || 'directed');
|
||||
setEditingType(type);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingId || !editLabel.trim()) return;
|
||||
|
||||
updateEdgeType(editingId, {
|
||||
label: editLabel.trim(),
|
||||
color: editColor,
|
||||
style: editStyle,
|
||||
defaultDirectionality: editDirectionality,
|
||||
});
|
||||
|
||||
setEditingId(null);
|
||||
const handleSaveEdit = (
|
||||
id: string,
|
||||
updates: {
|
||||
label: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality: EdgeDirectionality;
|
||||
}
|
||||
) => {
|
||||
updateEdgeType(id, updates);
|
||||
setEditingType(null);
|
||||
showToast(`Relation type "${updates.label}" updated`, 'success');
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingType(null);
|
||||
};
|
||||
|
||||
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => {
|
||||
const strokeDasharray = {
|
||||
solid: '0',
|
||||
dashed: '8,4',
|
||||
dotted: '2,4',
|
||||
}[style];
|
||||
const handleDuplicateType = (type: EdgeTypeConfig) => {
|
||||
// Generate a unique ID for the duplicate
|
||||
let suffix = 2;
|
||||
let newId = `${type.id}-copy`;
|
||||
let newLabel = `${type.label} (Copy)`;
|
||||
|
||||
return (
|
||||
<svg width="100%" height="20" className="mt-1">
|
||||
<line
|
||||
x1="0"
|
||||
y1="10"
|
||||
x2="100%"
|
||||
y2="10"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
while (edgeTypes.some(et => et.id === newId)) {
|
||||
newId = `${type.id}-copy-${suffix}`;
|
||||
newLabel = `${type.label} (Copy ${suffix})`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const duplicatedType: EdgeTypeConfig = {
|
||||
id: newId,
|
||||
label: newLabel,
|
||||
color: type.color,
|
||||
style: type.style,
|
||||
defaultDirectionality: type.defaultDirectionality,
|
||||
};
|
||||
|
||||
addEdgeType(duplicatedType);
|
||||
showToast(`Relation type duplicated as "${newLabel}"`, 'success');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Modal */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Customize the types of relations that can connect actors
|
||||
Quickly add and manage the types of relations that can connect actors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Add New Type Form */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3>
|
||||
<EdgeTypeForm
|
||||
name={newTypeName}
|
||||
color={newTypeColor}
|
||||
style={newTypeStyle}
|
||||
defaultDirectionality={newTypeDirectionality}
|
||||
onNameChange={setNewTypeName}
|
||||
onColorChange={setNewTypeColor}
|
||||
onStyleChange={setNewTypeStyle}
|
||||
onDefaultDirectionalityChange={setNewTypeDirectionality}
|
||||
{/* Content - Two-Column or Full-Width Edit */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{editingType ? (
|
||||
/* Full-Width Edit Mode */
|
||||
<div className="w-full p-6 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<EditEdgeTypeInline
|
||||
type={editingType}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddType}
|
||||
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Relation Type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing Types List */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Relation Types</h3>
|
||||
<div className="space-y-2">
|
||||
{edgeTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="border border-gray-200 rounded-md overflow-hidden"
|
||||
>
|
||||
{editingId === type.id ? (
|
||||
// Edit mode
|
||||
<div className="bg-blue-50 p-4">
|
||||
<EdgeTypeForm
|
||||
name={editLabel}
|
||||
color={editColor}
|
||||
style={editStyle}
|
||||
defaultDirectionality={editDirectionality}
|
||||
onNameChange={setEditLabel}
|
||||
onColorChange={setEditColor}
|
||||
onStyleChange={setEditStyle}
|
||||
onDefaultDirectionalityChange={setEditDirectionality}
|
||||
/>
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// View mode
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
||||
{type.label}
|
||||
</div>
|
||||
<div className="w-full max-w-xs">
|
||||
{renderStylePreview(type.style || 'solid', type.color)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditType(type)}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteType(type.id)}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<>
|
||||
{/* Left Column - Quick Add (60%) */}
|
||||
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">
|
||||
Quick Add Relation Type
|
||||
</h3>
|
||||
<QuickAddEdgeTypeForm onAdd={handleAddType} />
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">Pro Tips</h4>
|
||||
<ul className="text-xs text-blue-800 space-y-1">
|
||||
<li>• Press <kbd className="px-1 py-0.5 bg-white border border-blue-300 rounded text-xs">Enter</kbd> to quickly add a type</li>
|
||||
<li>• Choose meaningful names like "Supervises" or "Reports To"</li>
|
||||
<li>• Use different line styles to distinguish relation types visually</li>
|
||||
<li>• Click any type on the right to edit it</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Right Column - Management (40%) */}
|
||||
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Relation Types ({edgeTypes.length})
|
||||
</h3>
|
||||
</div>
|
||||
<EdgeTypeManagementList
|
||||
types={edgeTypes}
|
||||
onEdit={handleEditType}
|
||||
onDelete={handleDeleteType}
|
||||
onDuplicate={handleDuplicateType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Hidden when editing */}
|
||||
{!editingType && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Close
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
import type { EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
* EdgeTypeForm - Reusable form fields for creating/editing edge types
|
||||
*
|
||||
* Features:
|
||||
* - Name input
|
||||
* - Color picker (visual + text input)
|
||||
* - Line style selector (solid/dashed/dotted)
|
||||
* - Default directionality selector
|
||||
* - Visual style preview
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality?: EdgeDirectionality;
|
||||
onNameChange: (value: string) => void;
|
||||
onColorChange: (value: string) => void;
|
||||
onStyleChange: (value: 'solid' | 'dashed' | 'dotted') => void;
|
||||
onDefaultDirectionalityChange?: (value: EdgeDirectionality) => void;
|
||||
}
|
||||
|
||||
const EdgeTypeForm = ({
|
||||
name,
|
||||
color,
|
||||
style,
|
||||
defaultDirectionality = 'directed',
|
||||
onNameChange,
|
||||
onColorChange,
|
||||
onStyleChange,
|
||||
onDefaultDirectionalityChange,
|
||||
}: Props) => {
|
||||
const renderStylePreview = (lineStyle: 'solid' | 'dashed' | 'dotted', lineColor: string) => {
|
||||
const strokeDasharray = {
|
||||
solid: '0',
|
||||
dashed: '8,4',
|
||||
dotted: '2,4',
|
||||
}[lineStyle];
|
||||
|
||||
return (
|
||||
<svg width="100%" height="20" className="mt-1">
|
||||
<line
|
||||
x1="0"
|
||||
y1="10"
|
||||
x2="100%"
|
||||
y2="10"
|
||||
stroke={lineColor}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g., Supervises, Communicates With"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Color *
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
className="h-10 w-20 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
placeholder="#6366f1"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Line Style *
|
||||
</label>
|
||||
<select
|
||||
value={style}
|
||||
onChange={(e) => onStyleChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="solid">Solid</option>
|
||||
<option value="dashed">Dashed</option>
|
||||
<option value="dotted">Dotted</option>
|
||||
</select>
|
||||
{renderStylePreview(style, color)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Default Directionality *
|
||||
</label>
|
||||
<select
|
||||
value={defaultDirectionality}
|
||||
onChange={(e) => onDefaultDirectionalityChange?.(e.target.value as EdgeDirectionality)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="directed">Directed (→) - One-way</option>
|
||||
<option value="bidirectional">Bidirectional (↔) - Two-way</option>
|
||||
<option value="undirected">Undirected (—) - No direction</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
New relations of this type will use this directionality by default
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeTypeForm;
|
||||
152
src/components/Config/EdgeTypeFormFields.tsx
Normal file
152
src/components/Config/EdgeTypeFormFields.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { KeyboardEvent } from 'react';
|
||||
import type { EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
* EdgeTypeFormFields - Reusable form fields for add/edit relation types
|
||||
*
|
||||
* Features:
|
||||
* - All fields visible
|
||||
* - Compact single-row layout for name and color
|
||||
* - Keyboard accessible
|
||||
* - Consistent between add and edit modes
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality: EdgeDirectionality;
|
||||
onNameChange: (value: string) => void;
|
||||
onColorChange: (value: string) => void;
|
||||
onStyleChange: (value: 'solid' | 'dashed' | 'dotted') => void;
|
||||
onDefaultDirectionalityChange: (value: EdgeDirectionality) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
nameInputRef?: React.RefObject<HTMLInputElement>;
|
||||
autoFocusName?: boolean;
|
||||
}
|
||||
|
||||
const EdgeTypeFormFields = ({
|
||||
name,
|
||||
color,
|
||||
style,
|
||||
defaultDirectionality,
|
||||
onNameChange,
|
||||
onColorChange,
|
||||
onStyleChange,
|
||||
onDefaultDirectionalityChange,
|
||||
onKeyDown,
|
||||
nameInputRef,
|
||||
autoFocusName = false,
|
||||
}: Props) => {
|
||||
const renderStylePreview = (lineStyle: 'solid' | 'dashed' | 'dotted', lineColor: string) => {
|
||||
const strokeDasharray = {
|
||||
solid: '0',
|
||||
dashed: '8,4',
|
||||
dotted: '2,4',
|
||||
}[lineStyle];
|
||||
|
||||
return (
|
||||
<svg width="100%" height="20" className="mt-1">
|
||||
<line
|
||||
x1="0"
|
||||
y1="10"
|
||||
x2="100%"
|
||||
y2="10"
|
||||
stroke={lineColor}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Name, Color, and Line Style - Single row */}
|
||||
<div>
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="edge-type-name" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="edge-type-name"
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="e.g., Supervises"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-required="true"
|
||||
autoFocus={autoFocusName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div className="w-20 flex-shrink-0">
|
||||
<label htmlFor="edge-type-color-picker" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Color <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="edge-type-color-picker"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-8 w-full rounded cursor-pointer border border-gray-300"
|
||||
aria-label="Color picker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Style */}
|
||||
<div className="w-32 flex-shrink-0">
|
||||
<label htmlFor="edge-type-style" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Style <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="edge-type-style"
|
||||
value={style}
|
||||
onChange={(e) => onStyleChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="solid">Solid</option>
|
||||
<option value="dashed">Dashed</option>
|
||||
<option value="dotted">Dotted</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Style Preview */}
|
||||
<div>
|
||||
{renderStylePreview(style, color)}
|
||||
</div>
|
||||
|
||||
{/* Default Directionality */}
|
||||
<div>
|
||||
<label htmlFor="edge-type-directionality" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Default Directionality <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="edge-type-directionality"
|
||||
value={defaultDirectionality}
|
||||
onChange={(e) => onDefaultDirectionalityChange(e.target.value as EdgeDirectionality)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="directed">Directed (→) - One-way</option>
|
||||
<option value="bidirectional">Bidirectional (↔) - Two-way</option>
|
||||
<option value="undirected">Undirected (—) - No direction</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
New relations of this type will use this directionality by default
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeTypeFormFields;
|
||||
119
src/components/Config/EdgeTypeManagementList.tsx
Normal file
119
src/components/Config/EdgeTypeManagementList.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import type { EdgeTypeConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* EdgeTypeManagementList - List view of existing relation types
|
||||
*
|
||||
* Features:
|
||||
* - Scrollable list of relation types
|
||||
* - Visual preview of line style and color
|
||||
* - Edit, duplicate, and delete actions
|
||||
* - Hover states for better UX
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
types: EdgeTypeConfig[];
|
||||
onEdit: (type: EdgeTypeConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDuplicate: (type: EdgeTypeConfig) => void;
|
||||
}
|
||||
|
||||
const EdgeTypeManagementList = ({ types, onEdit, onDelete, onDuplicate }: Props) => {
|
||||
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => {
|
||||
const strokeDasharray = {
|
||||
solid: '0',
|
||||
dashed: '8,4',
|
||||
dotted: '2,4',
|
||||
}[style];
|
||||
|
||||
return (
|
||||
<svg width="100" height="20">
|
||||
<line
|
||||
x1="0"
|
||||
y1="10"
|
||||
x2="100"
|
||||
y2="10"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (types.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-sm">No relation types yet.</p>
|
||||
<p className="text-xs mt-1">Add your first relation type above.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{types.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => onEdit(type)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit(type);
|
||||
}
|
||||
}}
|
||||
aria-label={`Edit ${type.label}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Type Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
||||
{type.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderStylePreview(type.style || 'solid', type.color)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{type.defaultDirectionality === 'directed' && '→'}
|
||||
{type.defaultDirectionality === 'bidirectional' && '↔'}
|
||||
{type.defaultDirectionality === 'undirected' && '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate(type);
|
||||
}}
|
||||
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
aria-label={`Duplicate ${type.label}`}
|
||||
title="Duplicate"
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(type.id);
|
||||
}}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
aria-label={`Delete ${type.label}`}
|
||||
title="Delete"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeTypeManagementList;
|
||||
121
src/components/Config/EditEdgeTypeInline.tsx
Normal file
121
src/components/Config/EditEdgeTypeInline.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState, useEffect, useRef, KeyboardEvent } from 'react';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import EdgeTypeFormFields from './EdgeTypeFormFields';
|
||||
import type { EdgeTypeConfig, EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
* EditEdgeTypeInline - Inline edit view that replaces the right column
|
||||
*
|
||||
* Features:
|
||||
* - Replaces management list in right column when editing
|
||||
* - Reuses EdgeTypeFormFields
|
||||
* - Save/Cancel actions
|
||||
* - Keyboard accessible (Cmd/Ctrl+Enter to save, Escape to cancel)
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
type: EdgeTypeConfig;
|
||||
onSave: (
|
||||
id: string,
|
||||
updates: {
|
||||
label: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality: EdgeDirectionality;
|
||||
}
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EditEdgeTypeInline = ({ type, onSave, onCancel }: Props) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6366f1');
|
||||
const [style, setStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
||||
const [defaultDirectionality, setDefaultDirectionality] = useState<EdgeDirectionality>('directed');
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync state with type prop
|
||||
useEffect(() => {
|
||||
if (type) {
|
||||
setName(type.label);
|
||||
setColor(type.color);
|
||||
setStyle(type.style || 'solid');
|
||||
setDefaultDirectionality(type.defaultDirectionality || 'directed');
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
nameInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(type.id, {
|
||||
label: name.trim(),
|
||||
color,
|
||||
style,
|
||||
defaultDirectionality,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full">
|
||||
{/* Form Fields */}
|
||||
<div className="flex-1 mb-6">
|
||||
<EdgeTypeFormFields
|
||||
name={name}
|
||||
color={color}
|
||||
style={style}
|
||||
defaultDirectionality={defaultDirectionality}
|
||||
onNameChange={setName}
|
||||
onColorChange={setColor}
|
||||
onStyleChange={setStyle}
|
||||
onDefaultDirectionalityChange={setDefaultDirectionality}
|
||||
onKeyDown={handleKeyDown}
|
||||
nameInputRef={nameInputRef}
|
||||
autoFocusName={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-6 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 px-6 py-3 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<SaveIcon fontSize="small" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
|
||||
</kbd>{' '}
|
||||
to save, <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Esc</kbd> to cancel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditEdgeTypeInline;
|
||||
|
|
@ -1,17 +1,23 @@
|
|||
import { useState } from 'react';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import NodeTypeForm from './NodeTypeForm';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import type { NodeTypeConfig } from '../../types';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import QuickAddTypeForm from './QuickAddTypeForm';
|
||||
import TypeManagementList from './TypeManagementList';
|
||||
import EditTypeInline from './EditTypeInline';
|
||||
import type { NodeTypeConfig, NodeShape } from '../../types';
|
||||
|
||||
/**
|
||||
* NodeTypeConfig - Modal for managing actor/node types
|
||||
*
|
||||
* Features:
|
||||
* - Add new node types with custom name and color
|
||||
* - Edit existing node types
|
||||
* - Delete node types
|
||||
* - Color picker for visual customization
|
||||
* - Two-column layout: quick add (left) + management/edit (right)
|
||||
* - Progressive disclosure for advanced options
|
||||
* - Inline editing replaces right column
|
||||
* - Compact card-based management list
|
||||
* - Type duplication support
|
||||
* - Toast notifications for actions
|
||||
* - Full keyboard accessibility
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,55 +28,34 @@ interface Props {
|
|||
const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
||||
const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||
const { showToast } = useToastStore();
|
||||
|
||||
const [newTypeName, setNewTypeName] = useState('');
|
||||
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
|
||||
const [newTypeShape, setNewTypeShape] = useState<import('../../types').NodeShape>('rectangle');
|
||||
const [newTypeDescription, setNewTypeDescription] = useState('');
|
||||
const [newTypeIcon, setNewTypeIcon] = useState('');
|
||||
const [editingType, setEditingType] = useState<NodeTypeConfig | null>(null);
|
||||
|
||||
// Editing state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = useState('');
|
||||
const [editColor, setEditColor] = useState('');
|
||||
const [editShape, setEditShape] = useState<import('../../types').NodeShape>('rectangle');
|
||||
const [editIcon, setEditIcon] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
const handleAddType = () => {
|
||||
if (!newTypeName.trim()) {
|
||||
alert('Please enter a name for the node type');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
|
||||
const handleAddType = (type: { name: string; color: string; shape: NodeShape; icon: string; description: string }) => {
|
||||
const id = type.name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
// Check if ID already exists
|
||||
if (nodeTypes.some(nt => nt.id === id)) {
|
||||
alert('A node type with this name already exists');
|
||||
showToast('A node type with this name already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newType: NodeTypeConfig = {
|
||||
id,
|
||||
label: newTypeName.trim(),
|
||||
color: newTypeColor,
|
||||
shape: newTypeShape,
|
||||
icon: newTypeIcon || undefined,
|
||||
description: newTypeDescription.trim() || undefined,
|
||||
label: type.name,
|
||||
color: type.color,
|
||||
shape: type.shape,
|
||||
icon: type.icon || undefined,
|
||||
description: type.description || undefined,
|
||||
};
|
||||
|
||||
addNodeType(newType);
|
||||
|
||||
// Reset form
|
||||
setNewTypeName('');
|
||||
setNewTypeColor('#6366f1');
|
||||
setNewTypeShape('rectangle');
|
||||
setNewTypeDescription('');
|
||||
setNewTypeIcon('');
|
||||
showToast(`Actor type "${type.name}" created`, 'success');
|
||||
};
|
||||
|
||||
const handleDeleteType = async (id: string) => {
|
||||
const type = nodeTypes.find(t => t.id === id);
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Actor Type',
|
||||
message: 'Are you sure you want to delete this actor type? This action cannot be undone.',
|
||||
|
|
@ -79,166 +64,137 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
|||
});
|
||||
if (confirmed) {
|
||||
deleteNodeType(id);
|
||||
showToast(`Actor type "${type?.label}" deleted`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditType = (type: NodeTypeConfig) => {
|
||||
setEditingId(type.id);
|
||||
setEditLabel(type.label);
|
||||
setEditColor(type.color);
|
||||
setEditShape(type.shape || 'rectangle');
|
||||
setEditIcon(type.icon || '');
|
||||
setEditDescription(type.description || '');
|
||||
setEditingType(type);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingId || !editLabel.trim()) return;
|
||||
|
||||
updateNodeType(editingId, {
|
||||
label: editLabel.trim(),
|
||||
color: editColor,
|
||||
shape: editShape,
|
||||
icon: editIcon || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
|
||||
setEditingId(null);
|
||||
const handleSaveEdit = (id: string, updates: { label: string; color: string; shape: NodeShape; icon?: string; description?: string }) => {
|
||||
updateNodeType(id, updates);
|
||||
setEditingType(null);
|
||||
showToast(`Actor type "${updates.label}" updated`, 'success');
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingType(null);
|
||||
};
|
||||
|
||||
const handleDuplicateType = (type: NodeTypeConfig) => {
|
||||
// Generate a unique ID for the duplicate
|
||||
let suffix = 2;
|
||||
let newId = `${type.id}-copy`;
|
||||
let newLabel = `${type.label} (Copy)`;
|
||||
|
||||
while (nodeTypes.some(nt => nt.id === newId)) {
|
||||
newId = `${type.id}-copy-${suffix}`;
|
||||
newLabel = `${type.label} (Copy ${suffix})`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const duplicatedType: NodeTypeConfig = {
|
||||
id: newId,
|
||||
label: newLabel,
|
||||
color: type.color,
|
||||
shape: type.shape,
|
||||
icon: type.icon,
|
||||
description: type.description,
|
||||
};
|
||||
|
||||
addNodeType(duplicatedType);
|
||||
showToast(`Actor type duplicated as "${newLabel}"`, 'success');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Modal */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Customize the types of actors that can be added to your constellation
|
||||
Quickly add and manage the types of actors in your constellation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Add New Type Form */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Actor Type</h3>
|
||||
<NodeTypeForm
|
||||
name={newTypeName}
|
||||
color={newTypeColor}
|
||||
shape={newTypeShape}
|
||||
icon={newTypeIcon}
|
||||
description={newTypeDescription}
|
||||
onNameChange={setNewTypeName}
|
||||
onColorChange={setNewTypeColor}
|
||||
onShapeChange={setNewTypeShape}
|
||||
onIconChange={setNewTypeIcon}
|
||||
onDescriptionChange={setNewTypeDescription}
|
||||
{/* Content - Two-Column or Full-Width Edit */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{editingType ? (
|
||||
/* Full-Width Edit Mode */
|
||||
<div className="w-full p-6 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<EditTypeInline
|
||||
type={editingType}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddType}
|
||||
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Actor Type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing Types List */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Actor Types</h3>
|
||||
<div className="space-y-2">
|
||||
{nodeTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="border border-gray-200 rounded-md overflow-hidden"
|
||||
>
|
||||
{editingId === type.id ? (
|
||||
// Edit mode
|
||||
<div className="bg-blue-50 p-4">
|
||||
<NodeTypeForm
|
||||
name={editLabel}
|
||||
color={editColor}
|
||||
shape={editShape}
|
||||
icon={editIcon}
|
||||
description={editDescription}
|
||||
onNameChange={setEditLabel}
|
||||
onColorChange={setEditColor}
|
||||
onShapeChange={setEditShape}
|
||||
onIconChange={setEditIcon}
|
||||
onDescriptionChange={setEditDescription}
|
||||
/>
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// View mode
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded"
|
||||
style={{ backgroundColor: type.color }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{type.label}
|
||||
</div>
|
||||
{type.description && (
|
||||
<div className="text-xs text-gray-500">{type.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditType(type)}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteType(type.id)}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<>
|
||||
{/* Left Column - Quick Add (60%) */}
|
||||
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">
|
||||
Quick Add Actor Type
|
||||
</h3>
|
||||
<QuickAddTypeForm onAdd={handleAddType} />
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">Pro Tips</h4>
|
||||
<ul className="text-xs text-blue-800 space-y-1">
|
||||
<li>• Press <kbd className="px-1 py-0.5 bg-white border border-blue-300 rounded text-xs">Enter</kbd> to quickly add a type</li>
|
||||
<li>• Shape and icon are optional - focus on name and color first</li>
|
||||
<li>• Click any type on the right to edit it</li>
|
||||
<li>• Use duplicate to create variations quickly</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Right Column - Management (40%) */}
|
||||
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Actor Types ({nodeTypes.length})
|
||||
</h3>
|
||||
</div>
|
||||
<TypeManagementList
|
||||
types={nodeTypes}
|
||||
onEdit={handleEditType}
|
||||
onDelete={handleDeleteType}
|
||||
onDuplicate={handleDuplicateType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Hidden when editing */}
|
||||
{!editingType && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Close
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
97
src/components/Config/QuickAddEdgeTypeForm.tsx
Normal file
97
src/components/Config/QuickAddEdgeTypeForm.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useState, useRef, KeyboardEvent } from 'react';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EdgeTypeFormFields from './EdgeTypeFormFields';
|
||||
import type { EdgeDirectionality } from '../../types';
|
||||
|
||||
/**
|
||||
* QuickAddEdgeTypeForm - Quick add form for new relation types
|
||||
*
|
||||
* Features:
|
||||
* - Compact form using EdgeTypeFormFields
|
||||
* - Keyboard accessible (Cmd/Ctrl+Enter to add)
|
||||
* - Auto-clears after successful add
|
||||
* - Focus management
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onAdd: (data: {
|
||||
label: string;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
defaultDirectionality: EdgeDirectionality;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const QuickAddEdgeTypeForm = ({ onAdd }: Props) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6366f1');
|
||||
const [style, setStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
||||
const [defaultDirectionality, setDefaultDirectionality] = useState<EdgeDirectionality>('directed');
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) {
|
||||
nameInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd({
|
||||
label: name.trim(),
|
||||
color,
|
||||
style,
|
||||
defaultDirectionality,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setColor('#6366f1');
|
||||
setStyle('solid');
|
||||
setDefaultDirectionality('directed');
|
||||
nameInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3>
|
||||
|
||||
<EdgeTypeFormFields
|
||||
name={name}
|
||||
color={color}
|
||||
style={style}
|
||||
defaultDirectionality={defaultDirectionality}
|
||||
onNameChange={setName}
|
||||
onColorChange={setColor}
|
||||
onStyleChange={setStyle}
|
||||
onDefaultDirectionalityChange={setDefaultDirectionality}
|
||||
onKeyDown={handleKeyDown}
|
||||
nameInputRef={nameInputRef}
|
||||
autoFocusName={false}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="w-full mt-4 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
Add Relation Type
|
||||
</button>
|
||||
|
||||
<div className="text-xs text-gray-500 text-center mt-2">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
|
||||
{navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
|
||||
</kbd>{' '}
|
||||
to add
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddEdgeTypeForm;
|
||||
106
src/components/Config/QuickAddTypeForm.tsx
Normal file
106
src/components/Config/QuickAddTypeForm.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useState, useRef, KeyboardEvent } from 'react';
|
||||
import TypeFormFields from './TypeFormFields';
|
||||
import type { NodeShape } from '../../types';
|
||||
|
||||
/**
|
||||
* QuickAddTypeForm - Streamlined form for quickly adding new actor types
|
||||
*
|
||||
* Features:
|
||||
* - One-line quick add (name + color + button)
|
||||
* - Progressive disclosure for advanced options
|
||||
* - Keyboard accessible (Enter to submit, Escape to cancel)
|
||||
* - Focus management
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onAdd: (type: {
|
||||
name: string;
|
||||
color: string;
|
||||
shape: NodeShape;
|
||||
icon: string;
|
||||
description: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const QuickAddTypeForm = ({ onAdd }: Props) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#6366f1');
|
||||
const [shape, setShape] = useState<NodeShape>('rectangle');
|
||||
const [icon, setIcon] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) {
|
||||
nameInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd({ name: name.trim(), color, shape, icon, description });
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setColor('#6366f1');
|
||||
setShape('rectangle');
|
||||
setIcon('');
|
||||
setDescription('');
|
||||
|
||||
// Focus back to name input for quick subsequent additions
|
||||
nameInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Reset form
|
||||
setName('');
|
||||
setColor('#6366f1');
|
||||
setShape('rectangle');
|
||||
setIcon('');
|
||||
setDescription('');
|
||||
nameInputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TypeFormFields
|
||||
name={name}
|
||||
color={color}
|
||||
shape={shape}
|
||||
icon={icon}
|
||||
description={description}
|
||||
onNameChange={setName}
|
||||
onColorChange={setColor}
|
||||
onShapeChange={setShape}
|
||||
onIconChange={setIcon}
|
||||
onDescriptionChange={setDescription}
|
||||
onKeyDown={handleKeyDown}
|
||||
nameInputRef={nameInputRef}
|
||||
autoFocusName={false}
|
||||
showAdvancedByDefault={false}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-label="Add actor type"
|
||||
>
|
||||
Add Type
|
||||
</button>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
{name && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
Press Enter to add, Escape to cancel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddTypeForm;
|
||||
|
|
@ -49,10 +49,10 @@ const SHAPE_OPTIONS: ShapeOption[] = [
|
|||
const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Shape
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
Shape (optional)
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SHAPE_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id;
|
||||
return (
|
||||
|
|
@ -61,27 +61,30 @@ const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProp
|
|||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`
|
||||
relative p-3 rounded-lg border-2 transition-all
|
||||
relative p-2 rounded-md border-2 transition-all
|
||||
hover:border-blue-400 hover:bg-blue-50
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'}
|
||||
`}
|
||||
title={option.description}
|
||||
aria-label={option.label}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Shape Preview */}
|
||||
<div className="flex justify-center items-center h-12 mb-2">
|
||||
<ShapePreview shape={option.id} color={color} size={40} />
|
||||
<div className="flex justify-center items-center h-8">
|
||||
<ShapePreview shape={option.id} color={color} size={28} />
|
||||
</div>
|
||||
|
||||
{/* Shape Label */}
|
||||
<div className="text-xs text-center text-gray-700 font-medium">
|
||||
{option.label}
|
||||
<div className="text-xs text-center text-gray-700 font-medium mt-1 truncate">
|
||||
{option.label.split(' ')[0]}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<div className="absolute top-0.5 right-0.5 w-3 h-3 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
className="w-2 h-2 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
|
|
|
|||
106
src/components/Config/TypeManagementList.tsx
Normal file
106
src/components/Config/TypeManagementList.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import type { NodeTypeConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* TypeManagementList - Compact list view for managing existing actor types
|
||||
*
|
||||
* Features:
|
||||
* - White background cards with click to edit
|
||||
* - Color badge + name + description preview
|
||||
* - Always visible duplicate and delete buttons
|
||||
* - Keyboard accessible
|
||||
* - ARIA compliant
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
types: NodeTypeConfig[];
|
||||
onEdit: (type: NodeTypeConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDuplicate: (type: NodeTypeConfig) => void;
|
||||
}
|
||||
|
||||
const TypeManagementList = ({ types, onEdit, onDelete, onDuplicate }: Props) => {
|
||||
|
||||
if (types.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-sm">No actor types yet.</p>
|
||||
<p className="text-xs mt-1">Add your first actor type above.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{types.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => onEdit(type)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit(type);
|
||||
}
|
||||
}}
|
||||
aria-label={`Edit ${type.label}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Type Info */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Color Badge */}
|
||||
<div
|
||||
className="w-10 h-10 rounded flex-shrink-0"
|
||||
style={{ backgroundColor: type.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Name & Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{type.label}
|
||||
</div>
|
||||
{type.description && (
|
||||
<div className="text-xs text-gray-500 truncate mt-0.5">
|
||||
{type.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate(type);
|
||||
}}
|
||||
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
aria-label={`Duplicate ${type.label}`}
|
||||
title="Duplicate"
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(type.id);
|
||||
}}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
aria-label={`Delete ${type.label}`}
|
||||
title="Delete"
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeManagementList;
|
||||
|
|
@ -48,3 +48,19 @@ code {
|
|||
button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Slide-in animation for edit panel */
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.3s ease-out forwards;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue