| 1 | "use client"; |
| 2 | |
| 3 | import { FileSpreadsheet, X } from "lucide-react"; |
| 4 | import { useEffect, useRef, useState } from "react"; |
| 5 | |
| 6 | import { |
| 7 | PRESENTATION_PORTAL_CONTENT_CLASS, |
| 8 | PRESENTATION_PORTAL_OVERLAY_CLASS, |
| 9 | } from "@/components/presentation/overlay-layers"; |
| 10 | import { Button } from "@/components/ui/button"; |
| 11 | import { |
| 12 | Credenza, |
| 13 | CredenzaContent, |
| 14 | CredenzaDescription, |
| 15 | CredenzaFooter, |
| 16 | CredenzaHeader, |
| 17 | CredenzaTitle, |
| 18 | } from "@/components/ui/credenza"; |
| 19 | import { |
| 20 | ChartDataEditor, |
| 21 | type ChartDataMode, |
| 22 | type ChartDataType, |
| 23 | type SeriesChartType, |
| 24 | } from "./chart-data-editor"; |
| 25 | import { importChartDataFromFile } from "./chart-data-editor/import-data"; |
| 26 | import { ChartRenderer } from "./charts/ChartRenderer"; |
| 27 | |
| 28 | interface ChartDataEditorDialogProps { |
| 29 | open: boolean; |
| 30 | onOpenChange: (open: boolean) => void; |
| 31 | data: ChartDataType; |
| 32 | onDataChange: (data: ChartDataType) => void; |
| 33 | chartType: ChartDataMode; |
| 34 | title?: string; |
| 35 | isComposedChart?: boolean; |
| 36 | seriesChartTypes?: Record<string, SeriesChartType>; |
| 37 | onSeriesChartTypesChange?: (types: Record<string, SeriesChartType>) => void; |
| 38 | previewChartType?: string; |
| 39 | chartOptions?: Record<string, unknown>; |
| 40 | } |
| 41 | |
| 42 | export function ChartDataEditorDialog({ |
| 43 | open, |
| 44 | onOpenChange, |
| 45 | data, |
| 46 | onDataChange, |
| 47 | chartType, |
| 48 | title = "Edit Chart Data", |
| 49 | isComposedChart = false, |
| 50 | seriesChartTypes: initialSeriesChartTypes, |
| 51 | onSeriesChartTypesChange, |
| 52 | previewChartType, |
| 53 | chartOptions, |
| 54 | }: ChartDataEditorDialogProps) { |
| 55 | const [localData, setLocalData] = useState<ChartDataType>(data); |
| 56 | const [localSeriesChartTypes, setLocalSeriesChartTypes] = useState< |
| 57 | Record<string, SeriesChartType> |
| 58 | >(initialSeriesChartTypes ?? {}); |
| 59 | const [importVersion, setImportVersion] = useState(0); |
| 60 | const [importError, setImportError] = useState<string | null>(null); |
| 61 | const fileInputRef = useRef<HTMLInputElement | null>(null); |
| 62 | |
| 63 | // Sync with external data when dialog opens |
| 64 | useEffect(() => { |
| 65 | if (open) { |
| 66 | setLocalData(data); |
| 67 | setLocalSeriesChartTypes(initialSeriesChartTypes ?? {}); |
| 68 | setImportError(null); |
| 69 | setImportVersion((version) => version + 1); |
| 70 | } |
| 71 | }, [open, data, initialSeriesChartTypes]); |
| 72 | |
| 73 | const handleSave = () => { |
| 74 | onDataChange(localData); |
| 75 | if (isComposedChart && onSeriesChartTypesChange) { |
| 76 | onSeriesChartTypesChange(localSeriesChartTypes); |
| 77 | } |
| 78 | onOpenChange(false); |
| 79 | }; |
| 80 | |
| 81 | const handleCancel = () => { |
| 82 | setLocalData(data); |
| 83 | setLocalSeriesChartTypes(initialSeriesChartTypes ?? {}); |
| 84 | onOpenChange(false); |
| 85 | }; |
| 86 | |
| 87 | const handleDataChange = (newData: ChartDataType) => { |
| 88 | setLocalData(newData); |
| 89 | }; |
| 90 | |
| 91 | const handleSeriesChartTypesChange = ( |
| 92 | types: Record<string, SeriesChartType>, |
| 93 | ) => { |
| 94 | setLocalSeriesChartTypes(types); |
| 95 | }; |
| 96 | |
| 97 | const handleImportFile = async (file: File) => { |
| 98 | try { |
| 99 | const importedData = await importChartDataFromFile(file, chartType); |
| 100 | setLocalData(importedData); |
| 101 | setImportVersion((version) => version + 1); |
| 102 | setImportError(null); |
| 103 | } catch (error) { |
| 104 | try { |
| 105 | setImportError( |
| 106 | error instanceof Error |
| 107 | ? error.message |
| 108 | : "Unable to import this file.", |
| 109 | ); |
| 110 | } catch (reactDoctorCatchError) { |
| 111 | if (fileInputRef.current) { |
| 112 | fileInputRef.current.value = ""; |
| 113 | } |
| 114 | throw reactDoctorCatchError; |
| 115 | } |
| 116 | } |
| 117 | if (fileInputRef.current) { |
| 118 | fileInputRef.current.value = ""; |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | const previewType = previewChartType ?? ""; |
| 123 | const previewOptions = { |
| 124 | ...(chartOptions ?? {}), |
| 125 | seriesChartTypes: localSeriesChartTypes, |
| 126 | previewMode: true, |
| 127 | }; |
| 128 | |
| 129 | return ( |
| 130 | <Credenza open={open} onOpenChange={onOpenChange}> |
| 131 | <CredenzaContent |
| 132 | overlayClassName={PRESENTATION_PORTAL_OVERLAY_CLASS} |
| 133 | shouldHaveClose={false} |
| 134 | className={`${PRESENTATION_PORTAL_CONTENT_CLASS} ignore-click-outside/toolbar flex h-[92dvh] max-h-[92dvh] w-screen max-w-none flex-col overflow-hidden rounded-t-xl p-0 sm:rounded-xl md:h-[calc(100dvh-3rem)] md:max-h-[calc(100dvh-3rem)] md:rounded-xl`} |
| 135 | > |
| 136 | <CredenzaHeader className="shrink-0 border-b px-4 py-3 sm:px-5 sm:py-4"> |
| 137 | <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3"> |
| 138 | <div className="min-w-0"> |
| 139 | <CredenzaTitle>{title}</CredenzaTitle> |
| 140 | <CredenzaDescription className="line-clamp-2"> |
| 141 | Edit the data and preview the chart before saving. |
| 142 | </CredenzaDescription> |
| 143 | </div> |
| 144 | <div className="flex shrink-0 items-center gap-2"> |
| 145 | <input |
| 146 | aria-label="chart data editor dialog control" |
| 147 | ref={fileInputRef} |
| 148 | type="file" |
| 149 | accept=".csv,text/csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" |
| 150 | className="hidden" |
| 151 | onChange={(event) => { |
| 152 | const file = event.target.files?.[0]; |
| 153 | if (file) void handleImportFile(file); |
| 154 | }} |
| 155 | /> |
| 156 | <Button |
| 157 | variant="outline" |
| 158 | className="hidden gap-2 sm:inline-flex" |
| 159 | onClick={() => fileInputRef.current?.click()} |
| 160 | > |
| 161 | <FileSpreadsheet className="size-4" /> |
| 162 | Import CSV/XLSX |
| 163 | </Button> |
| 164 | <Button |
| 165 | variant="outline" |
| 166 | className="size-9 p-0 sm:hidden" |
| 167 | onClick={() => fileInputRef.current?.click()} |
| 168 | aria-label="Import CSV or Excel data" |
| 169 | > |
| 170 | <FileSpreadsheet className="size-4" /> |
| 171 | </Button> |
| 172 | <Button |
| 173 | variant="ghost" |
| 174 | className="size-9 p-0" |
| 175 | onClick={handleCancel} |
| 176 | aria-label="Close chart data editor" |
| 177 | > |
| 178 | <X className="size-4" /> |
| 179 | </Button> |
| 180 | </div> |
| 181 | </div> |
| 182 | </CredenzaHeader> |
| 183 | |
| 184 | <div className="grid min-h-0 flex-1 grid-rows-[minmax(220px,34dvh)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[minmax(320px,0.9fr)_minmax(460px,1.1fr)] lg:grid-rows-none"> |
| 185 | <section className="min-h-0 border-b bg-muted/20 p-3 sm:p-4 lg:border-r lg:border-b-0 lg:p-5"> |
| 186 | <div className="h-full min-h-0 overflow-hidden rounded-lg border bg-background p-2 sm:p-3"> |
| 187 | {previewType ? ( |
| 188 | <ChartRenderer |
| 189 | chartType={previewType} |
| 190 | chartData={localData} |
| 191 | chartOptions={previewOptions} |
| 192 | className="h-full min-h-0 border-0 shadow-none" |
| 193 | /> |
| 194 | ) : ( |
| 195 | <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> |
| 196 | Chart preview unavailable. |
| 197 | </div> |
| 198 | )} |
| 199 | </div> |
| 200 | </section> |
| 201 | |
| 202 | <section className="min-h-0 overflow-auto p-3 sm:p-4 lg:p-5"> |
| 203 | {importError && ( |
| 204 | <div className="mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"> |
| 205 | {importError} |
| 206 | </div> |
| 207 | )} |
| 208 | <ChartDataEditor |
| 209 | key={`${chartType}-${importVersion}`} |
| 210 | data={localData} |
| 211 | chartType={chartType} |
| 212 | onDataChange={handleDataChange} |
| 213 | isComposedChart={isComposedChart} |
| 214 | seriesChartTypes={localSeriesChartTypes} |
| 215 | onSeriesChartTypesChange={handleSeriesChartTypesChange} |
| 216 | /> |
| 217 | </section> |
| 218 | </div> |
| 219 | |
| 220 | <CredenzaFooter className="shrink-0 border-t px-4 py-3 sm:px-5 sm:py-4"> |
| 221 | <Button variant="outline" onClick={handleCancel}> |
| 222 | Cancel |
| 223 | </Button> |
| 224 | <Button onClick={handleSave}>Save Changes</Button> |
| 225 | </CredenzaFooter> |
| 226 | </CredenzaContent> |
| 227 | </Credenza> |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | // Re-export types for backward compatibility |
| 232 | export type { |
| 233 | ChartDataMode, |
| 234 | ChartDataType, |
| 235 | SeriesChartType, |
| 236 | } from "./chart-data-editor"; |
| 237 |