|
12 | 12 | */ |
13 | 13 | "use client"; |
14 | 14 |
|
15 | | -import React, { useState, useRef, useMemo, useEffect } from "react"; |
| 15 | +import React, { useState, useRef, useMemo, useCallback, useEffect } from "react"; |
16 | 16 | import { TreeNode, Template, Field, AttachmentInfo, XYChartData, QueryDefinition, QueryRule, ConditionalRuleOperator, ChecklistItem, SimpleQueryRule } from "@/lib/types"; |
17 | 17 | import { Button } from "../ui/button"; |
18 | 18 | import { Input } from "../ui/input"; |
@@ -138,6 +138,56 @@ const operatorLabels: Record<ConditionalRuleOperator, string> = { |
138 | 138 | less_than: 'Less Than', |
139 | 139 | }; |
140 | 140 |
|
| 141 | +const XYChartSpreadsheetEditor = ({ |
| 142 | + points, |
| 143 | + onChange, |
| 144 | + isDarkMode |
| 145 | +}: { |
| 146 | + points: { x: string; y: string }[]; |
| 147 | + onChange: (newPoints: { x: string; y: string }[]) => void; |
| 148 | + isDarkMode: boolean; |
| 149 | +}) => { |
| 150 | + // We use stable references for the spreadsheet data to avoid infinite loops |
| 151 | + const spreadsheetData = useMemo(() => { |
| 152 | + const minRows = Math.max(points.length + 5, 5); |
| 153 | + return Array.from({ length: minRows }, (_, i) => { |
| 154 | + const point = points[i]; |
| 155 | + return [ |
| 156 | + { value: point?.x !== undefined ? String(point.x) : '' }, |
| 157 | + { value: point?.y !== undefined ? String(point.y) : '' } |
| 158 | + ]; |
| 159 | + }); |
| 160 | + }, [points]); |
| 161 | + |
| 162 | + const handleChange = useCallback((newData: any[][]) => { |
| 163 | + const newPoints = newData |
| 164 | + .filter(row => row[0]?.value || row[1]?.value) |
| 165 | + .map(row => ({ |
| 166 | + x: String(row[0]?.value || ''), |
| 167 | + y: String(row[1]?.value || '') |
| 168 | + })); |
| 169 | + |
| 170 | + // Only trigger parent update if something actually changed |
| 171 | + // This is CRITICAL to prevent infinite loops if Spreadsheet fires on mount |
| 172 | + if (JSON.stringify(newPoints) !== JSON.stringify(points)) { |
| 173 | + onChange(newPoints); |
| 174 | + } |
| 175 | + }, [points, onChange]); |
| 176 | + |
| 177 | + return ( |
| 178 | + <div className="overflow-x-auto rounded-md border w-full bg-background max-h-[400px]"> |
| 179 | + <div className="min-w-full inline-block align-middle"> |
| 180 | + <Spreadsheet |
| 181 | + darkMode={isDarkMode} |
| 182 | + data={spreadsheetData} |
| 183 | + columnLabels={['X', 'Y']} |
| 184 | + onChange={handleChange} |
| 185 | + /> |
| 186 | + </div> |
| 187 | + </div> |
| 188 | + ); |
| 189 | +}; |
| 190 | + |
141 | 191 |
|
142 | 192 | export const NodeForm = ({ |
143 | 193 | node, |
@@ -182,7 +232,8 @@ export const NodeForm = ({ |
182 | 232 | }); |
183 | 233 |
|
184 | 234 | const { toast } = useToast(); |
185 | | - const { currentUser } = useAuthContext(); |
| 235 | + const { currentUser, theme } = useAuthContext(); |
| 236 | + const isDarkMode = theme === 'dark' || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches); |
186 | 237 | const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({}); |
187 | 238 | const [uploadingStates, setUploadingStates] = useState<Record<string, boolean>>({}); |
188 | 239 | const [dragOverStates, setDragOverStates] = useState<Record<string, boolean>>({}); |
@@ -774,24 +825,28 @@ export const NodeForm = ({ |
774 | 825 | } |
775 | 826 | case 'xy-chart': { |
776 | 827 | const chartData: XYChartData = { points: [], ...formData[field.id] }; |
| 828 | + |
777 | 829 | renderedContent = ( |
778 | | - <div className="space-y-2"> |
| 830 | + <div className="space-y-4"> |
779 | 831 | <div className="grid grid-cols-2 gap-4"> |
780 | 832 | <div className="space-y-2"> <Label htmlFor={`${field.id}-x-label`} className="text-xs">X-Axis Label</Label> <Input id={`${field.id}-x-label`} placeholder="e.g., Time (s)" value={chartData.xAxisLabel || ''} onChange={e => handleChartDataChange(field.id, 0, 'xAxisLabel', e.target.value)} /></div> |
781 | 833 | <div className="space-y-2"> <Label htmlFor={`${field.id}-y-label`} className="text-xs">Y-Axis Label</Label> <Input id={`${field.id}-y-label`} placeholder="e.g., Temperature (°C)" value={chartData.yAxisLabel || ''} onChange={e => handleChartDataChange(field.id, 0, 'yAxisLabel', e.target.value)} /></div> |
782 | 834 | </div> |
| 835 | + |
783 | 836 | <div className="space-y-2"> |
784 | | - {chartData.points.map((dataPoint, index) => ( |
785 | | - <Card key={index} className="bg-muted/50 p-2"> |
786 | | - <div className="flex items-center gap-2"> |
787 | | - <Label className="text-xs w-8">X:</Label> <Input type="number" value={dataPoint.x} onChange={e => handleChartDataChange(field.id, index, 'x', e.target.value)} className="h-8" /> |
788 | | - <Label className="text-xs w-8">Y:</Label> <Input type="number" value={dataPoint.y} onChange={e => handleChartDataChange(field.id, index, 'y', e.target.value)} className="h-8" /> |
789 | | - <Button type="button" variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleRemoveChartRow(field.id, index)}> <Trash2 className="h-4 w-4" /> </Button> |
790 | | - </div> |
791 | | - </Card> |
792 | | - ))} |
| 837 | + <Label className="text-xs">Data Points (X, Y)</Label> |
| 838 | + <p className="text-xs text-muted-foreground mb-2">You can copy and paste 2 columns of numerical data directly here.</p> |
| 839 | + <XYChartSpreadsheetEditor |
| 840 | + points={chartData.points} |
| 841 | + isDarkMode={isDarkMode} |
| 842 | + onChange={(newPoints) => { |
| 843 | + handleDataChange(field.id, { |
| 844 | + ...chartData, |
| 845 | + points: newPoints |
| 846 | + }); |
| 847 | + }} |
| 848 | + /> |
793 | 849 | </div> |
794 | | - <Button type="button" variant="outline" size="sm" onClick={() => handleAddChartRow(field.id)} className="mt-2"><PlusCircle className="mr-2 h-4 w-4" /> Add Data Point</Button> |
795 | 850 | </div> |
796 | 851 | ); |
797 | 852 | break; |
|
0 commit comments