|
1 | 1 | // Copyright (c) Microsoft Corporation. |
2 | 2 | // Licensed under the MIT License. |
3 | 3 |
|
4 | | -import React, { FC, useState, useEffect, useRef } from 'react'; |
5 | | -import { |
6 | | - Dialog, |
7 | | - DialogTitle, |
8 | | - DialogContent, |
9 | | - DialogActions, |
10 | | - Button, |
11 | | - Typography, |
12 | | - TextField, |
13 | | - Box, |
14 | | -} from '@mui/material'; |
15 | 4 | import { Chart, DictTable, FieldItem } from '../components/ComponentType'; |
16 | 5 | import { assembleVegaChart, prepVisTable, exportTableToDsv } from '../app/utils'; |
17 | 6 | import { ClientConfig } from '../app/dfSlice'; |
18 | 7 |
|
19 | | -// Chartifact library type declarations |
20 | | -interface SpecReview<T> { |
21 | | - pluginName: string; |
22 | | - containerId: string; |
23 | | - approvedSpec: T; |
24 | | - blockedSpec?: T; |
25 | | - reason?: string; |
26 | | -} |
27 | | - |
28 | | -interface SandboxedPreHydrateMessage { |
29 | | - type: 'sandboxedPreHydrate'; |
30 | | - transactionId: number; |
31 | | - specs: SpecReview<{}>[]; |
32 | | -} |
33 | | - |
34 | | -interface SandboxOptions { |
35 | | - onReady?: () => void; |
36 | | - onError?: (error: Error) => void; |
37 | | - onApprove: (message: SandboxedPreHydrateMessage) => SpecReview<{}>[]; |
38 | | -} |
39 | | - |
40 | | -interface ChartifactSandbox { |
41 | | - options: SandboxOptions; |
42 | | - element: HTMLElement; |
43 | | - iframe: HTMLIFrameElement; |
44 | | - destroy(): void; |
45 | | - send(markdown: string): void; |
46 | | -} |
47 | | - |
48 | | -interface ChartifactHtmlWrapper { |
49 | | - htmlMarkdownWrapper: (title: string, markdown: string) => string; |
50 | | - htmlJsonWrapper: (title: string, json: string) => string; |
51 | | -} |
52 | | - |
53 | | -const chartifactScripts = [ |
54 | | - 'https://microsoft.github.io/chartifact/dist/v1/chartifact.sandbox.umd.js', |
55 | | - 'https://microsoft.github.io/chartifact/dist/v1/chartifact.html-wrapper.umd.js' |
56 | | -]; |
57 | | - |
58 | | -// Type declarations for Chartifact global |
59 | | -declare global { |
60 | | - interface Window { |
61 | | - Chartifact?: { |
62 | | - sandbox: { |
63 | | - Sandbox: new ( |
64 | | - elementOrSelector: string | HTMLElement, |
65 | | - markdown: string, |
66 | | - options: SandboxOptions |
67 | | - ) => ChartifactSandbox; |
68 | | - }; |
69 | | - htmlWrapper: ChartifactHtmlWrapper; |
70 | | - }; |
71 | | - } |
72 | | -} |
73 | | - |
74 | | -interface ChartifactDialogProps { |
75 | | - open: boolean; |
76 | | - onClose: () => void; |
77 | | - reportContent: string; |
78 | | - reportStyle: string; |
79 | | - charts: Chart[]; |
80 | | - tables: DictTable[]; |
81 | | - conceptShelfItems: FieldItem[]; |
82 | | - config: ClientConfig; |
83 | | -} |
84 | | - |
85 | | -export const ChartifactDialog: FC<ChartifactDialogProps> = ({ |
86 | | - open, |
87 | | - onClose, |
88 | | - reportContent, |
89 | | - reportStyle, |
90 | | - charts, |
91 | | - tables, |
92 | | - conceptShelfItems, |
93 | | - config |
94 | | -}) => { |
95 | | - const [source, setSource] = useState(''); |
96 | | - const [isConverting, setIsConverting] = useState(false); |
97 | | - const [chartifactLoaded, setChartifactLoaded] = useState(false); |
98 | | - const [sandboxReady, setSandboxReady] = useState(false); |
99 | | - const [parentElement, setParentElement] = useState<HTMLDivElement | null>(null); |
100 | | - const sandboxRef = useRef<ChartifactSandbox | null>(null); |
101 | | - |
102 | | - // Load Chartifact scripts |
103 | | - const loadChartifactScripts = async (): Promise<void> => { |
104 | | - // Check if Chartifact is already loaded |
105 | | - if (window.Chartifact?.sandbox && window.Chartifact?.htmlWrapper) { |
106 | | - setChartifactLoaded(true); |
107 | | - return; |
108 | | - } |
109 | | - |
110 | | - try { |
111 | | - for (const src of chartifactScripts) { |
112 | | - await new Promise<void>((resolve, reject) => { |
113 | | - const script = document.createElement('script'); |
114 | | - script.src = src; |
115 | | - script.onload = () => resolve(); |
116 | | - script.onerror = () => reject(new Error(`Failed to load ${src}`)); |
117 | | - document.head.appendChild(script); |
118 | | - }); |
119 | | - } |
120 | | - |
121 | | - // Verify that Chartifact was loaded correctly |
122 | | - if (window.Chartifact?.sandbox && window.Chartifact?.htmlWrapper) { |
123 | | - setChartifactLoaded(true); |
124 | | - } else { |
125 | | - throw new Error('Chartifact namespace not found after loading scripts'); |
126 | | - } |
127 | | - } catch (error) { |
128 | | - console.error('Error loading Chartifact scripts:', error); |
129 | | - throw error; |
130 | | - } |
131 | | - }; |
132 | | - |
133 | | - // Initialize Chartifact sandbox |
134 | | - const initializeSandbox = () => { |
135 | | - if (!chartifactLoaded || !parentElement || !source) { |
136 | | - return; |
137 | | - } |
138 | | - |
139 | | - try { |
140 | | - sandboxRef.current = new window.Chartifact!.sandbox.Sandbox(parentElement, source, { |
141 | | - onReady: () => { |
142 | | - setSandboxReady(true); |
143 | | - }, |
144 | | - onError: (error: any) => { |
145 | | - console.error('Sandbox error:', error); |
146 | | - }, |
147 | | - onApprove: (message: any) => { |
148 | | - //TODO policy to approve unapproved on localhost |
149 | | - const { specs } = message; |
150 | | - return specs; |
151 | | - }, |
152 | | - }); |
153 | | - } catch (error) { |
154 | | - console.error('Error initializing Chartifact sandbox:', error); |
155 | | - } |
156 | | - }; |
157 | | - |
158 | | - // Check if sandbox is functional |
159 | | - const isSandboxFunctional = (): boolean => { |
160 | | - if (!sandboxRef.current || !sandboxRef.current.iframe) { |
161 | | - return false; |
162 | | - } |
163 | | - |
164 | | - const iframe = sandboxRef.current.iframe; |
165 | | - const contentWindow = iframe.contentWindow; |
166 | | - |
167 | | - // Only recreate if we have clear evidence of a broken iframe |
168 | | - // Missing contentWindow is a clear sign of tombstoning |
169 | | - if (!contentWindow) { |
170 | | - return false; |
171 | | - } |
172 | | - |
173 | | - // Missing or invalid src indicates a problem |
174 | | - if (!iframe.src || iframe.src === 'about:blank') { |
175 | | - return false; |
176 | | - } |
177 | | - |
178 | | - // For normal cases (including blob URLs), assume functional to preserve user state |
179 | | - // Only the clear failures above will trigger recreation |
180 | | - return true; |
181 | | - }; // Load scripts when dialog opens |
182 | | - useEffect(() => { |
183 | | - if (open && !chartifactLoaded) { |
184 | | - loadChartifactScripts(); |
185 | | - } |
186 | | - }, [open, chartifactLoaded]); |
187 | | - |
188 | | - // Initialize sandbox when dialog opens with all requirements ready |
189 | | - useEffect(() => { |
190 | | - if (open && chartifactLoaded && source && parentElement) { |
191 | | - if (!isSandboxFunctional() || !sandboxReady) { |
192 | | - // Destroy existing sandbox before creating new one |
193 | | - if (sandboxRef.current) { |
194 | | - if (sandboxRef.current.destroy) { |
195 | | - sandboxRef.current.destroy(); |
196 | | - } |
197 | | - sandboxRef.current = null; |
198 | | - setSandboxReady(false); |
199 | | - } |
200 | | - initializeSandbox(); |
201 | | - } else if (sandboxRef.current) { |
202 | | - sandboxRef.current.send(source); |
203 | | - } |
204 | | - } |
205 | | - |
206 | | - // Cleanup function runs when dialog closes or component unmounts |
207 | | - return () => { |
208 | | - if (!open && sandboxRef.current) { |
209 | | - if (sandboxRef.current.destroy) { |
210 | | - sandboxRef.current.destroy(); |
211 | | - } |
212 | | - sandboxRef.current = null; |
213 | | - setSandboxReady(false); |
214 | | - } |
215 | | - }; |
216 | | - }, [open, chartifactLoaded, source, parentElement, charts, tables, conceptShelfItems, config]); |
217 | | - |
218 | | - // Convert report content when dialog opens |
219 | | - useEffect(() => { |
220 | | - if (open && reportContent) { |
221 | | - setIsConverting(true); |
222 | | - convertToChartifact(reportContent, reportStyle, charts, tables, conceptShelfItems, config) |
223 | | - .then(chartifactMarkdown => { |
224 | | - setSource(chartifactMarkdown); |
225 | | - setIsConverting(false); |
226 | | - }) |
227 | | - .catch(error => { |
228 | | - console.error('Error converting to Chartifact:', error); |
229 | | - setSource('Error converting report to Chartifact format'); |
230 | | - setIsConverting(false); |
231 | | - }); |
232 | | - } |
233 | | - }, [open, reportContent]); |
234 | | - |
235 | | - return ( |
236 | | - <Dialog |
237 | | - open={open} |
238 | | - onClose={onClose} |
239 | | - maxWidth="xl" |
240 | | - fullWidth |
241 | | - PaperProps={{ |
242 | | - sx: { |
243 | | - minHeight: '90vh', |
244 | | - maxHeight: '90vh', |
245 | | - } |
246 | | - }} |
247 | | - > |
248 | | - <DialogTitle> |
249 | | - <Typography variant="h5" component="div"> |
250 | | - Chartifact Report |
251 | | - </Typography> |
252 | | - </DialogTitle> |
253 | | - <DialogContent dividers sx={{ display: 'flex', flexDirection: 'row', gap: 2, p: 2, overflow: 'hidden' }}> |
254 | | - <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minHeight: 0 }}> |
255 | | - <Typography variant="body2" color="text.secondary"> |
256 | | - Source |
257 | | - </Typography> |
258 | | - <TextField |
259 | | - multiline |
260 | | - fullWidth |
261 | | - value={source} |
262 | | - onChange={(e) => setSource(e.target.value)} |
263 | | - placeholder={isConverting ? "Converting report to Chartifact format..." : "Enter the report source here..."} |
264 | | - variant="outlined" |
265 | | - disabled={isConverting} |
266 | | - sx={{ |
267 | | - flex: 1, |
268 | | - minHeight: 0, |
269 | | - display: 'flex', |
270 | | - flexDirection: 'column', |
271 | | - '& .MuiInputBase-root': { |
272 | | - height: '100%', |
273 | | - alignItems: 'flex-start', |
274 | | - overflow: 'hidden', |
275 | | - }, |
276 | | - '& .MuiInputBase-input': { |
277 | | - fontFamily: 'monospace', |
278 | | - fontSize: '0.875rem', |
279 | | - overflow: 'auto !important', |
280 | | - height: '100% !important', |
281 | | - } |
282 | | - }} |
283 | | - /> |
284 | | - </Box> |
285 | | - <Box |
286 | | - sx={{ |
287 | | - flex: 1, |
288 | | - display: 'flex', |
289 | | - flexDirection: 'column', |
290 | | - gap: 1, |
291 | | - minHeight: 0 |
292 | | - }} |
293 | | - > |
294 | | - <Typography variant="body2" color="text.secondary"> |
295 | | - Preview |
296 | | - </Typography> |
297 | | - <Box |
298 | | - ref={setParentElement} |
299 | | - sx={{ |
300 | | - flex: 1, |
301 | | - minHeight: 0, |
302 | | - border: '1px solid', |
303 | | - borderColor: 'divider', |
304 | | - borderRadius: 1, |
305 | | - overflow: 'auto', |
306 | | - position: 'relative', |
307 | | - '& > iframe': { |
308 | | - position: 'absolute', |
309 | | - top: 0, |
310 | | - left: 0, |
311 | | - width: '100%', |
312 | | - height: '100%', |
313 | | - border: 'none', |
314 | | - } |
315 | | - }} |
316 | | - /> |
317 | | - </Box> |
318 | | - </DialogContent> |
319 | | - <DialogActions sx={{ justifyContent: 'space-between', px: 3, py: 2 }}> |
320 | | - <Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.75rem' }}> |
321 | | - <a href="https://microsoft.github.io/chartifact/" target="_blank" rel="noopener noreferrer"> |
322 | | - Learn more about Chartifact |
323 | | - </a> |
324 | | - </Typography> |
325 | | - <Box sx={{ display: 'flex', gap: 1 }}> |
326 | | - <Button |
327 | | - onClick={() => { |
328 | | - const blob = new Blob([source], { type: 'text/markdown' }); |
329 | | - const url = URL.createObjectURL(blob); |
330 | | - const a = document.createElement('a'); |
331 | | - a.href = url; |
332 | | - a.download = 'chartifact-report.idoc.md'; |
333 | | - a.click(); |
334 | | - URL.revokeObjectURL(url); |
335 | | - }} |
336 | | - disabled={!source} |
337 | | - > |
338 | | - Download Markdown |
339 | | - </Button> |
340 | | - <Button |
341 | | - onClick={() => { |
342 | | - if (window.Chartifact?.htmlWrapper) { |
343 | | - const html = window.Chartifact.htmlWrapper.htmlMarkdownWrapper('Chartifact Report', source); |
344 | | - const blob = new Blob([html], { type: 'text/html' }); |
345 | | - const url = URL.createObjectURL(blob); |
346 | | - const a = document.createElement('a'); |
347 | | - a.href = url; |
348 | | - a.download = 'chartifact-report.html'; |
349 | | - a.click(); |
350 | | - URL.revokeObjectURL(url); |
351 | | - } |
352 | | - }} |
353 | | - disabled={!source || !chartifactLoaded} |
354 | | - > |
355 | | - Download HTML |
356 | | - </Button> |
357 | | - <Button onClick={onClose} color="primary"> |
358 | | - Close |
359 | | - </Button> |
360 | | - </Box> |
361 | | - </DialogActions> |
362 | | - </Dialog> |
363 | | - ); |
364 | | -}; |
365 | | - |
366 | 8 | // Function to generate CSS styling based on report type |
367 | 9 | const generateStyleCSS = (style: string): string => { |
368 | 10 | // Font families |
|
0 commit comments