mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +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 { useState } from 'react';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
import EdgeTypeForm from './EdgeTypeForm';
|
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
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';
|
import type { EdgeTypeConfig, EdgeDirectionality } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EdgeTypeConfig - Modal for managing relation/edge types
|
* EdgeTypeConfig - Modal for managing relation/edge types
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Add new edge types with custom name, color, and style
|
* - Two-column layout: quick add (left) + management/edit (right)
|
||||||
* - Edit existing edge types
|
* - Inline editing replaces right column
|
||||||
* - Delete edge types
|
* - Compact card-based management list
|
||||||
* - Style selector (solid, dashed, dotted)
|
* - Toast notifications for actions
|
||||||
|
* - Full keyboard accessibility
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -22,51 +26,38 @@ interface Props {
|
||||||
const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
||||||
const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory();
|
const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory();
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||||
|
const { showToast } = useToastStore();
|
||||||
|
|
||||||
const [newTypeName, setNewTypeName] = useState('');
|
const [editingType, setEditingType] = useState<EdgeTypeConfig | null>(null);
|
||||||
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
|
|
||||||
const [newTypeStyle, setNewTypeStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
|
||||||
const [newTypeDirectionality, setNewTypeDirectionality] = useState<EdgeDirectionality>('directed');
|
|
||||||
|
|
||||||
// Editing state
|
const handleAddType = (type: {
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
label: string;
|
||||||
const [editLabel, setEditLabel] = useState('');
|
color: string;
|
||||||
const [editColor, setEditColor] = useState('');
|
style: 'solid' | 'dashed' | 'dotted';
|
||||||
const [editStyle, setEditStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
|
defaultDirectionality: EdgeDirectionality;
|
||||||
const [editDirectionality, setEditDirectionality] = useState<EdgeDirectionality>('directed');
|
}) => {
|
||||||
|
const id = type.label.toLowerCase().replace(/\s+/g, '-');
|
||||||
const handleAddType = () => {
|
|
||||||
if (!newTypeName.trim()) {
|
|
||||||
alert('Please enter a name for the relation type');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (edgeTypes.some(et => et.id === id)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newType: EdgeTypeConfig = {
|
const newType: EdgeTypeConfig = {
|
||||||
id,
|
id,
|
||||||
label: newTypeName.trim(),
|
label: type.label,
|
||||||
color: newTypeColor,
|
color: type.color,
|
||||||
style: newTypeStyle,
|
style: type.style,
|
||||||
defaultDirectionality: newTypeDirectionality,
|
defaultDirectionality: type.defaultDirectionality,
|
||||||
};
|
};
|
||||||
|
|
||||||
addEdgeType(newType);
|
addEdgeType(newType);
|
||||||
|
showToast(`Relation type "${type.label}" created`, 'success');
|
||||||
// Reset form
|
|
||||||
setNewTypeName('');
|
|
||||||
setNewTypeColor('#6366f1');
|
|
||||||
setNewTypeStyle('solid');
|
|
||||||
setNewTypeDirectionality('directed');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteType = async (id: string) => {
|
const handleDeleteType = async (id: string) => {
|
||||||
|
const type = edgeTypes.find(t => t.id === id);
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Delete Relation Type',
|
title: 'Delete Relation Type',
|
||||||
message: 'Are you sure you want to delete this relation type? This action cannot be undone.',
|
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) {
|
if (confirmed) {
|
||||||
deleteEdgeType(id);
|
deleteEdgeType(id);
|
||||||
|
showToast(`Relation type "${type?.label}" deleted`, 'success');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditType = (type: EdgeTypeConfig) => {
|
const handleEditType = (type: EdgeTypeConfig) => {
|
||||||
setEditingId(type.id);
|
setEditingType(type);
|
||||||
setEditLabel(type.label);
|
|
||||||
setEditColor(type.color);
|
|
||||||
setEditStyle(type.style || 'solid');
|
|
||||||
setEditDirectionality(type.defaultDirectionality || 'directed');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = (
|
||||||
if (!editingId || !editLabel.trim()) return;
|
id: string,
|
||||||
|
updates: {
|
||||||
updateEdgeType(editingId, {
|
label: string;
|
||||||
label: editLabel.trim(),
|
color: string;
|
||||||
color: editColor,
|
style: 'solid' | 'dashed' | 'dotted';
|
||||||
style: editStyle,
|
defaultDirectionality: EdgeDirectionality;
|
||||||
defaultDirectionality: editDirectionality,
|
}
|
||||||
});
|
) => {
|
||||||
|
updateEdgeType(id, updates);
|
||||||
setEditingId(null);
|
setEditingType(null);
|
||||||
|
showToast(`Relation type "${updates.label}" updated`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setEditingId(null);
|
setEditingType(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => {
|
const handleDuplicateType = (type: EdgeTypeConfig) => {
|
||||||
const strokeDasharray = {
|
// Generate a unique ID for the duplicate
|
||||||
solid: '0',
|
let suffix = 2;
|
||||||
dashed: '8,4',
|
let newId = `${type.id}-copy`;
|
||||||
dotted: '2,4',
|
let newLabel = `${type.label} (Copy)`;
|
||||||
}[style];
|
|
||||||
|
|
||||||
return (
|
while (edgeTypes.some(et => et.id === newId)) {
|
||||||
<svg width="100%" height="20" className="mt-1">
|
newId = `${type.id}-copy-${suffix}`;
|
||||||
<line
|
newLabel = `${type.label} (Copy ${suffix})`;
|
||||||
x1="0"
|
suffix++;
|
||||||
y1="10"
|
}
|
||||||
x2="100%"
|
|
||||||
y2="10"
|
const duplicatedType: EdgeTypeConfig = {
|
||||||
stroke={color}
|
id: newId,
|
||||||
strokeWidth="3"
|
label: newLabel,
|
||||||
strokeDasharray={strokeDasharray}
|
color: type.color,
|
||||||
/>
|
style: type.style,
|
||||||
</svg>
|
defaultDirectionality: type.defaultDirectionality,
|
||||||
);
|
};
|
||||||
|
|
||||||
|
addEdgeType(duplicatedType);
|
||||||
|
showToast(`Relation type duplicated as "${newLabel}"`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Main Modal */}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2>
|
<h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - Two-Column or Full-Width Edit */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-hidden flex">
|
||||||
{/* Add New Type Form */}
|
{editingType ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
/* Full-Width Edit Mode */
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3>
|
<div className="w-full p-6 overflow-y-auto">
|
||||||
<EdgeTypeForm
|
<div className="max-w-2xl mx-auto">
|
||||||
name={newTypeName}
|
<EditEdgeTypeInline
|
||||||
color={newTypeColor}
|
type={editingType}
|
||||||
style={newTypeStyle}
|
onSave={handleSaveEdit}
|
||||||
defaultDirectionality={newTypeDirectionality}
|
onCancel={handleCancelEdit}
|
||||||
onNameChange={setNewTypeName}
|
|
||||||
onColorChange={setNewTypeColor}
|
|
||||||
onStyleChange={setNewTypeStyle}
|
|
||||||
onDefaultDirectionalityChange={setNewTypeDirectionality}
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// View mode
|
<>
|
||||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
{/* Left Column - Quick Add (60%) */}
|
||||||
<div className="flex-1">
|
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
<div>
|
||||||
{type.label}
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">
|
||||||
</div>
|
Quick Add Relation Type
|
||||||
<div className="w-full max-w-xs">
|
</h3>
|
||||||
{renderStylePreview(type.style || 'solid', type.color)}
|
<QuickAddEdgeTypeForm onAdd={handleAddType} />
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
{ConfirmDialogComponent}
|
{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 { useState } from 'react';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
import NodeTypeForm from './NodeTypeForm';
|
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
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
|
* NodeTypeConfig - Modal for managing actor/node types
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Add new node types with custom name and color
|
* - Two-column layout: quick add (left) + management/edit (right)
|
||||||
* - Edit existing node types
|
* - Progressive disclosure for advanced options
|
||||||
* - Delete node types
|
* - Inline editing replaces right column
|
||||||
* - Color picker for visual customization
|
* - Compact card-based management list
|
||||||
|
* - Type duplication support
|
||||||
|
* - Toast notifications for actions
|
||||||
|
* - Full keyboard accessibility
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -22,55 +28,34 @@ interface Props {
|
||||||
const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
|
||||||
const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory();
|
const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory();
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||||
|
const { showToast } = useToastStore();
|
||||||
|
|
||||||
const [newTypeName, setNewTypeName] = useState('');
|
const [editingType, setEditingType] = useState<NodeTypeConfig | null>(null);
|
||||||
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
|
|
||||||
const [newTypeShape, setNewTypeShape] = useState<import('../../types').NodeShape>('rectangle');
|
|
||||||
const [newTypeDescription, setNewTypeDescription] = useState('');
|
|
||||||
const [newTypeIcon, setNewTypeIcon] = useState('');
|
|
||||||
|
|
||||||
// Editing state
|
const handleAddType = (type: { name: string; color: string; shape: NodeShape; icon: string; description: string }) => {
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const id = type.name.toLowerCase().replace(/\s+/g, '-');
|
||||||
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, '-');
|
|
||||||
|
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (nodeTypes.some(nt => nt.id === id)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newType: NodeTypeConfig = {
|
const newType: NodeTypeConfig = {
|
||||||
id,
|
id,
|
||||||
label: newTypeName.trim(),
|
label: type.name,
|
||||||
color: newTypeColor,
|
color: type.color,
|
||||||
shape: newTypeShape,
|
shape: type.shape,
|
||||||
icon: newTypeIcon || undefined,
|
icon: type.icon || undefined,
|
||||||
description: newTypeDescription.trim() || undefined,
|
description: type.description || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
addNodeType(newType);
|
addNodeType(newType);
|
||||||
|
showToast(`Actor type "${type.name}" created`, 'success');
|
||||||
// Reset form
|
|
||||||
setNewTypeName('');
|
|
||||||
setNewTypeColor('#6366f1');
|
|
||||||
setNewTypeShape('rectangle');
|
|
||||||
setNewTypeDescription('');
|
|
||||||
setNewTypeIcon('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteType = async (id: string) => {
|
const handleDeleteType = async (id: string) => {
|
||||||
|
const type = nodeTypes.find(t => t.id === id);
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Delete Actor Type',
|
title: 'Delete Actor Type',
|
||||||
message: 'Are you sure you want to delete this actor type? This action cannot be undone.',
|
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) {
|
if (confirmed) {
|
||||||
deleteNodeType(id);
|
deleteNodeType(id);
|
||||||
|
showToast(`Actor type "${type?.label}" deleted`, 'success');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditType = (type: NodeTypeConfig) => {
|
const handleEditType = (type: NodeTypeConfig) => {
|
||||||
setEditingId(type.id);
|
setEditingType(type);
|
||||||
setEditLabel(type.label);
|
|
||||||
setEditColor(type.color);
|
|
||||||
setEditShape(type.shape || 'rectangle');
|
|
||||||
setEditIcon(type.icon || '');
|
|
||||||
setEditDescription(type.description || '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = (id: string, updates: { label: string; color: string; shape: NodeShape; icon?: string; description?: string }) => {
|
||||||
if (!editingId || !editLabel.trim()) return;
|
updateNodeType(id, updates);
|
||||||
|
setEditingType(null);
|
||||||
updateNodeType(editingId, {
|
showToast(`Actor type "${updates.label}" updated`, 'success');
|
||||||
label: editLabel.trim(),
|
|
||||||
color: editColor,
|
|
||||||
shape: editShape,
|
|
||||||
icon: editIcon || undefined,
|
|
||||||
description: editDescription.trim() || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
setEditingId(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Main Modal */}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2>
|
<h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - Two-Column or Full-Width Edit */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-hidden flex">
|
||||||
{/* Add New Type Form */}
|
{editingType ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
/* Full-Width Edit Mode */
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Actor Type</h3>
|
<div className="w-full p-6 overflow-y-auto">
|
||||||
<NodeTypeForm
|
<div className="max-w-2xl mx-auto">
|
||||||
name={newTypeName}
|
<EditTypeInline
|
||||||
color={newTypeColor}
|
type={editingType}
|
||||||
shape={newTypeShape}
|
onSave={handleSaveEdit}
|
||||||
icon={newTypeIcon}
|
onCancel={handleCancelEdit}
|
||||||
description={newTypeDescription}
|
|
||||||
onNameChange={setNewTypeName}
|
|
||||||
onColorChange={setNewTypeColor}
|
|
||||||
onShapeChange={setNewTypeShape}
|
|
||||||
onIconChange={setNewTypeIcon}
|
|
||||||
onDescriptionChange={setNewTypeDescription}
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// View mode
|
<>
|
||||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
{/* Left Column - Quick Add (60%) */}
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
|
||||||
<div
|
<div>
|
||||||
className="w-8 h-8 rounded"
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">
|
||||||
style={{ backgroundColor: type.color }}
|
Quick Add Actor Type
|
||||||
/>
|
</h3>
|
||||||
<div className="flex-1">
|
<QuickAddTypeForm onAdd={handleAddType} />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
{ConfirmDialogComponent}
|
{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) => {
|
const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-xs font-medium text-gray-700">
|
||||||
Shape
|
Shape (optional)
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{SHAPE_OPTIONS.map((option) => {
|
{SHAPE_OPTIONS.map((option) => {
|
||||||
const isSelected = value === option.id;
|
const isSelected = value === option.id;
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,27 +61,30 @@ const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProp
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(option.id)}
|
onClick={() => onChange(option.id)}
|
||||||
className={`
|
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
|
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'}
|
${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'}
|
||||||
`}
|
`}
|
||||||
title={option.description}
|
title={option.description}
|
||||||
|
aria-label={option.label}
|
||||||
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
{/* Shape Preview */}
|
{/* Shape Preview */}
|
||||||
<div className="flex justify-center items-center h-12 mb-2">
|
<div className="flex justify-center items-center h-8">
|
||||||
<ShapePreview shape={option.id} color={color} size={40} />
|
<ShapePreview shape={option.id} color={color} size={28} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shape Label */}
|
{/* Shape Label */}
|
||||||
<div className="text-xs text-center text-gray-700 font-medium">
|
<div className="text-xs text-center text-gray-700 font-medium mt-1 truncate">
|
||||||
{option.label}
|
{option.label.split(' ')[0]}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Indicator */}
|
{/* Selected Indicator */}
|
||||||
{isSelected && (
|
{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
|
<svg
|
||||||
className="w-3 h-3 text-white"
|
className="w-2 h-2 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
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 {
|
button {
|
||||||
transition: all 0.2s ease-in-out;
|
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