From 3c9ae0d4e95510439d7655f9dfd4b630bc98b168 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 2 Dec 2025 20:49:21 +0000 Subject: [PATCH 1/3] feat: add schema-based topic publishing with form UI Implement dynamic form generation for ROS 2 message publishing based on topic schemas, with synchronized tree and panel navigation. Features: - Schema-based form UI with support for nested objects and arrays - Dynamic JSON generation that reflects form state in real-time - Numeric input validation (uint/float types with proper constraints) - Topic integration in entity tree sidebar - Optimized data loading to prevent duplicate API requests - Auto-expansion of component nodes when selected --- src/components/EntityDetailPanel.tsx | 277 +++++++++--------------- src/components/EntityTreeNode.tsx | 4 +- src/components/SchemaFormField.tsx | 307 +++++++++++++++++++++++++++ src/components/TopicPublishForm.tsx | 225 ++++++++++++++++++++ src/lib/schema-utils.ts | 87 ++++++++ src/lib/sovd-api.ts | 114 ++++++++-- src/lib/store.ts | 132 +++++++++++- src/lib/types.ts | 47 +++- 8 files changed, 996 insertions(+), 197 deletions(-) create mode 100644 src/components/SchemaFormField.tsx create mode 100644 src/components/TopicPublishForm.tsx create mode 100644 src/lib/schema-utils.ts diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index be6f299..576188d 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -1,11 +1,9 @@ -import { useState } from 'react'; import { useShallow } from 'zustand/shallow'; -import { toast } from 'react-toastify'; -import { Copy, Loader2, Send, Radio, ChevronDown, ChevronRight } from 'lucide-react'; +import { Copy, Loader2, Radio } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; import { EmptyState } from '@/components/EmptyState'; +import { TopicPublishForm } from '@/components/TopicPublishForm'; import { useAppStore, type AppState } from '@/lib/store'; import type { ComponentTopic } from '@/lib/types'; @@ -30,78 +28,6 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { })) ); - const [expandedTopics, setExpandedTopics] = useState>(new Set()); - const [publishingTopics, setPublishingTopics] = useState>(new Set()); - const [topicInputs, setTopicInputs] = useState>({}); - - const toggleTopicExpanded = (topicPath: string) => { - setExpandedTopics(prev => { - const next = new Set(prev); - if (next.has(topicPath)) { - next.delete(topicPath); - } else { - next.add(topicPath); - } - return next; - }); - }; - - const handlePublishToTopic = async (topic: ComponentTopic, topicName: string) => { - if (!client || !selectedEntity) return; - - const inputData = topicInputs[topic.topic] || ''; - if (!inputData.trim()) { - toast.error('Please enter message data'); - return; - } - - // Parse JSON before setting publishing state to avoid stuck state on parse error - let data: unknown; - try { - data = JSON.parse(inputData); - } catch { - toast.error('Invalid JSON format. Please check your message data.'); - return; - } - - setPublishingTopics(prev => new Set(prev).add(topic.topic)); - - try { - const messageType = inferMessageType(topic.data); - - await client.publishToComponentTopic(selectedEntity.id, topicName, { - type: messageType, - data, - }); - - toast.success(`Published to ${topic.topic}`); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to publish'; - toast.error(`Publish failed: ${message}`); - } finally { - setPublishingTopics(prev => { - const next = new Set(prev); - next.delete(topic.topic); - return next; - }); - } - }; - - // Helper to infer message type from data structure - const inferMessageType = (data: unknown): string => { - // This is a simplified heuristic - in production you'd want to query type info - if (data && typeof data === 'object') { - const keys = Object.keys(data as object); - if (keys.includes('linear') && keys.includes('angular')) { - return 'geometry_msgs/msg/Twist'; - } - if (keys.includes('data') && keys.length === 1) { - return 'std_msgs/msg/String'; - } - } - return 'std_msgs/msg/String'; // Default fallback - }; - const handleCopyEntity = async () => { if (selectedEntity) { await navigator.clipboard.writeText(JSON.stringify(selectedEntity, null, 2)); @@ -137,6 +63,7 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { // Entity detail view if (selectedEntity) { + const isTopic = selectedEntity.type === 'topic'; const hasTopics = selectedEntity.type === 'component' && selectedEntity.topics && selectedEntity.topics.length > 0; const hasError = !!selectedEntity.error; @@ -171,113 +98,119 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { - ) : hasTopics ? ( -
- {/* Topics List */} - {selectedEntity.topics!.map((topic: ComponentTopic) => { - const topicName = topic.topic.split('/').pop() || topic.topic; - const isExpanded = expandedTopics.has(topic.topic); - const isPublishing = publishingTopics.has(topic.topic); - const hasNoData = topic.data === null || topic.data === undefined; - - return ( - - -
-
- -
-
- {topic.topic} - {hasNoData && ( - - No Data - - )} -
- - {hasNoData - ? 'No messages received (topic may be inactive)' - : `Last update: ${new Date(topic.timestamp / 1000000).toLocaleString()}` - } - -
-
- + {topic.type && ( + + {topic.type} + + )} +
+ + {isMetadataOnly + ? `Schema available • ${topic.publisher_count ?? 0} pub / ${topic.subscriber_count ?? 0} sub` + : hasNoData + ? 'No messages received (topic may be inactive)' + : `Last update: ${new Date(topic.timestamp / 1000000).toLocaleString()}` + } +
- - {isExpanded && ( - - {/* Latest Data */} -
-
Latest Message
- {hasNoData ? ( -
- No data available - topic exists but is not publishing messages +
+ + + {/* Latest Data or Schema Info */} +
+
+ {isMetadataOnly ? 'Message Schema' : 'Latest Message'} +
+ {isMetadataOnly ? ( +
+ {!!topic.type_info?.default_value && ( +
+ Default values available for form editing
- ) : ( -
-                                                            {JSON.stringify(topic.data, null, 2)}
-                                                        
)}
- - {/* Publish Form - only show if we have data to infer type */} - {!hasNoData && ( -
-
Publish Message
-