LCOV - code coverage report
Current view: top level - dashql-app/src/session/chart_builder - chart_builder_overlay.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 95.0 % 140 133
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import type { TopLevelSpec } from 'vega-lite';
       3              : 
       4              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       5              : import { useFocusTrap } from '../../ui/foundations/focus.js';
       6              : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
       7              : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
       8              : import { AlertIcon, BracesIcon, ScreenFullIcon, ScreenNormalIcon, ToolsIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
       9              : import { checkVegaLiteSpec } from '../../ui/vegalite/vegalite_check.js';
      10              : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
      11              : import { VegaLiteView } from '../../views/visualization/vegalite_view.js';
      12              : import type { ResolvedVisualizeQuery } from '../scripts/script_types.js';
      13              : import { chartColumns } from './chart_columns.js';
      14              : import { ChartSpecBuilder } from './chart_spec_builder.js';
      15              : import { ChartSpecJsonEditor } from './chart_spec_json_editor.js';
      16              : import { encodedFields, inlineSharedEncoding, NEW_CHART_SPEC, type ChartSpec } from './chart_spec_paths.js';
      17              : import * as styles from './chart_builder.module.css';
      18              : 
      19            1 : const IGNORE_OUTSIDE_CLICK = () => {};
      20            1 : const VEGA_LITE_SCHEMA = 'https://vega.github.io/schema/vega-lite/v6.json';
      21              : /// Checking the spec compiles it, wait until the user pauses
      22            1 : const CHECK_DELAY_MS = 200;
      23              : 
      24            1 : const enum ChartBuilderMode {
      25            1 :     Builder = 0,
      26            1 :     Json = 1,
      27            1 : }
      28              : 
      29              : /// The spec that the user authored, without the `$schema` and the `data` that the compiler adds
      30            1 : export function authoredChartSpec(visualizeQuery: ResolvedVisualizeQuery | null): ChartSpec {
      31            1 :     if (visualizeQuery?.renderer !== 'vegalite') return { ...NEW_CHART_SPEC };
      32            1 :     const spec: ChartSpec = { ...(visualizeQuery.vegaLiteSpec as unknown as ChartSpec) };
      33            1 :     delete spec.$schema;
      34            1 :     delete spec.data;
      35            1 :     return spec;
      36            1 : }
      37              : 
      38              : export interface ChartBuilderOverlayProps {
      39              :     /// The display name of the script
      40              :     scriptName: string;
      41              :     /// The chart of the script when the overlay opens
      42              :     visualizeQuery: ResolvedVisualizeQuery | null;
      43              :     /// The latest query of the script, the preview shows its result
      44              :     queryState: QueryExecutionState | null;
      45              :     /// Stage the spec for the `@vis vegalite` tag of the script as a diff to accept or reject
      46              :     onChange: (spec: string) => void;
      47              :     onClose: () => void;
      48              :     dependencies?: ChartBuilderOverlayDependencies;
      49              : }
      50              : 
      51              : export interface ChartBuilderOverlayDependencies {
      52              :     VegaLiteView: typeof VegaLiteView;
      53              : }
      54              : 
      55            1 : const DEFAULT_DEPENDENCIES: ChartBuilderOverlayDependencies = { VegaLiteView };
      56              : 
      57              : /// Build the chart of a script: a builder or the JSON of the Vega-Lite spec on the left, the chart on the right.
      58              : /// Every edit updates the chart and the diff of the script, the user accepts or rejects the diff in the script.
      59            1 : export function ChartBuilderOverlay(props: ChartBuilderOverlayProps): React.ReactElement {
      60            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
      61            1 :     const dialogRef = React.useRef<HTMLElement | null>(null);
      62            1 :     const [expanded, setExpanded] = React.useState(false);
      63            1 :     const [mode, setMode] = React.useState<ChartBuilderMode>(ChartBuilderMode.Builder);
      64              :     // The overlay owns the spec while it is open, the diff of the script follows it
      65            1 :     const [spec, setSpec] = React.useState<ChartSpec>(() => authoredChartSpec(props.visualizeQuery));
      66            1 :     const [problems, setProblems] = React.useState<string[]>([]);
      67              : 
      68            1 :     const onChangeRef = React.useRef(props.onChange);
      69            1 :     onChangeRef.current = props.onChange;
      70            1 :     const updateSpec = React.useCallback((next: ChartSpec) => {
      71            1 :         setSpec(next);
      72            1 :         onChangeRef.current(JSON.stringify(next));
      73            1 :     }, []);
      74              : 
      75              :     // The builder edits the encoding of a layer only. An encoding that the layers share moves into the layers when
      76              :     // the builder shows the spec, the script shows the move as a diff like any other edit.
      77            1 :     React.useLayoutEffect(() => {
      78            1 :         if (mode !== ChartBuilderMode.Builder) return;
      79            1 :         const inlined = inlineSharedEncoding(spec);
      80            1 :         if (inlined != null) updateSpec(inlined);
      81            1 :     }, [mode, spec, updateSpec]);
      82              : 
      83              :     // Check the spec once the user pauses, the problems show in the header
      84            1 :     React.useEffect(() => {
      85            1 :         let cancelled = false;
      86            1 :         const timer = setTimeout(() => {
      87            0 :             checkVegaLiteSpec(spec)
      88            0 :                 .then(found => { if (!cancelled) setProblems(found); })
      89            0 :                 .catch(() => {});
      90            1 :         }, CHECK_DELAY_MS);
      91            1 :         return () => {
      92            1 :             cancelled = true;
      93            1 :             clearTimeout(timer);
      94            1 :         };
      95            1 :     }, [spec]);
      96              : 
      97            1 :     const query = props.queryState;
      98            1 :     const resultTable = query?.status === QueryExecutionStatus.SUCCEEDED ? query.resultTable : null;
      99            1 :     const columns = React.useMemo(() => chartColumns(resultTable?.schema ?? null, encodedFields(spec)), [resultTable, spec]);
     100            1 :     const previewSpec = React.useMemo(() => ({ $schema: VEGA_LITE_SCHEMA, ...spec }) as unknown as TopLevelSpec, [spec]);
     101              : 
     102              :     // Focus the dialog, not its first button
     103            1 :     useFocusTrap({
     104            1 :         containerRef: dialogRef as React.RefObject<HTMLElement>,
     105            1 :         initialFocusRef: dialogRef as React.RefObject<HTMLElement>,
     106            1 :         restoreFocusOnCleanUp: true,
     107            1 :     });
     108              : 
     109            1 :     return (
     110            1 :         <Overlay
     111            1 :             centered
     112            1 :             width={OverlaySize.XXL}
     113            1 :             height={OverlaySize.XL}
     114            1 :             maxHeight={OverlaySize.XL}
     115            1 :             preventFocusOnOpen
     116            1 :             onEscape={props.onClose}
     117            1 :             onClickOutside={IGNORE_OUTSIDE_CLICK}
     118            1 :             expanded={expanded}
     119              :         >
     120            1 :             <section
     121            1 :                 ref={dialogRef}
     122            1 :                 className={styles.dialog}
     123            1 :                 role="dialog"
     124            1 :                 aria-modal="true"
     125            1 :                 tabIndex={-1}
     126            1 :                 aria-label={`Visualize ${props.scriptName} as Chart`}
     127            1 :                 data-expanded={expanded || undefined}
     128            1 :                 data-testid="chart-builder-overlay"
     129              :             >
     130            1 :                 <section className={styles.editor_pane} aria-label="Chart spec">
     131            1 :                     <header className={styles.pane_header}>
     132            1 :                         <h2 className={styles.title}>Visualize as Chart</h2>
     133            1 :                         <div className={styles.spacer} />
     134            1 :                         <SegmentedControl
     135            1 :                             aria-label="Chart editor"
     136            1 :                             size={SegmentedControlSize.Small}
     137            1 :                             onChange={index => setMode(index as ChartBuilderMode)}
     138              :                         >
     139            1 :                             <SegmentedControl.IconButton
     140            1 :                                 icon={ToolsIcon}
     141            1 :                                 aria-label="Builder"
     142            1 :                                 selected={mode === ChartBuilderMode.Builder}
     143            1 :                                 data-testid="chart-mode-builder"
     144            1 :                             />
     145            1 :                             <SegmentedControl.IconButton
     146            1 :                                 icon={BracesIcon}
     147            1 :                                 aria-label="JSON"
     148            1 :                                 selected={mode === ChartBuilderMode.Json}
     149            1 :                                 data-testid="chart-mode-json"
     150            1 :                             />
     151            1 :                         </SegmentedControl>
     152            1 :                     </header>
     153            1 :                     <div className={styles.editor_content}>
     154            1 :                         {mode === ChartBuilderMode.Builder ? (
     155            1 :                             <ChartSpecBuilder
     156            1 :                                 spec={spec}
     157            1 :                                 columns={columns}
     158            1 :                                 onChange={updateSpec}
     159            1 :                                 onEditJson={() => setMode(ChartBuilderMode.Json)}
     160            1 :                             />
     161              :                         ) : (
     162            1 :                             <ChartSpecJsonEditor initialSpec={spec} onChange={updateSpec} />
     163              :                         )}
     164            1 :                     </div>
     165            1 :                 </section>
     166            1 :                 <section className={styles.preview_pane} aria-label="Chart preview">
     167            1 :                     <header className={styles.pane_header}>
     168            1 :                         {problems.length > 0 && (
     169            0 :                             <div className={styles.diagnostic} role="status" title={problems.join('\n')} data-testid="chart-problems">
     170            0 :                                 <AlertIcon size={16} aria-hidden="true" />
     171            0 :                                 <span className={styles.diagnostic_text}>{problems[0]}</span>
     172            0 :                             </div>
     173              :                         )}
     174            1 :                         <div className={styles.spacer} />
     175            1 :                         <IconButton
     176            1 :                             variant={ButtonVariant.Invisible}
     177            1 :                             size={ButtonSize.Small}
     178            1 :                             aria-label={expanded ? 'Restore size' : 'Expand'}
     179            1 :                             aria-pressed={expanded}
     180            1 :                             onClick={() => setExpanded(e => !e)}
     181              :                         >
     182            1 :                             {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
     183            1 :                         </IconButton>
     184            1 :                         <IconButton
     185            1 :                             variant={ButtonVariant.Invisible}
     186            1 :                             size={ButtonSize.Small}
     187            1 :                             aria-label="Close chart builder"
     188            1 :                             data-testid="chart-builder-close"
     189            1 :                             onClick={props.onClose}
     190              :                         >
     191            1 :                             <XIcon size={16} />
     192            1 :                         </IconButton>
     193            1 :                     </header>
     194            1 :                     <div className={styles.preview_content} data-testid="chart-preview">
     195            1 :                         {resultTable != null ? (
     196            1 :                             <dependencies.VegaLiteView query={query} vegaLiteSpec={previewSpec} />
     197              :                         ) : (
     198            1 :                             <p className={styles.preview_empty}>Run the query to preview the chart.</p>
     199              :                         )}
     200            1 :                     </div>
     201            1 :                 </section>
     202            1 :             </section>
     203            1 :         </Overlay>
     204              :     );
     205            1 : }
        

Generated by: LCOV version 2.0-1