diff --git a/src/__tests__/integration/tangible-cascade.test.tsx b/src/__tests__/integration/tangible-cascade.test.tsx new file mode 100644 index 0000000..b6596c0 --- /dev/null +++ b/src/__tests__/integration/tangible-cascade.test.tsx @@ -0,0 +1,406 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useWorkspaceStore } from "../../stores/workspaceStore"; +import { useTimelineStore } from "../../stores/timelineStore"; +import { useGraphStore } from "../../stores/graphStore"; +import { resetWorkspaceStore } from "../../test-utils/test-helpers"; +import type { LabelConfig } from "../../types"; + +describe("Tangible Cascade Cleanup Integration Tests", () => { + let documentId: string; + + beforeEach(() => { + localStorage.clear(); + resetWorkspaceStore(); + documentId = useWorkspaceStore.getState().createDocument("Test Doc"); + }); + + describe("Label deletion cascade", () => { + it("should remove deleted label from tangible filterLabels", () => { + const { addLabelToDocument, deleteLabelFromDocument } = + useWorkspaceStore.getState(); + + // Add labels + const label1: LabelConfig = { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }; + const label2: LabelConfig = { + id: "label-2", + name: "Label 2", + color: "#111", + appliesTo: "both", + }; + + addLabelToDocument(documentId, label1); + addLabelToDocument(documentId, label2); + + // Add tangible with both labels + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "Filter Tangible", + mode: "filter", + filterLabels: ["label-1", "label-2"], + }); + + // Delete label-1 + deleteLabelFromDocument(documentId, "label-1"); + + // Check tangible only has label-2 + const doc = useWorkspaceStore.getState().documents.get(documentId); + const tangible = doc?.tangibles?.[0]; + + expect(tangible?.filterLabels).toEqual(["label-2"]); + }); + + it("should not delete tangible when label is removed from filterLabels", () => { + const { addLabelToDocument, deleteLabelFromDocument } = + useWorkspaceStore.getState(); + + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "Filter Tangible", + mode: "filter", + filterLabels: ["label-1"], + }); + + deleteLabelFromDocument(documentId, "label-1"); + + const doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(1); + expect(doc?.tangibles?.[0].filterLabels).toEqual([]); + }); + + it("should not affect state mode tangibles when label is deleted", () => { + const { addLabelToDocument, deleteLabelFromDocument } = + useWorkspaceStore.getState(); + + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + const timelineStore = useTimelineStore.getState(); + const stateId = timelineStore.createState("Test State", undefined, false); + + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "State Tangible", + mode: "state", + stateId: stateId, + }); + + deleteLabelFromDocument(documentId, "label-1"); + + const doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(1); + expect(doc?.tangibles?.[0].mode).toBe("state"); + expect(doc?.tangibles?.[0].stateId).toBe(stateId); + }); + }); + + describe("State deletion cascade", () => { + it("should delete tangible when referenced state is deleted", () => { + const timelineStore = useTimelineStore.getState(); + + // Create a new state + const stateId = timelineStore.createState("Test State", undefined, false); + + // Add tangible referencing this state + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "State Tangible", + mode: "state", + stateId: stateId, + }); + + // Verify tangible exists + let doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(1); + + // Switch to root state (can't delete current state) + const timeline = timelineStore.timelines.get(documentId); + timelineStore.switchToState(timeline!.rootStateId); + + // Delete the state + timelineStore.deleteState(stateId); + + // Tangible should be deleted + doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(0); + }); + + it("should delete stateDial tangibles when state is deleted", () => { + const timelineStore = useTimelineStore.getState(); + + const stateId = timelineStore.createState("Dial State", undefined, false); + + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "Dial Tangible", + mode: "stateDial", + stateId: stateId, + }); + + const timeline = timelineStore.timelines.get(documentId); + timelineStore.switchToState(timeline!.rootStateId); + timelineStore.deleteState(stateId); + + const doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(0); + }); + + it("should not delete filter mode tangibles when state is deleted", () => { + const timelineStore = useTimelineStore.getState(); + + const stateId = timelineStore.createState("Test State", undefined, false); + + const { addLabelToDocument, addTangibleToDocument } = + useWorkspaceStore.getState(); + + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "Filter Tangible", + mode: "filter", + filterLabels: ["label-1"], + }); + + const timeline = timelineStore.timelines.get(documentId); + timelineStore.switchToState(timeline!.rootStateId); + timelineStore.deleteState(stateId); + + const doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(1); + }); + + it("should handle multiple tangibles referencing same state", () => { + const timelineStore = useTimelineStore.getState(); + + const stateId = timelineStore.createState( + "Shared State", + undefined, + false, + ); + + const { addTangibleToDocument } = useWorkspaceStore.getState(); + addTangibleToDocument(documentId, { + name: "State Tangible 1", + mode: "state", + stateId: stateId, + id: "", // Placeholder, will be auto-generated + }); + addTangibleToDocument(documentId, { + name: "State Tangible 2", + mode: "state", + stateId: stateId, + id: "", // Placeholder, will be auto-generated + }); + addTangibleToDocument(documentId, { + name: "Different State", + mode: "state", + stateId: "other-state", + id: "", // Placeholder, will be auto-generated + }); + + const timeline = timelineStore.timelines.get(documentId); + timelineStore.switchToState(timeline!.rootStateId); + timelineStore.deleteState(stateId); + + const doc = useWorkspaceStore.getState().documents.get(documentId); + // Only tangible with ID 'different-state' (auto-generated from name) should remain + expect(doc?.tangibles).toHaveLength(1); + }); + }); + + describe("Document loading and persistence", () => { + it("should sync tangibles to graphStore when added to active document", () => { + const { addTangibleToDocument, addLabelToDocument } = + useWorkspaceStore.getState(); + + // Add label first (required for filter mode) + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + // Add tangibles to the active document + addTangibleToDocument(documentId, { + name: "Test Tangible 1", + mode: "filter", + filterLabels: ["label-1"], + hardwareId: "token-001", + id: "", + }); + addTangibleToDocument(documentId, { + name: "Test Tangible 2", + mode: "filter", + filterLabels: ["label-1"], + hardwareId: "token-002", + id: "", + }); + + // Tangibles should be in graphStore (synced by addTangibleToDocument) + const graphTangibles = useGraphStore.getState().tangibles; + expect(graphTangibles).toHaveLength(2); + expect(graphTangibles[0].name).toBe("Test Tangible 1"); + expect(graphTangibles[1].name).toBe("Test Tangible 2"); + }); + + it("should persist tangibles in document storage", () => { + const { addTangibleToDocument, addLabelToDocument, saveDocument } = + useWorkspaceStore.getState(); + + // Add label first (required for filter mode) + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + // Add tangible + addTangibleToDocument(documentId, { + name: "Persistent Tangible", + mode: "filter", + filterLabels: ["label-1"], + hardwareId: "token-persistent", + id: "", + }); + + // Save document (though addTangibleToDocument already saves to storage) + saveDocument(documentId); + + // Verify tangible is persisted in document + const doc = useWorkspaceStore.getState().documents.get(documentId); + expect(doc?.tangibles).toHaveLength(1); + expect(doc?.tangibles?.[0].hardwareId).toBe("token-persistent"); + expect(doc?.tangibles?.[0].name).toBe("Persistent Tangible"); + + // Verify tangible is also in graphStore (synced by addTangibleToDocument) + const graphTangibles = useGraphStore.getState().tangibles; + expect(graphTangibles).toHaveLength(1); + expect(graphTangibles[0].hardwareId).toBe("token-persistent"); + }); + + it("should mark document as dirty when tangible is added", () => { + const { addTangibleToDocument, addLabelToDocument } = + useWorkspaceStore.getState(); + + // Add label first + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + // Clear dirty flag set by label addition + const metadata = useWorkspaceStore + .getState() + .documentMetadata.get(documentId); + if (metadata) { + metadata.isDirty = false; + } + + // Add tangible + addTangibleToDocument(documentId, { + name: "Test Tangible", + mode: "filter", + filterLabels: ["label-1"], + id: "", + }); + + // Document should be marked as dirty + const updatedMetadata = useWorkspaceStore + .getState() + .documentMetadata.get(documentId); + expect(updatedMetadata?.isDirty).toBe(true); + }); + }); + + describe("Cross-store synchronization", () => { + it("should sync tangibles to graphStore when label is deleted", () => { + const { + addLabelToDocument, + deleteLabelFromDocument, + addTangibleToDocument, + } = useWorkspaceStore.getState(); + + addLabelToDocument(documentId, { + id: "label-1", + name: "Label 1", + color: "#000", + appliesTo: "both", + }); + + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "Filter Tangible", + mode: "filter", + filterLabels: ["label-1"], + }); + + // Before deletion + let graphState = useGraphStore.getState(); + expect(graphState.tangibles[0].filterLabels).toEqual(["label-1"]); + + // Delete label + deleteLabelFromDocument(documentId, "label-1"); + + // After deletion - graphStore should be synced + graphState = useGraphStore.getState(); + expect(graphState.tangibles[0].filterLabels).toEqual([]); + }); + + it("should remove tangibles from graphStore when state is deleted", () => { + const timelineStore = useTimelineStore.getState(); + const { addTangibleToDocument } = useWorkspaceStore.getState(); + + const stateId = timelineStore.createState("Test State", undefined, false); + + addTangibleToDocument(documentId, { + id: "tangible-1", + name: "State Tangible", + mode: "state", + stateId: stateId, + }); + + // Before deletion + let graphState = useGraphStore.getState(); + expect(graphState.tangibles).toHaveLength(1); + + // Delete state + const timeline = timelineStore.timelines.get(documentId); + timelineStore.switchToState(timeline!.rootStateId); + timelineStore.deleteState(stateId); + + // After deletion - graphStore should be synced + graphState = useGraphStore.getState(); + expect(graphState.tangibles).toHaveLength(0); + }); + }); +}); diff --git a/src/components/Config/EditTangibleInline.tsx b/src/components/Config/EditTangibleInline.tsx new file mode 100644 index 0000000..4cadb7a --- /dev/null +++ b/src/components/Config/EditTangibleInline.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect, KeyboardEvent } from 'react'; +import SaveIcon from '@mui/icons-material/Save'; +import TangibleForm from './TangibleForm'; +import type { TangibleConfig, TangibleMode, LabelConfig, ConstellationState } from '../../types'; + +interface Props { + tangible: TangibleConfig; + labels: LabelConfig[]; + states: ConstellationState[]; + onSave: ( + id: string, + updates: { + name: string; + mode: TangibleMode; + description?: string; + hardwareId?: string; + filterLabels?: string[]; + stateId?: string; + } + ) => void; + onCancel: () => void; +} + +const EditTangibleInline = ({ tangible, labels, states, onSave, onCancel }: Props) => { + const [name, setName] = useState(''); + const [mode, setMode] = useState('filter'); + const [description, setDescription] = useState(''); + const [hardwareId, setHardwareId] = useState(''); + const [filterLabels, setFilterLabels] = useState([]); + const [stateId, setStateId] = useState(''); + + // Sync state with tangible prop + useEffect(() => { + if (tangible) { + setName(tangible.name); + setMode(tangible.mode); + setDescription(tangible.description || ''); + setHardwareId(tangible.hardwareId || ''); + setFilterLabels(tangible.filterLabels || []); + setStateId(tangible.stateId || ''); + } + }, [tangible]); + + const handleSave = () => { + if (!name.trim()) return; + + // Validate mode-specific fields + if (mode === 'filter' && filterLabels.length === 0) { + alert('Filter mode requires at least one label'); + return; + } + if ((mode === 'state' || mode === 'stateDial') && !stateId) { + alert('State mode requires a state selection'); + return; + } + + onSave(tangible.id, { + name: name.trim(), + mode, + description: description.trim() || undefined, + hardwareId: hardwareId.trim() || undefined, + filterLabels: mode === 'filter' ? filterLabels : undefined, + stateId: (mode === 'state' || mode === 'stateDial') ? stateId : undefined, + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+ {/* Form Fields */} +
+ +
+ + {/* Actions */} +
+
+ + +
+ + {/* Keyboard Shortcut Hint */} +
+ + {navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter + {' '} + to save, Esc to cancel +
+
+
+ ); +}; + +export default EditTangibleInline; diff --git a/src/components/Config/QuickAddTangibleForm.tsx b/src/components/Config/QuickAddTangibleForm.tsx new file mode 100644 index 0000000..4ac8934 --- /dev/null +++ b/src/components/Config/QuickAddTangibleForm.tsx @@ -0,0 +1,117 @@ +import { useState, useRef, KeyboardEvent } from 'react'; +import TangibleForm from './TangibleForm'; +import type { TangibleMode, LabelConfig, ConstellationState } from '../../types'; + +interface Props { + labels: LabelConfig[]; + states: ConstellationState[]; + onAdd: (tangible: { + name: string; + mode: TangibleMode; + description: string; + hardwareId?: string; + filterLabels?: string[]; + stateId?: string; + }) => void; +} + +const QuickAddTangibleForm = ({ labels, states, onAdd }: Props) => { + const [name, setName] = useState(''); + const [hardwareId, setHardwareId] = useState(''); + const [mode, setMode] = useState('filter'); + const [description, setDescription] = useState(''); + const [filterLabels, setFilterLabels] = useState([]); + const [stateId, setStateId] = useState(''); + + const nameInputRef = useRef(null); + + const handleSubmit = () => { + if (!name.trim()) { + nameInputRef.current?.focus(); + return; + } + + // Validate mode-specific fields + if (mode === 'filter' && filterLabels.length === 0) { + alert('Filter mode requires at least one label'); + return; + } + if ((mode === 'state' || mode === 'stateDial') && !stateId) { + alert('State mode requires a state selection'); + return; + } + + onAdd({ + name: name.trim(), + mode, + description, + hardwareId: hardwareId.trim() || undefined, + filterLabels: mode === 'filter' ? filterLabels : undefined, + stateId: (mode === 'state' || mode === 'stateDial') ? stateId : undefined, + }); + + // Reset form + setName(''); + setHardwareId(''); + setMode('filter'); + setDescription(''); + setFilterLabels([]); + setStateId(''); + + nameInputRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setName(''); + setHardwareId(''); + setMode('filter'); + setDescription(''); + setFilterLabels([]); + setStateId(''); + nameInputRef.current?.blur(); + } + }; + + return ( +
+ + + + + {/* Keyboard Shortcuts Hint */} + {name && ( +
+ Press Enter to add, Escape to cancel +
+ )} +
+ ); +}; + +export default QuickAddTangibleForm; diff --git a/src/components/Config/TangibleConfig.tsx b/src/components/Config/TangibleConfig.tsx new file mode 100644 index 0000000..9882a10 --- /dev/null +++ b/src/components/Config/TangibleConfig.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; +import { useConfirm } from '../../hooks/useConfirm'; +import { useToastStore } from '../../stores/toastStore'; +import { useTimelineStore } from '../../stores/timelineStore'; +import QuickAddTangibleForm from './QuickAddTangibleForm'; +import TangibleManagementList from './TangibleManagementList'; +import EditTangibleInline from './EditTangibleInline'; +import type { TangibleConfig as TangibleConfigType, TangibleMode } from '../../types'; + +interface Props { + isOpen: boolean; + onClose: () => void; + initialEditingTangibleId?: string | null; +} + +const TangibleConfigModal = ({ isOpen, onClose, initialEditingTangibleId }: Props) => { + const { tangibles, labels, addTangible, updateTangible, deleteTangible } = useGraphWithHistory(); + const { confirm, ConfirmDialogComponent } = useConfirm(); + const { showToast } = useToastStore(); + const { getAllStates } = useTimelineStore(); + + const [editingTangible, setEditingTangible] = useState(null); + + // Get all available states for state mode + const availableStates = useMemo(() => getAllStates(), [getAllStates]); + + // Set editing tangible when initialEditingTangibleId changes + useEffect(() => { + if (initialEditingTangibleId && isOpen) { + const tangibleToEdit = tangibles.find((t) => t.id === initialEditingTangibleId); + if (tangibleToEdit) { + setEditingTangible(tangibleToEdit); + } + } else if (!isOpen) { + setEditingTangible(null); + } + }, [initialEditingTangibleId, isOpen, tangibles]); + + const handleAddTangible = (tangible: { + name: string; + mode: TangibleMode; + description: string; + hardwareId?: string; + filterLabels?: string[]; + stateId?: string; + }) => { + // Validate mode-specific fields + if (tangible.mode === 'filter' && (!tangible.filterLabels || tangible.filterLabels.length === 0)) { + showToast('Filter mode requires at least one label', 'error'); + return; + } + if ((tangible.mode === 'state' || tangible.mode === 'stateDial') && !tangible.stateId) { + showToast('State mode requires a state selection', 'error'); + return; + } + + const newTangible: Omit = { + name: tangible.name, + mode: tangible.mode, + description: tangible.description || undefined, + hardwareId: tangible.hardwareId, + filterLabels: tangible.filterLabels, + stateId: tangible.stateId, + }; + + addTangible(newTangible as TangibleConfigType); + showToast(`Tangible "${tangible.name}" created`, 'success'); + }; + + const handleDeleteTangible = async (id: string) => { + const tangible = tangibles.find((t) => t.id === id); + + const confirmed = await confirm({ + title: 'Delete Tangible', + message: 'Are you sure you want to delete this tangible? This action cannot be undone.', + confirmLabel: 'Delete', + severity: 'danger', + }); + + if (confirmed) { + deleteTangible(id); + showToast(`Tangible "${tangible?.name}" deleted`, 'success'); + } + }; + + const handleEditTangible = (tangible: TangibleConfigType) => { + setEditingTangible(tangible); + }; + + const handleSaveEdit = ( + id: string, + updates: { name: string; mode: TangibleMode; description?: string; hardwareId?: string; filterLabels?: string[]; stateId?: string } + ) => { + updateTangible(id, updates); + setEditingTangible(null); + showToast(`Tangible "${updates.name}" updated`, 'success'); + }; + + const handleCancelEdit = () => { + setEditingTangible(null); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Main Modal */} +
+
+ {/* Header */} +
+

Configure Tangibles

+

+ Set up physical objects for presentation mode interactions +

+
+ + {/* Content - Two-Column or Full-Width Edit */} +
+ {editingTangible ? ( + /* Full-Width Edit Mode */ +
+
+ +
+
+ ) : ( + <> + {/* Left Column - Quick Add (60%) */} +
+
+

+ Quick Add Tangible +

+ +
+ + {/* Helper Text */} +
+

About Tangibles

+
    +
  • • Tangibles are physical objects used in presentation mode
  • +
  • • Filter mode: activates filters on selected labels
  • +
  • • State mode: switches to a specific timeline state
  • +
  • • Internal IDs are auto-generated from names
  • +
  • • Hardware IDs map configurations to physical tokens/devices
  • +
  • • You can change hardware IDs to swap physical tokens
  • +
+
+
+ + {/* Right Column - Management (40%) */} +
+
+
+

+ Tangibles ({tangibles.length}) +

+
+ +
+
+ + )} +
+ + {/* Footer - Hidden when editing */} + {!editingTangible && ( +
+ +
+ )} +
+
+ + {/* Confirmation Dialog */} + {ConfirmDialogComponent} + + ); +}; + +export default TangibleConfigModal; diff --git a/src/components/Config/TangibleForm.tsx b/src/components/Config/TangibleForm.tsx new file mode 100644 index 0000000..9081962 --- /dev/null +++ b/src/components/Config/TangibleForm.tsx @@ -0,0 +1,180 @@ +import type { TangibleMode, LabelConfig, ConstellationState } from '../../types'; + +interface Props { + name: string; + mode: TangibleMode; + description: string; + hardwareId: string; + filterLabels: string[]; + stateId: string; + labels: LabelConfig[]; + states: ConstellationState[]; + onNameChange: (value: string) => void; + onModeChange: (value: TangibleMode) => void; + onDescriptionChange: (value: string) => void; + onHardwareIdChange: (value: string) => void; + onFilterLabelsChange: (value: string[]) => void; + onStateIdChange: (value: string) => void; +} + +const TangibleForm = ({ + name, + mode, + description, + hardwareId, + filterLabels, + stateId, + labels, + states, + onNameChange, + onModeChange, + onDescriptionChange, + onHardwareIdChange, + onFilterLabelsChange, + onStateIdChange, +}: Props) => { + return ( +
+
+ + onNameChange(e.target.value)} + placeholder="e.g., Red Block, Filter Card" + 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" + /> +
+ +
+ + onHardwareIdChange(e.target.value)} + placeholder="e.g., token-001, device-a" + 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" + /> +

+ Maps this configuration to a physical token or device +

+
+ +
+ +
+ + + +
+
+ + {/* Mode-specific fields */} + {mode === 'filter' && ( +
+ +
+ {labels.length === 0 ? ( +

No labels available

+ ) : ( + labels.map((label) => ( + + )) + )} +
+
+ )} + + {(mode === 'state' || mode === 'stateDial') && ( +
+ + +
+ )} + +
+ + onDescriptionChange(e.target.value)} + placeholder="Brief description of this tangible" + 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" + /> +
+
+ ); +}; + +export default TangibleForm; diff --git a/src/components/Config/TangibleManagementList.tsx b/src/components/Config/TangibleManagementList.tsx new file mode 100644 index 0000000..18ec0ba --- /dev/null +++ b/src/components/Config/TangibleManagementList.tsx @@ -0,0 +1,97 @@ +import DeleteIcon from '@mui/icons-material/Delete'; +import type { TangibleConfig, LabelConfig, ConstellationState } from '../../types'; + +interface Props { + tangibles: TangibleConfig[]; + labels: LabelConfig[]; + states: ConstellationState[]; + onEdit: (tangible: TangibleConfig) => void; + onDelete: (id: string) => void; +} + +const TangibleManagementList = ({ tangibles, states, onEdit, onDelete }: Props) => { + if (tangibles.length === 0) { + return ( +
+

No tangibles yet.

+

Add your first tangible above.

+
+ ); + } + + const getModeDisplay = (tangible: TangibleConfig) => { + switch (tangible.mode) { + case 'filter': { + const labelCount = tangible.filterLabels?.length || 0; + return `Filter (${labelCount} label${labelCount !== 1 ? 's' : ''})`; + } + case 'state': { + const state = states.find(s => s.id === tangible.stateId); + return `State: ${state?.label || 'Unknown'}`; + } + case 'stateDial': { + const dialState = states.find(s => s.id === tangible.stateId); + return `State Dial: ${dialState?.label || 'Unknown'}`; + } + default: + return tangible.mode; + } + }; + + return ( +
+ {tangibles.map((tangible) => ( +
onEdit(tangible)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onEdit(tangible); + } + }} + aria-label={`Edit ${tangible.name}`} + > +
+
+

+ {tangible.name} +

+ {tangible.hardwareId && ( +

+ Hardware: {tangible.hardwareId} +

+ )} +

+ {getModeDisplay(tangible)} +

+ {tangible.description && ( +

+ {tangible.description} +

+ )} +
+
+ +
+
+
+ ))} +
+ ); +}; + +export default TangibleManagementList; diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 53efe61..d2937f3 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -8,6 +8,7 @@ import DocumentManager from '../Workspace/DocumentManager'; import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import LabelConfigModal from '../Config/LabelConfig'; +import TangibleConfigModal from '../Config/TangibleConfig'; import BibliographyConfigModal from '../Config/BibliographyConfig'; import InputDialog from '../Common/InputDialog'; import { useConfirm } from '../../hooks/useConfirm'; @@ -36,6 +37,7 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => const [showNodeConfig, setShowNodeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showLabelConfig, setShowLabelConfig] = useState(false); + const [showTangibleConfig, setShowTangibleConfig] = useState(false); const [showBibliographyConfig, setShowBibliographyConfig] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); @@ -184,6 +186,11 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => closeMenu(); }, [closeMenu]); + const handleConfigureTangibles = useCallback(() => { + setShowTangibleConfig(true); + closeMenu(); + }, [closeMenu]); + const handleManageBibliography = useCallback(() => { setShowBibliographyConfig(true); closeMenu(); @@ -395,6 +402,12 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) => > Configure Labels +