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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import {
       3              :     DndContext,
       4              :     DragOverlay,
       5              :     KeyboardSensor,
       6              :     PointerSensor,
       7              :     useDraggable,
       8              :     useDroppable,
       9              :     useSensor,
      10              :     useSensors,
      11              :     type DragEndEvent,
      12              :     type DragStartEvent,
      13              : } from '@dnd-kit/core';
      14              : 
      15              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
      16              : import { ChevronDownIcon, ChevronUpIcon, PlusIcon, SymbolIcon, XIcon, type Icon } from '../../ui/foundations/symbol_icon.js';
      17              : import { TextInput } from '../../ui/foundations/text_input.js';
      18              : import { classNames } from '../../utils/classnames.js';
      19              : import type { ChartColumn } from './chart_columns.js';
      20              : import { ChartSelect, type ChartSelectOption } from './chart_select.js';
      21              : import {
      22              :     AGGREGATES,
      23              :     CHANNELS,
      24              :     DEFAULT_CHANNELS,
      25              :     FIELD_TYPES,
      26              :     FIELD_TYPE_SHORT,
      27              :     PRIMARY_MARKS,
      28              :     SECONDARY_MARKS,
      29              :     SORTS,
      30              :     TIME_UNITS,
      31              :     addColumnLayer,
      32              :     addLayer,
      33              :     channelSummary,
      34              :     clearChannelField,
      35              :     encodedChannels,
      36              :     getChannel,
      37              :     getLayer,
      38              :     getMarkType,
      39              :     getTimeUnit,
      40              :     isBuilderEditable,
      41              :     isCountChannel,
      42              :     isCustomChannel,
      43              :     layerCount,
      44              :     newLayerView,
      45              :     patchChannel,
      46              :     removeChannel,
      47              :     removeLayer,
      48              :     setChannelCount,
      49              :     setChannelField,
      50              :     setChannelType,
      51              :     setLayer,
      52              :     setMarkType,
      53              :     setTopLevel,
      54              :     withTimeUnit,
      55              :     type ChartSpec,
      56              :     type FieldType,
      57              : } from './chart_spec_paths.js';
      58              : import * as styles from './chart_builder.module.css';
      59              : 
      60            1 : const MoreIcon: Icon = SymbolIcon('kebab_horizontal');
      61            1 : const TrashIcon: Icon = SymbolIcon('trash_16');
      62              : 
      63              : /// The value of the count option of a field dropdown, no column can have this name
      64            1 : const COUNT_OPTION = '\u001fcount';
      65            1 : const NONE_OPTION = '\u001fnone';
      66              : 
      67              : /// The marks with a monochrome example icon each
      68            1 : const MARKS: { mark: string; icon: Icon }[] = [...PRIMARY_MARKS, ...SECONDARY_MARKS].map(mark => ({ mark, icon: SymbolIcon(`mark_${mark}_16`) }));
      69            1 : const MARK_ICONS = new Map(MARKS.map(({ mark, icon }) => [mark, icon]));
      70            1 : const TYPE_OPTIONS: ChartSelectOption[] = FIELD_TYPES.map(type => ({ value: type, label: FIELD_TYPE_SHORT[type], description: type }));
      71            1 : const AGGREGATE_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...AGGREGATES.map(a => ({ value: a, label: a }))];
      72            1 : const TIME_UNIT_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...TIME_UNITS.map(({ unit, description }) => ({ value: unit, label: unit, description }))];
      73            1 : const SORT_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...SORTS.map(s => ({ value: s, label: s }))];
      74            1 : const STACK_OPTIONS: ChartSelectOption[] = [
      75            1 :     { value: NONE_OPTION, label: 'default' },
      76            1 :     { value: 'zero', label: 'zero' },
      77            1 :     { value: 'normalize', label: 'normalize' },
      78            1 :     { value: 'center', label: 'center' },
      79            1 :     { value: 'null', label: 'off' },
      80            1 : ];
      81              : 
      82              : /// The prefixes of the drag and drop ids
      83            1 : const COLUMN_DRAG_PREFIX = 'column:';
      84            1 : const CHANNEL_DROP_PREFIX = 'channel:';
      85            1 : const LAYER_DROP_ID = 'layer:new';
      86              : 
      87              : interface Props {
      88              :     spec: ChartSpec;
      89              :     columns: readonly ChartColumn[];
      90              :     onChange: (spec: ChartSpec) => void;
      91              :     /// Switch to the JSON, for specs that the builder cannot edit
      92              :     onEditJson: () => void;
      93              : }
      94              : 
      95              : /// The visual editor of a Vega-Lite spec: the view, the columns of the result, and the layers of the chart with a mark
      96              : /// and a row per encoding channel each. The columns can be dragged onto a channel, or picked in the dropdown of the
      97              : /// channel. Dropping a column onto the layers adds a layer that shows the column.
      98            1 : export function ChartSpecBuilder(props: Props): React.ReactElement {
      99            1 :     const { spec, onChange } = props;
     100              :     // The layer that the mark and the encoding edit, a single view is one layer
     101            1 :     const [layerIndex, setLayerIndex] = React.useState(0);
     102            1 :     const layers = layerCount(spec);
     103            1 :     const selectedLayer = Math.min(layerIndex, layers - 1);
     104            1 :     const view = getLayer(spec, selectedLayer);
     105            1 :     const onViewChange = React.useCallback(
     106            1 :         (next: ChartSpec) => onChange(setLayer(spec, selectedLayer, next)),
     107            1 :         [spec, selectedLayer, onChange],
     108            1 :     );
     109              :     // Channels that the user added to the selected layer and that have no field yet
     110            1 :     const [addedChannels, setAddedChannels] = React.useState<string[]>([]);
     111            1 :     const selectLayer = React.useCallback((index: number) => {
     112            1 :         setLayerIndex(index);
     113            1 :         setAddedChannels([]);
     114            1 :     }, []);
     115            1 :     const appendLayer = React.useCallback((next: ChartSpec) => {
     116            1 :         onChange(next);
     117            1 :         selectLayer(layerCount(next) - 1);
     118            1 :     }, [onChange, selectLayer]);
     119            1 :     const [dragColumn, setDragColumn] = React.useState<ChartColumn | null>(null);
     120            1 :     const sensors = useSensors(
     121            1 :         useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
     122            1 :         useSensor(KeyboardSensor),
     123            1 :     );
     124              : 
     125            1 :     const visibleChannels = React.useMemo(() => {
     126            1 :         const visible = new Set<string>([...DEFAULT_CHANNELS, ...encodedChannels(view), ...addedChannels]);
     127            1 :         const known: string[] = CHANNELS.filter(channel => visible.has(channel));
     128              :         // Channels that the builder does not list, e.g. `fill`, are shown after the known ones
     129            1 :         const unknown = [...visible].filter(channel => !(CHANNELS as readonly string[]).includes(channel));
     130            1 :         return [...known, ...unknown];
     131            1 :     }, [view, addedChannels]);
     132            1 :     const addableChannels = React.useMemo<ChartSelectOption[]>(
     133            1 :         () => CHANNELS.filter(channel => !visibleChannels.includes(channel)).map(channel => ({ value: channel, label: channel })),
     134            1 :         [visibleChannels],
     135            1 :     );
     136              : 
     137            1 :     const onDragStart = React.useCallback((event: DragStartEvent) => {
     138            0 :         setDragColumn((event.active.data.current?.column as ChartColumn | undefined) ?? null);
     139            1 :     }, []);
     140            1 :     const onDragEnd = React.useCallback((event: DragEndEvent) => {
     141            0 :         setDragColumn(null);
     142            0 :         const column = event.active.data.current?.column as ChartColumn | undefined;
     143            0 :         const target = event.over?.id;
     144            0 :         if (column == null || typeof target !== 'string') return;
     145            0 :         if (target === LAYER_DROP_ID) {
     146            0 :             appendLayer(addColumnLayer(spec, selectedLayer, { field: column.name, type: column.type }));
     147            0 :         } else if (target.startsWith(CHANNEL_DROP_PREFIX)) {
     148            0 :             onViewChange(setChannelField(view, target.slice(CHANNEL_DROP_PREFIX.length), column.name, column.type));
     149              :         }
     150            1 :     }, [spec, view, selectedLayer, appendLayer, onViewChange]);
     151              : 
     152            1 :     if (!isBuilderEditable(spec)) {
     153            1 :         return (
     154            1 :             <div className={styles.builder_unsupported} data-testid="chart-builder-unsupported">
     155            1 :                 <p>This spec composes several views. The builder edits single views and their layers only.</p>
     156            1 :                 <Button size={ButtonSize.Small} onClick={props.onEditJson}>Edit the JSON</Button>
     157            1 :             </div>
     158              :         );
     159              :     }
     160              : 
     161            1 :     const markType = getMarkType(view);
     162            1 :     return (
     163            1 :         <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragCancel={() => setDragColumn(null)}>
     164            1 :             <div className={styles.builder} data-testid="chart-builder">
     165              :                 {/* The view is shared by the layers */}
     166            1 :                 <ViewSection spec={spec} onChange={onChange} />
     167            1 :                 <section className={styles.builder_section} aria-labelledby="chart-builder-columns">
     168            1 :                     <h3 id="chart-builder-columns" className={styles.builder_heading}>Columns</h3>
     169            1 :                     {props.columns.length === 0 ? (
     170            1 :                         <p className={styles.builder_hint}>Run the query to list its columns.</p>
     171              :                     ) : (
     172            1 :                         <ul className={styles.column_list} aria-label="Result columns">
     173            1 :                             {props.columns.map(column => <DraggableColumn key={column.name} column={column} />)}
     174            1 :                         </ul>
     175              :                     )}
     176            1 :                 </section>
     177            1 :                 <LayerBar
     178            1 :                     spec={spec}
     179            1 :                     selected={selectedLayer}
     180            1 :                     onSelect={selectLayer}
     181            1 :                     onAdd={() => appendLayer(addLayer(spec, newLayerView(view)))}
     182            1 :                     onRemove={() => {
     183            1 :                         onChange(removeLayer(spec, selectedLayer));
     184            1 :                         selectLayer(Math.max(0, selectedLayer - 1));
     185            1 :                     }}
     186            1 :                 />
     187            1 :                 <div className={styles.layer_panel} data-testid="chart-layer-panel">
     188            1 :                     <section className={styles.builder_section} aria-labelledby="chart-builder-mark">
     189            1 :                         <h3 id="chart-builder-mark" className={styles.builder_heading}>Mark</h3>
     190            1 :                         <div className={styles.mark_list} role="group" aria-labelledby="chart-builder-mark" data-testid="chart-mark">
     191            1 :                             {MARKS.map(({ mark, icon: MarkIcon }) => (
     192            1 :                                 <IconButton
     193            1 :                                     key={mark}
     194            1 :                                     className={styles.mark_button}
     195            1 :                                     variant={mark === markType ? ButtonVariant.Default : ButtonVariant.Invisible}
     196            1 :                                     size={ButtonSize.Small}
     197            1 :                                     aria-label={mark}
     198            1 :                                     aria-pressed={mark === markType}
     199            1 :                                     data-testid={`chart-mark-${mark}`}
     200            1 :                                     onClick={() => { if (mark !== markType) onViewChange(setMarkType(view, mark)); }}
     201              :                                 >
     202            1 :                                     <MarkIcon size={16} />
     203            1 :                                 </IconButton>
     204            1 :                             ))}
     205            1 :                         </div>
     206            1 :                     </section>
     207            1 :                     <section className={styles.builder_section} aria-labelledby="chart-builder-encoding">
     208            1 :                         <div className={styles.builder_heading_row}>
     209            1 :                             <h3 id="chart-builder-encoding" className={styles.builder_heading}>Encoding</h3>
     210            1 :                             {addableChannels.length > 0 && (
     211            1 :                                 <ChartSelect
     212            1 :                                     aria-label="Add channel"
     213            1 :                                     data-testid="chart-add-channel"
     214            1 :                                     variant={ButtonVariant.Invisible}
     215            1 :                                     value={null}
     216            1 :                                     placeholder="Add channel"
     217            1 :                                     options={addableChannels}
     218            1 :                                     onChange={channel => setAddedChannels(current => [...current, channel])}
     219            1 :                                 />
     220              :                             )}
     221            1 :                         </div>
     222            1 :                         <div className={styles.channel_list}>
     223            1 :                             {visibleChannels.map(channel => (
     224            1 :                                 <ChannelRow
     225            1 :                                     key={`${selectedLayer}:${channel}`}
     226            1 :                                     channel={channel}
     227            1 :                                     spec={view}
     228            1 :                                     columns={props.columns}
     229            1 :                                     onChange={onViewChange}
     230            1 :                                     onRemove={() => {
     231            0 :                                         setAddedChannels(current => current.filter(c => c !== channel));
     232            0 :                                         onViewChange(removeChannel(view, channel));
     233            0 :                                     }}
     234            1 :                                 />
     235            1 :                             ))}
     236            1 :                         </div>
     237            1 :                     </section>
     238            1 :                 </div>
     239            1 :             </div>
     240            1 :             <DragOverlay dropAnimation={null}>
     241            1 :                 {dragColumn != null ? <ColumnChip column={dragColumn} dragging /> : null}
     242            1 :             </DragOverlay>
     243            1 :         </DndContext>
     244              :     );
     245            1 : }
     246              : 
     247              : interface LayerBarProps {
     248              :     spec: ChartSpec;
     249              :     selected: number;
     250              :     onSelect: (index: number) => void;
     251              :     onAdd: () => void;
     252              :     onRemove: () => void;
     253              : }
     254              : 
     255              : /// The layers of the chart, one button per layer and a target that adds a layer for a dropped column
     256            1 : function LayerBar(props: LayerBarProps) {
     257            1 :     const count = layerCount(props.spec);
     258            1 :     return (
     259            1 :         <section className={styles.builder_section} aria-labelledby="chart-builder-layers">
     260            1 :             <h3 id="chart-builder-layers" className={styles.builder_heading}>Layers</h3>
     261            1 :             <div className={styles.layer_bar}>
     262            1 :                 <div className={styles.layer_list} role="group" aria-labelledby="chart-builder-layers" data-testid="chart-layers">
     263            1 :                     {Array.from({ length: count }, (_, index) => {
     264            1 :                         const mark = getMarkType(getLayer(props.spec, index));
     265            1 :                         const MarkIcon = mark != null ? MARK_ICONS.get(mark) : undefined;
     266            1 :                         return (
     267            1 :                             <button
     268            1 :                                 key={index}
     269            1 :                                 type="button"
     270            1 :                                 className={styles.layer_button}
     271            1 :                                 aria-pressed={index === props.selected}
     272            1 :                                 aria-label={`Layer ${index + 1}: ${mark ?? 'no mark'}`}
     273            1 :                                 data-testid={`chart-layer-${index}`}
     274            1 :                                 onClick={() => props.onSelect(index)}
     275              :                             >
     276            1 :                                 {MarkIcon != null && <MarkIcon size={16} aria-hidden="true" />}
     277            1 :                                 <span className={styles.layer_name}>{mark ?? 'layer'}</span>
     278            1 :                             </button>
     279              :                         );
     280            1 :                     })}
     281            1 :                     <AddLayerTarget onAdd={props.onAdd} />
     282            1 :                 </div>
     283            1 :                 {count > 1 && (
     284            1 :                     <IconButton
     285            1 :                         variant={ButtonVariant.Invisible}
     286            1 :                         size={ButtonSize.Small}
     287            1 :                         aria-label={`Remove layer ${props.selected + 1}`}
     288            1 :                         data-testid="chart-layer-remove"
     289            1 :                         onClick={props.onRemove}
     290              :                     >
     291            1 :                         <TrashIcon size={16} />
     292            1 :                     </IconButton>
     293              :                 )}
     294            1 :             </div>
     295            1 :         </section>
     296              :     );
     297            1 : }
     298              : 
     299              : /// Add a layer on click, or a layer that shows a column dropped onto it
     300            1 : function AddLayerTarget(props: { onAdd: () => void }) {
     301            1 :     const { setNodeRef, isOver, active } = useDroppable({ id: LAYER_DROP_ID });
     302            1 :     return (
     303            1 :         <button
     304            1 :             ref={setNodeRef}
     305            1 :             type="button"
     306            1 :             className={styles.layer_add}
     307            1 :             data-dragging={active != null || undefined}
     308            1 :             data-over={isOver || undefined}
     309            1 :             aria-label="Add a layer"
     310            1 :             title="Add a layer, or drop a column to show it in a new layer"
     311            1 :             data-testid="chart-layer-add"
     312            1 :             onClick={props.onAdd}
     313              :         >
     314            1 :             <PlusIcon size={16} aria-hidden="true" />
     315            1 :             {active != null && <span>Drop to add a layer</span>}
     316            1 :         </button>
     317              :     );
     318            1 : }
     319              : 
     320            1 : function ColumnChip(props: { column: ChartColumn; dragging?: boolean }) {
     321            1 :     return (
     322            1 :         <span
     323            1 :             className={classNames(styles.column_chip, { [styles.column_chip_dragging]: props.dragging, [styles.column_chip_missing]: props.column.missing })}
     324            1 :             title={props.column.missing ? `${props.column.name} is no column of the result` : props.column.name}
     325              :         >
     326            1 :             <span className={styles.type_badge} data-type={props.column.type}>{FIELD_TYPE_SHORT[props.column.type]}</span>
     327            1 :             <span className={styles.column_name}>{props.column.name}</span>
     328            1 :         </span>
     329              :     );
     330            1 : }
     331              : 
     332            1 : function DraggableColumn(props: { column: ChartColumn }) {
     333            1 :     const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
     334            1 :         id: `${COLUMN_DRAG_PREFIX}${props.column.name}`,
     335            1 :         data: { column: props.column },
     336            1 :     });
     337            1 :     return (
     338            1 :         <li
     339            1 :             ref={setNodeRef}
     340            1 :             className={classNames(styles.column_item, { [styles.column_item_source]: isDragging })}
     341            1 :             data-testid="chart-column"
     342            1 :             {...attributes}
     343            1 :             {...listeners}
     344            1 :             aria-label={`Drag ${props.column.name} onto a channel`}
     345              :         >
     346            1 :             <ColumnChip column={props.column} />
     347            1 :         </li>
     348              :     );
     349            1 : }
     350              : 
     351              : interface ChannelRowProps {
     352              :     channel: string;
     353              :     spec: ChartSpec;
     354              :     columns: readonly ChartColumn[];
     355              :     onChange: (spec: ChartSpec) => void;
     356              :     onRemove: () => void;
     357              : }
     358              : 
     359            1 : function ChannelRow(props: ChannelRowProps) {
     360            1 :     const { channel, spec, onChange } = props;
     361            1 :     const [expanded, setExpanded] = React.useState(false);
     362            1 :     const { setNodeRef, isOver, active } = useDroppable({ id: `${CHANNEL_DROP_PREFIX}${channel}` });
     363            1 :     const def = getChannel(spec, channel);
     364            1 :     const custom = isCustomChannel(def);
     365            1 :     const count = isCountChannel(def);
     366            1 :     const fieldDef = def != null && !custom ? def as Record<string, unknown> : null;
     367            1 :     const field = typeof fieldDef?.field === 'string' ? fieldDef.field : null;
     368            1 :     const type = typeof fieldDef?.type === 'string' && (FIELD_TYPES as readonly string[]).includes(fieldDef.type) ? fieldDef.type as FieldType : null;
     369            1 :     const encoded = field != null || count;
     370              : 
     371            1 :     const fieldOptions = React.useMemo<ChartSelectOption[]>(() => {
     372            1 :         const options: ChartSelectOption[] = props.columns.map(column => ({
     373            1 :             value: column.name,
     374            1 :             label: column.name,
     375            1 :             description: column.missing ? 'not in the result' : column.type,
     376            1 :         }));
     377            1 :         if (field != null && !props.columns.some(column => column.name === field)) {
     378            0 :             options.push({ value: field, label: field });
     379              :         }
     380            1 :         options.push({ value: COUNT_OPTION, label: 'count()', description: 'the number of rows' });
     381            1 :         return options;
     382            1 :     }, [props.columns, field]);
     383              : 
     384            1 :     const pickField = (value: string) => {
     385            1 :         if (value === COUNT_OPTION) {
     386            1 :             onChange(setChannelCount(spec, channel));
     387            1 :             return;
     388            1 :         }
     389            1 :         const column = props.columns.find(c => c.name === value);
     390            1 :         onChange(setChannelField(spec, channel, value, type ?? column?.type ?? 'nominal'));
     391            1 :     };
     392              : 
     393            1 :     return (
     394            1 :         <div
     395            1 :             ref={setNodeRef}
     396            1 :             className={classNames(styles.channel_row, { [styles.channel_row_drop]: active != null, [styles.channel_row_over]: isOver })}
     397            1 :             data-testid={`chart-channel-${channel}`}
     398              :         >
     399            1 :             <div className={styles.channel_main}>
     400            1 :                 <span className={styles.channel_label}>{channel}</span>
     401            1 :                 {custom ? (
     402            1 :                     <span className={styles.custom_pill} title="Edit this channel in the JSON">custom</span>
     403              :                 ) : (
     404            1 :                     <>
     405            1 :                         <ChartSelect
     406            1 :                             aria-label={`Field of ${channel}`}
     407            1 :                             data-testid={`chart-channel-${channel}-field`}
     408            1 :                             className={styles.field_select}
     409            1 :                             value={count ? COUNT_OPTION : field}
     410            1 :                             triggerLabel={channelSummary(def) ?? undefined}
     411            1 :                             placeholder="Drop or pick a field"
     412            1 :                             options={fieldOptions}
     413            1 :                             onChange={pickField}
     414            1 :                         />
     415            1 :                         <ChartSelect
     416            1 :                             aria-label={`Type of ${channel}`}
     417            1 :                             data-testid={`chart-channel-${channel}-type`}
     418            1 :                             className={styles.type_select}
     419            1 :                             value={type}
     420            1 :                             placeholder="–"
     421            1 :                             options={TYPE_OPTIONS}
     422            1 :                             disabled={!encoded || count}
     423            1 :                             onChange={value => onChange(setChannelType(spec, channel, value as FieldType))}
     424            1 :                         />
     425            1 :                     </>
     426              :                 )}
     427            1 :                 <IconButton
     428            1 :                     variant={ButtonVariant.Invisible}
     429            1 :                     size={ButtonSize.Small}
     430            1 :                     aria-label={`${expanded ? 'Hide' : 'Show'} the options of ${channel}`}
     431            1 :                     aria-expanded={expanded}
     432            1 :                     disabled={!encoded || custom}
     433            1 :                     onClick={() => setExpanded(e => !e)}
     434              :                 >
     435            1 :                     <MoreIcon size={16} />
     436            1 :                 </IconButton>
     437            1 :                 <IconButton
     438            1 :                     variant={ButtonVariant.Invisible}
     439            1 :                     size={ButtonSize.Small}
     440            1 :                     aria-label={`Remove ${channel}`}
     441            1 :                     data-testid={`chart-channel-${channel}-remove`}
     442            1 :                     disabled={def == null && (DEFAULT_CHANNELS as readonly string[]).includes(channel)}
     443            1 :                     onClick={() => {
     444              :                         // A default channel keeps its row, the other channels disappear with their field
     445            1 :                         if (encoded && !custom) {
     446            1 :                             onChange(clearChannelField(spec, channel));
     447              :                         } else {
     448            0 :                             props.onRemove();
     449              :                         }
     450            1 :                     }}
     451              :                 >
     452            1 :                     <XIcon size={16} />
     453            1 :                 </IconButton>
     454            1 :             </div>
     455            1 :             {expanded && encoded && !custom && fieldDef != null && (
     456            1 :                 <ChannelOptions channel={channel} def={fieldDef} type={type} count={count} spec={spec} onChange={onChange} />
     457              :             )}
     458            1 :         </div>
     459              :     );
     460            1 : }
     461              : 
     462              : interface ChannelOptionsProps {
     463              :     channel: string;
     464              :     def: Record<string, unknown>;
     465              :     type: FieldType | null;
     466              :     count: boolean;
     467              :     spec: ChartSpec;
     468              :     onChange: (spec: ChartSpec) => void;
     469              : }
     470              : 
     471            1 : function ChannelOptions(props: ChannelOptionsProps) {
     472            1 :     const { channel, def, spec, onChange } = props;
     473            1 :     const patch = (values: Record<string, unknown>) => onChange(patchChannel(spec, channel, values));
     474            1 :     const optionValue = (value: unknown) => typeof value === 'string' ? value : NONE_OPTION;
     475            1 :     const sort = typeof def.sort === 'string' ? def.sort : null;
     476            1 :     const stack = def.stack === null ? 'null' : typeof def.stack === 'string' ? def.stack : NONE_OPTION;
     477            1 :     const timeUnit = getTimeUnit(def.timeUnit);
     478            1 :     return (
     479            1 :         <div className={styles.channel_options} data-testid={`chart-channel-${channel}-options`}>
     480            1 :             {!props.count && (
     481            1 :                 <div className={styles.option_row}>
     482            1 :                     <span className={styles.option_label}>Aggregate</span>
     483            1 :                     <ChartSelect
     484            1 :                         aria-label={`Aggregate of ${channel}`}
     485            1 :                         className={styles.select_block}
     486            1 :                         value={optionValue(def.aggregate)}
     487            1 :                         placeholder="none"
     488            1 :                         options={AGGREGATE_OPTIONS}
     489            1 :                         onChange={value => patch({ aggregate: value === NONE_OPTION ? undefined : value })}
     490            1 :                     />
     491            1 :                 </div>
     492              :             )}
     493            1 :             {props.type === 'quantitative' && !props.count && (
     494            0 :                 <label className={styles.option_row}>
     495            0 :                     <span className={styles.option_label}>Bin</span>
     496            0 :                     <input
     497            0 :                         type="checkbox"
     498            0 :                         aria-label={`Bin ${channel}`}
     499            0 :                         checked={def.bin === true || (def.bin != null && typeof def.bin === 'object')}
     500            0 :                         onChange={event => patch({ bin: event.target.checked ? true : undefined })}
     501            0 :                     />
     502            0 :                 </label>
     503              :             )}
     504            1 :             {props.type === 'temporal' && (
     505            1 :                 <div className={styles.option_row}>
     506            1 :                     <span className={styles.option_label}>Time unit</span>
     507            1 :                     <ChartSelect
     508            1 :                         aria-label={`Time unit of ${channel}`}
     509            1 :                         data-testid={`chart-channel-${channel}-time-unit`}
     510            1 :                         className={styles.select_block}
     511            1 :                         value={timeUnit.unit ?? NONE_OPTION}
     512            1 :                         placeholder="none"
     513            1 :                         options={TIME_UNIT_OPTIONS}
     514            1 :                         onChange={value => patch({ timeUnit: withTimeUnit(def.timeUnit, value === NONE_OPTION ? null : value, timeUnit.step) })}
     515            1 :                     />
     516            1 :                 </div>
     517              :             )}
     518            1 :             {props.type === 'temporal' && timeUnit.unit != null && (
     519            1 :                 <div className={styles.option_row}>
     520            1 :                     <span className={styles.option_label}>Step</span>
     521            1 :                     <SpecTextInput
     522            1 :                         aria-label={`Time unit step of ${channel}`}
     523            1 :                         data-testid={`chart-channel-${channel}-time-step`}
     524            1 :                         inputMode="numeric"
     525            1 :                         placeholder="1"
     526            1 :                         value={timeUnit.step != null && timeUnit.step > 1 ? String(timeUnit.step) : ''}
     527            1 :                         onCommit={text => {
     528              :                             // An empty text is no step, other text that is no whole number is ignored
     529            1 :                             const trimmed = text.trim();
     530            1 :                             if (trimmed.length > 0 && !/^\d+$/.test(trimmed)) return;
     531            1 :                             const step = trimmed.length === 0 ? null : Number.parseInt(trimmed, 10);
     532            1 :                             patch({ timeUnit: withTimeUnit(def.timeUnit, timeUnit.unit, step) });
     533            1 :                         }}
     534            1 :                     />
     535            1 :                 </div>
     536              :             )}
     537            1 :             {(sort != null || def.sort == null) && (
     538            1 :                 <div className={styles.option_row}>
     539            1 :                     <span className={styles.option_label}>Sort</span>
     540            1 :                     <ChartSelect
     541            1 :                         aria-label={`Sort of ${channel}`}
     542            1 :                         className={styles.select_block}
     543            1 :                         value={sort ?? NONE_OPTION}
     544            1 :                         placeholder="none"
     545            1 :                         options={SORT_OPTIONS}
     546            1 :                         onChange={value => patch({ sort: value === NONE_OPTION ? undefined : value })}
     547            1 :                     />
     548            1 :                 </div>
     549              :             )}
     550            1 :             {(channel === 'x' || channel === 'y' || channel === 'theta') && (
     551            1 :                 <div className={styles.option_row}>
     552            1 :                     <span className={styles.option_label}>Stack</span>
     553            1 :                     <ChartSelect
     554            1 :                         aria-label={`Stack of ${channel}`}
     555            1 :                         className={styles.select_block}
     556            1 :                         value={stack}
     557            1 :                         placeholder="default"
     558            1 :                         options={STACK_OPTIONS}
     559            1 :                         onChange={value => patch({ stack: value === NONE_OPTION ? undefined : value === 'null' ? null : value })}
     560            1 :                     />
     561            1 :                 </div>
     562              :             )}
     563            1 :             <div className={styles.option_row}>
     564            1 :                 <span className={styles.option_label}>Title</span>
     565            1 :                 <SpecTextInput
     566            1 :                     aria-label={`Title of ${channel}`}
     567            1 :                     value={typeof def.title === 'string' ? def.title : ''}
     568            1 :                     onCommit={text => patch({ title: text.length === 0 ? undefined : text })}
     569            1 :                 />
     570            1 :             </div>
     571            1 :         </div>
     572              :     );
     573            1 : }
     574              : 
     575              : /// The title of the chart, a string or the text of a title object
     576            1 : function chartTitle(spec: ChartSpec): string {
     577            1 :     const title = spec.title;
     578            1 :     if (typeof title === 'string') return title;
     579            1 :     if (title != null && typeof title === 'object' && typeof (title as Record<string, unknown>).text === 'string') {
     580            0 :         return (title as Record<string, unknown>).text as string;
     581              :     }
     582            1 :     return '';
     583            1 : }
     584              : 
     585              : /// The settings of the whole chart, collapsed until the user opens them. Collapsed, the heading shows the title.
     586            1 : function ViewSection(props: { spec: ChartSpec; onChange: (spec: ChartSpec) => void }) {
     587            1 :     const [open, setOpen] = React.useState(false);
     588            1 :     const title = chartTitle(props.spec);
     589            1 :     return (
     590            1 :         <section
     591            1 :             className={classNames(styles.builder_section, styles.builder_section_toggled, { [styles.builder_section_collapsed]: !open })}
     592            1 :             aria-labelledby="chart-builder-view"
     593              :         >
     594            1 :             <h3 className={styles.builder_heading_toggle}>
     595            1 :                 <button
     596            1 :                     type="button"
     597            1 :                     id="chart-builder-view"
     598            1 :                     className={styles.section_toggle}
     599            1 :                     aria-expanded={open}
     600            1 :                     aria-controls="chart-builder-view-settings"
     601            1 :                     data-testid="chart-view-toggle"
     602            1 :                     onClick={() => setOpen(o => !o)}
     603              :                 >
     604            1 :                     <span className={styles.builder_heading}>View</span>
     605            1 :                     {!open && title.length > 0 && <span className={styles.section_summary}>{title}</span>}
     606            1 :                     {open ? <ChevronUpIcon size={16} /> : <ChevronDownIcon size={16} />}
     607            1 :                 </button>
     608            1 :             </h3>
     609            1 :             {open && (
     610            1 :                 <div id="chart-builder-view-settings">
     611            1 :                     <ViewSettings spec={props.spec} onChange={props.onChange} />
     612            1 :                 </div>
     613              :             )}
     614            1 :         </section>
     615              :     );
     616            1 : }
     617              : 
     618              : /// A size of the view: a number of pixels or `container`
     619            0 : function parseViewSize(text: string): number | 'container' | undefined | null {
     620            0 :     const trimmed = text.trim();
     621            0 :     if (trimmed.length === 0) return undefined;
     622            0 :     if (trimmed === 'container') return 'container';
     623            0 :     const value = Number(trimmed);
     624            0 :     return Number.isFinite(value) && value > 0 ? value : null;
     625            0 : }
     626              : 
     627            1 : function ViewSettings(props: { spec: ChartSpec; onChange: (spec: ChartSpec) => void }) {
     628            1 :     const { spec, onChange } = props;
     629            1 :     const title = spec.title;
     630            1 :     const titleText = chartTitle(spec);
     631            1 :     const setTitle = (text: string) => {
     632            0 :         if (title != null && typeof title === 'object' && !Array.isArray(title)) {
     633            0 :             onChange(setTopLevel(spec, 'title', { ...(title as Record<string, unknown>), text }));
     634              :         } else {
     635            0 :             onChange(setTopLevel(spec, 'title', text.length === 0 ? undefined : text));
     636              :         }
     637            0 :     };
     638            1 :     const sizeInput = (key: 'width' | 'height') => (
     639            1 :         <div className={styles.option_row}>
     640            1 :             <span className={styles.option_label}>{key === 'width' ? 'Width' : 'Height'}</span>
     641            1 :             <SpecTextInput
     642            1 :                 aria-label={`${key === 'width' ? 'Width' : 'Height'} of the chart`}
     643            1 :                 placeholder="auto"
     644            1 :                 value={spec[key] == null ? '' : String(spec[key])}
     645            1 :                 onCommit={text => {
     646            0 :                     const size = parseViewSize(text);
     647            0 :                     if (size !== null) onChange(setTopLevel(spec, key, size));
     648            0 :                 }}
     649            1 :             />
     650            1 :         </div>
     651              :     );
     652            1 :     return (
     653            1 :         <div className={styles.view_settings}>
     654            1 :             <div className={styles.option_row}>
     655            1 :                 <span className={styles.option_label}>Title</span>
     656            1 :                 <SpecTextInput aria-label="Title of the chart" value={titleText} onCommit={setTitle} data-testid="chart-title" />
     657            1 :             </div>
     658            1 :             {sizeInput('width')}
     659            1 :             {sizeInput('height')}
     660            1 :         </div>
     661              :     );
     662            1 : }
     663              : 
     664              : interface SpecTextInputProps {
     665              :     'aria-label': string;
     666              :     'data-testid'?: string;
     667              :     value: string;
     668              :     placeholder?: string;
     669              :     inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
     670              :     /// Every edit of the text, the caller ignores text that is no valid value
     671              :     onCommit: (text: string) => void;
     672              : }
     673              : 
     674              : /// A text input that keeps the typed text while the spec holds another representation, e.g. a partly typed size
     675            1 : function SpecTextInput(props: SpecTextInputProps) {
     676            1 :     const [text, setText] = React.useState(props.value);
     677            1 :     const lastValue = React.useRef(props.value);
     678            1 :     if (lastValue.current !== props.value) {
     679            1 :         lastValue.current = props.value;
     680            1 :         if (text !== props.value) setText(props.value);
     681            1 :     }
     682            1 :     return (
     683            1 :         <TextInput
     684            1 :             className={styles.text_input}
     685            1 :             aria-label={props['aria-label']}
     686            1 :             data-testid={props['data-testid']}
     687            1 :             placeholder={props.placeholder}
     688            1 :             inputMode={props.inputMode}
     689            1 :             value={text}
     690            1 :             onChange={event => {
     691            1 :                 setText(event.target.value);
     692            1 :                 props.onCommit(event.target.value);
     693            1 :             }}
     694            1 :         />
     695              :     );
     696            1 : }
        

Generated by: LCOV version 2.0-1