LCOV - code coverage report
Current view: top level - dashql-app/src/session/inputs - session_input_card.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 77.5 % 160 124
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { classNames } from '../../utils/classnames.js';
       4              : import { sessionInputControl, SessionInputControl, type SessionInput } from './session_inputs.js';
       5              : 
       6              : import * as styles from './session_inputs.module.css';
       7              : 
       8              : /// How a card is drawn: a chip in the strip of the feed and the dashboard, a block in a box of the dataflow
       9              : export type SessionInputCardVariant = 'chip' | 'block';
      10              : 
      11              : interface Props {
      12              :     input: SessionInput;
      13              :     /// The literal of a value of the control in the Hyper dialect, or the problem with the value
      14              :     literalOf: (input: SessionInput, value: string) => { literal: string } | { error: string };
      15              :     /// Write the literal and run the scripts that use the input
      16              :     onCommit: (name: string, literal: string) => void;
      17              :     /// Do the scripts of the input run?
      18              :     busy?: boolean;
      19              :     variant?: SessionInputCardVariant;
      20              :     className?: string;
      21              : }
      22              : 
      23              : /// A `datetime-local` value from a SQL timestamp, null if the control can't show it
      24            0 : function toDateTimeLocal(value: string): string | null {
      25            0 :     const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(:\d{2})?)$/.exec(value.trim());
      26            0 :     return match == null ? null : `${match[1]}T${match[2]}`;
      27            0 : }
      28              : 
      29              : /// A SQL timestamp from a `datetime-local` value
      30            0 : function fromDateTimeLocal(value: string): string {
      31            0 :     return value.replace('T', ' ');
      32            0 : }
      33              : 
      34              : /// The units of an interval, an interval is a number and one unit (docs/design/session_inputs.md)
      35            1 : const INTERVAL_UNITS = ['year', 'month', 'day', 'hour', 'minute', 'second'];
      36              : 
      37              : /// The amount and the unit of the value of an interval, `3 day`, the unit is null without one
      38            1 : function splitInterval(value: string): { amount: string; unit: string | null } {
      39            1 :     const match = /^\s*(\S*)\s+(\S+)\s*$/.exec(value);
      40            1 :     return match == null ? { amount: value.trim(), unit: null } : { amount: match[1], unit: match[2].toLowerCase() };
      41            1 : }
      42              : 
      43            1 : const PLACEHOLDERS: Partial<Record<SessionInputControl, string>> = {
      44            1 :     [SessionInputControl.Interval]: '3',
      45            1 :     [SessionInputControl.Number]: '0',
      46            1 :     [SessionInputControl.Integer]: '0',
      47            1 : };
      48              : 
      49              : /// How long a typed draft rests before it is written
      50              : export const SESSION_INPUT_COMMIT_DELAY_MS = 600;
      51              : 
      52              : /// The value of an input in a card: the name of the parameter, its type and a control that follows the type. The
      53              : /// control owns its draft, a draft that rests for a moment is written as literal and runs the scripts that use the
      54              : /// input. Enter and leaving the control write it at once, Escape restores the value.
      55            1 : export function SessionInputCard(props: Props): React.ReactElement {
      56            1 :     const { input, onCommit, literalOf } = props;
      57            1 :     const value = input.value ?? '';
      58            1 :     let control = sessionInputControl(input.type);
      59              :     // A timestamp that the picker can't show is text
      60            1 :     if (control === SessionInputControl.Timestamp && value.length > 0 && toDateTimeLocal(value) == null) control = SessionInputControl.Text;
      61            1 :     const shown = control === SessionInputControl.Timestamp ? (toDateTimeLocal(value) ?? '') : value;
      62            1 :     const [draft, setDraft] = React.useState(shown);
      63              :     // The draft that waits to be written, a newer value of the input doesn't replace it
      64            1 :     const pending = React.useRef<{ value: string; timer: ReturnType<typeof setTimeout> } | null>(null);
      65            1 :     React.useEffect(() => {
      66            1 :         if (pending.current == null) setDraft(shown);
      67            1 :     }, [shown]);
      68            1 :     const committed = control === SessionInputControl.Timestamp ? fromDateTimeLocal(draft) : draft;
      69            1 :     const dirty = draft !== shown;
      70            1 :     const result = React.useMemo(() => literalOf(input, committed), [literalOf, input, committed]);
      71            1 :     const problem = draft.trim().length === 0 || !('error' in result) ? null : result.error;
      72            1 :     const unset = input.value == null;
      73              :     // The unit of an interval without an amount, the draft holds the unit once it has one
      74            1 :     const [emptyUnit, setEmptyUnit] = React.useState('day');
      75            1 :     const interval = splitInterval(draft);
      76            1 :     const intervalUnit = interval.unit ?? emptyUnit;
      77              : 
      78            1 :     const commit = React.useCallback((next: string) => {
      79            1 :         const literal = literalOf(input, next);
      80            1 :         if ('literal' in literal) onCommit(input.name, literal.literal);
      81            1 :     }, [input, literalOf, onCommit]);
      82            1 :     const commitRef = React.useRef(commit);
      83            1 :     commitRef.current = commit;
      84            1 :     const cancelPending = React.useCallback(() => {
      85            1 :         if (pending.current == null) return;
      86            1 :         clearTimeout(pending.current.timer);
      87            1 :         pending.current = null;
      88            1 :     }, []);
      89            1 :     const flushPending = React.useCallback(() => {
      90            1 :         const next = pending.current;
      91            1 :         if (next == null) return;
      92            1 :         clearTimeout(next.timer);
      93            1 :         pending.current = null;
      94            1 :         commitRef.current(next.value);
      95            1 :     }, []);
      96            1 :     React.useEffect(() => cancelPending, [cancelPending]);
      97              :     /// Take a typed draft and write it once it rests, a draft that equals the value writes nothing
      98            1 :     const editDraft = (next: string) => {
      99            1 :         setDraft(next);
     100            1 :         cancelPending();
     101            1 :         if (next === shown) return;
     102            1 :         const value = control === SessionInputControl.Timestamp ? fromDateTimeLocal(next) : next;
     103            1 :         pending.current = { value, timer: setTimeout(flushPending, SESSION_INPUT_COMMIT_DELAY_MS) };
     104            1 :     };
     105            1 :     const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
     106            1 :         if (event.key === 'Enter') {
     107            1 :             event.preventDefault();
     108            1 :             cancelPending();
     109            1 :             commit(committed);
     110            1 :         } else if (event.key === 'Escape') {
     111            1 :             event.preventDefault();
     112            1 :             cancelPending();
     113            1 :             setDraft(shown);
     114            1 :             event.currentTarget.blur();
     115            1 :         } else {
     116            0 :             return;
     117              :         }
     118            1 :         event.stopPropagation();
     119            1 :     }, [cancelPending, commit, committed, shown]);
     120              : 
     121            1 :     const label = `Value of :${input.name}`;
     122            1 :     const typeTitle = input.type.cast
     123            0 :         ? `Cast to ${input.type.sql}`
     124            1 :         : input.type.known ? `The type of the value ${input.literal ?? ''}` : 'Without a value or a cast, the input is text';
     125            1 :     const title = problem ?? input.warnings[0] ?? (unset ? 'The input has no value, the scripts that use it fail' : input.literal ?? undefined);
     126            1 :     return (
     127            1 :         <div
     128            1 :             className={classNames(styles.card, props.className, { [styles.card_block]: props.variant === 'block' })}
     129            1 :             data-session-input={input.name}
     130            1 :             data-dirty={dirty || undefined}
     131            1 :             data-invalid={problem != null || undefined}
     132            1 :             data-unset={(unset && !dirty) || undefined}
     133            1 :             data-busy={props.busy || undefined}
     134            1 :             title={title}
     135            1 :             onPointerDown={event => event.stopPropagation()}
     136              :         >
     137            1 :             {props.variant !== 'block' && <span className={styles.name}>:{input.name}</span>}
     138            1 :             {props.variant !== 'block' && <span className={styles.type} data-inferred={!input.type.cast || undefined} title={typeTitle}>{input.type.sql}</span>}
     139            1 :             {control === SessionInputControl.Interval ? (
     140            1 :                 <>
     141            1 :                     <input
     142            1 :                         className={classNames(styles.control, styles.control_amount)}
     143            1 :                         aria-label={label}
     144            1 :                         type="text"
     145            1 :                         inputMode="decimal"
     146            1 :                         value={interval.amount}
     147            1 :                         placeholder={PLACEHOLDERS[control]}
     148            1 :                         spellCheck={false}
     149            1 :                         autoComplete="off"
     150            1 :                         onChange={event => {
     151            1 :                             const amount = event.currentTarget.value.replace(/\s+/g, '');
     152            1 :                             editDraft(amount.length === 0 ? '' : `${amount} ${intervalUnit}`);
     153            1 :                         }}
     154            1 :                         onKeyDown={onKeyDown}
     155            1 :                         onBlur={flushPending}
     156            1 :                     />
     157            1 :                     <select
     158            1 :                         className={classNames(styles.control, styles.control_unit)}
     159            1 :                         aria-label={`Unit of :${input.name}`}
     160            1 :                         value={intervalUnit}
     161            1 :                         onChange={event => {
     162            1 :                             const unit = event.currentTarget.value;
     163            1 :                             if (interval.amount.length === 0) {
     164            1 :                                 setEmptyUnit(unit);
     165            1 :                                 return;
     166            1 :                             }
     167            1 :                             const next = `${interval.amount} ${unit}`;
     168            1 :                             cancelPending();
     169            1 :                             setDraft(next);
     170            1 :                             commit(next);
     171            1 :                         }}
     172              :                     >
     173            1 :                         {!INTERVAL_UNITS.includes(intervalUnit) && <option value={intervalUnit} disabled>{intervalUnit}</option>}
     174            1 :                         {INTERVAL_UNITS.map(unit => <option key={unit} value={unit}>{unit}</option>)}
     175            1 :                     </select>
     176            1 :                 </>
     177            0 :             ) : control === SessionInputControl.Boolean ? (
     178            0 :                 <select
     179            0 :                     className={styles.control}
     180            0 :                     aria-label={label}
     181            0 :                     value={/^(true|false)$/i.test(draft) ? draft.toLowerCase() : ''}
     182            0 :                     onChange={event => {
     183            0 :                         setDraft(event.currentTarget.value);
     184            0 :                         if (event.currentTarget.value !== '') commit(event.currentTarget.value);
     185            0 :                     }}
     186              :                 >
     187            0 :                     <option value="" disabled>unset</option>
     188            0 :                     <option value="true">true</option>
     189            0 :                     <option value="false">false</option>
     190            0 :                 </select>
     191              :             ) : (
     192            0 :                 <input
     193            0 :                     className={styles.control}
     194            0 :                     aria-label={label}
     195            0 :                     type={control === SessionInputControl.Date ? 'date' : control === SessionInputControl.Timestamp ? 'datetime-local' : 'text'}
     196            0 :                     inputMode={control === SessionInputControl.Number || control === SessionInputControl.Integer ? 'decimal' : undefined}
     197            0 :                     step={control === SessionInputControl.Timestamp ? 1 : undefined}
     198            0 :                     value={draft}
     199            0 :                     placeholder={PLACEHOLDERS[control] ?? 'value'}
     200            0 :                     spellCheck={false}
     201            0 :                     autoComplete="off"
     202            0 :                     onChange={event => editDraft(event.currentTarget.value)}
     203            0 :                     onKeyDown={onKeyDown}
     204            0 :                     onBlur={flushPending}
     205            0 :                 />
     206              :             )}
     207            1 :             <span className={styles.status} aria-hidden="true" />
     208            1 :         </div>
     209              :     );
     210            1 : }
        

Generated by: LCOV version 2.0-1