LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - query_result_search_controls.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 89.1 % 129 115
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { Button, ButtonSize, ButtonVariant } from '../../ui/foundations/button.js';
       4              : import { SearchIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
       5              : import { TextInput } from '../../ui/foundations/text_input.js';
       6              : import { TextInputAction } from '../../ui/foundations/text_input_action.js';
       7              : import type { ResultSearchState } from '../../compute/computation_types.js';
       8              : import * as styles from './query_result_search_controls.module.css';
       9              : 
      10              : /// Data searches run SQL over the whole result, so they only start once typing pauses
      11            1 : const DATA_SEARCH_DEBOUNCE_MS = 200;
      12              : 
      13              : interface SearchControlProps {
      14              :     kind: 'Columns' | 'Data';
      15              :     state: ResultSearchState;
      16              :     expanded: boolean;
      17              :     onExpand: () => void;
      18              :     onCollapse: () => void;
      19              :     onChange: (value: string) => void;
      20              :     matchCount: number | null;
      21              :     /// Delay before a typed pattern is propagated, 0 propagates immediately
      22              :     debounceMs?: number;
      23              : }
      24              : 
      25            1 : function SearchControl(props: SearchControlProps) {
      26            1 :     const testId = `search-${props.kind.toLowerCase()}`;
      27            1 :     const inputRef = React.useRef<HTMLInputElement>(null);
      28            1 :     const buttonRef = React.useRef<HTMLButtonElement>(null);
      29            1 :     const inputId = React.useId();
      30            1 :     React.useEffect(() => {
      31            1 :         if (props.expanded) inputRef.current?.focus();
      32            1 :     }, [props.expanded]);
      33              : 
      34              :     // Keep the input responsive with a local value and propagate it debounced
      35            1 :     const requestedPattern = props.state.requestedPattern;
      36            1 :     const [pattern, setPattern] = React.useState(requestedPattern);
      37            1 :     const pendingChange = React.useRef<{ timer: ReturnType<typeof setTimeout>; value: string } | null>(null);
      38            1 :     const onChangeRef = React.useRef(props.onChange);
      39            1 :     onChangeRef.current = props.onChange;
      40            1 :     const flushChange = React.useCallback(() => {
      41            1 :         const pending = pendingChange.current;
      42            1 :         if (pending == null) return;
      43            0 :         clearTimeout(pending.timer);
      44            0 :         pendingChange.current = null;
      45            0 :         onChangeRef.current(pending.value);
      46            1 :     }, []);
      47            1 :     const changePattern = React.useCallback((value: string, immediate: boolean) => {
      48            1 :         setPattern(value);
      49            1 :         const pending = pendingChange.current;
      50            1 :         if (pending != null) {
      51            0 :             clearTimeout(pending.timer);
      52            0 :             pendingChange.current = null;
      53            0 :         }
      54            1 :         const debounceMs = props.debounceMs ?? 0;
      55            1 :         if (immediate || debounceMs <= 0) {
      56            1 :             onChangeRef.current(value);
      57            1 :             return;
      58            1 :         }
      59            0 :         pendingChange.current = { timer: setTimeout(flushChange, debounceMs), value };
      60            1 :     }, [props.debounceMs, flushChange]);
      61              :     // Adopt patterns that change from outside while no local edit is pending
      62            1 :     React.useEffect(() => {
      63            1 :         if (pendingChange.current == null) setPattern(requestedPattern);
      64            1 :     }, [requestedPattern]);
      65              :     // Don't drop a pending edit when the control unmounts
      66            1 :     React.useEffect(() => () => flushChange(), [flushChange]);
      67              : 
      68            1 :     return (
      69            1 :         <div className={styles.control} aria-busy={props.state.pending || undefined}>
      70            1 :             {props.expanded ? (
      71            1 :                 <TextInput
      72            1 :                     ref={inputRef}
      73            1 :                     id={inputId}
      74            1 :                     className={styles.input}
      75            1 :                     aria-label={`Search ${props.kind.toLowerCase()}`}
      76            1 :                     data-testid={`${testId}-input`}
      77            1 :                     placeholder={`Search ${props.kind.toLowerCase()}`}
      78            1 :                     leadingVisual={SearchIcon}
      79            1 :                     value={pattern}
      80            1 :                     onChange={event => changePattern(event.target.value, false)}
      81            1 :                     onBlur={() => {
      82            1 :                         if (pattern.length === 0) {
      83            1 :                             props.onCollapse();
      84              :                         }
      85            1 :                     }}
      86            1 :                     onKeyDown={event => {
      87            1 :                         if (event.key === 'Escape') {
      88            1 :                             if (pattern.length === 0) {
      89            0 :                                 event.preventDefault();
      90            0 :                                 props.onCollapse();
      91            0 :                                 requestAnimationFrame(() => buttonRef.current?.focus());
      92            0 :                             }
      93              :                         }
      94            1 :                     }}
      95            1 :                     trailingAction={(
      96            1 :                         <TextInputAction
      97            1 :                             aria-label={`Close ${props.kind.toLowerCase()} search`}
      98            1 :                             aria-labelledby=""
      99            1 :                             data-testid={`${testId}-close`}
     100            1 :                             onClick={() => {
     101            1 :                                 changePattern('', true);
     102            1 :                                 props.onCollapse();
     103            1 :                                 requestAnimationFrame(() => buttonRef.current?.focus());
     104            1 :                             }}
     105              :                         >
     106            1 :                             <XIcon />
     107            1 :                         </TextInputAction>
     108              :                     )}
     109            1 :                 />
     110              :             ) : (
     111            1 :                 <Button
     112            1 :                     ref={buttonRef}
     113            1 :                     className={styles.button}
     114            1 :                     variant={ButtonVariant.Invisible}
     115            1 :                     size={ButtonSize.Small}
     116            1 :                     leadingVisual={SearchIcon}
     117            1 :                     aria-expanded={false}
     118            1 :                     aria-controls={inputId}
     119            1 :                     onClick={props.onExpand}
     120            1 :                     data-testid={`${testId}-button`}
     121            1 :                     title={`${props.kind} search`}
     122              :                 >
     123            1 :                     {props.kind}
     124            1 :                 </Button>
     125              :             )}
     126            1 :             {props.state.error != null && (
     127            0 :                 <span className={styles.error} title={props.state.error} aria-label={`${props.kind} search failed`}>
     128              :                     !
     129            0 :                 </span>
     130              :             )}
     131            1 :             <span className={styles.status} role="status" data-testid={`${testId}-status`}>
     132            1 :                 {props.state.error
     133            1 :                     ?? (props.state.pending
     134            0 :                         ? `Searching ${props.kind.toLowerCase()}`
     135            1 :                         : (props.state.requestedPattern.length > 0 && props.matchCount != null
     136            1 :                             ? `${props.kind} search complete, ${props.matchCount} matching ${props.kind.toLowerCase()}`
     137            1 :                             : ''))}
     138            1 :             </span>
     139            1 :         </div>
     140              :     );
     141            1 : }
     142              : 
     143              : interface Props {
     144              :     columnSearch: ResultSearchState;
     145              :     dataSearch: ResultSearchState;
     146              :     onColumnPatternChange: (value: string) => void;
     147              :     onDataPatternChange: (value: string) => void;
     148              :     columnMatchCount: number | null;
     149              :     dataMatchCount: number | null;
     150              : }
     151              : 
     152            1 : export function QueryResultSearchControls(props: Props) {
     153            1 :     const [columnsExpanded, setColumnsExpanded] = React.useState(() => props.columnSearch.requestedPattern.length > 0);
     154            1 :     const [dataExpanded, setDataExpanded] = React.useState(() => props.dataSearch.requestedPattern.length > 0);
     155            1 :     return (
     156            1 :         <>
     157            1 :             <SearchControl
     158            1 :                 kind="Columns"
     159            1 :                 state={props.columnSearch}
     160            1 :                 expanded={columnsExpanded}
     161            1 :                 onExpand={() => setColumnsExpanded(expanded => !expanded)}
     162            1 :                 onCollapse={() => setColumnsExpanded(false)}
     163            1 :                 onChange={props.onColumnPatternChange}
     164            1 :                 matchCount={props.columnMatchCount}
     165            1 :             />
     166            1 :             <SearchControl
     167            1 :                 kind="Data"
     168            1 :                 state={props.dataSearch}
     169            1 :                 expanded={dataExpanded}
     170            1 :                 onExpand={() => setDataExpanded(expanded => !expanded)}
     171            1 :                 onCollapse={() => setDataExpanded(false)}
     172            1 :                 onChange={props.onDataPatternChange}
     173            1 :                 matchCount={props.dataMatchCount}
     174            1 :                 debounceMs={DATA_SEARCH_DEBOUNCE_MS}
     175            1 :             />
     176            1 :         </>
     177              :     );
     178            1 : }
        

Generated by: LCOV version 2.0-1