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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : import icons from '@ankoh/dashql-svg-symbols';
       4              : 
       5              : import type { QueryExecutionState } from '../../query/query_execution_state.js';
       6              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       7              : import { useFocusTrap } from '../../ui/foundations/focus.js';
       8              : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
       9              : import { ScreenFullIcon, ScreenNormalIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      10              : import { VerticalTabs, VerticalTabProps, VerticalTabVariant } from '../../ui/foundations/vertical_tabs.js';
      11              : import { QueryResultDetails } from '../../views/query_result/query_result_details.js';
      12              : import { TableColumnHeader } from '../../views/query_result/data_table_cell.js';
      13              : import { QueryProfileView } from '../../views/query_profile/query_profile_view.js';
      14              : import { useQueryProfile } from '../../views/query_profile/engine_profile_view.js';
      15              : import { TabHeader } from '../tab_header.js';
      16              : import { getPlanResultText } from '../../shell/shell_result.js';
      17              : import { detectPlanEngine } from '../../views/query_profile/plan_engine.js';
      18              : import { classNames } from '../../utils/classnames.js';
      19              : import * as styles from './shell_query_result_overlay.module.css';
      20              : 
      21              : export interface ShellQueryResultOverlayDependencies {
      22              :     QueryResultDetails: typeof QueryResultDetails;
      23              :     QueryProfileView: typeof QueryProfileView;
      24              :     useQueryProfile: typeof useQueryProfile;
      25              : }
      26              : 
      27            1 : const DEFAULT_DEPENDENCIES: ShellQueryResultOverlayDependencies = {
      28            1 :     QueryResultDetails,
      29            1 :     QueryProfileView,
      30            1 :     useQueryProfile,
      31            1 : };
      32              : 
      33              : interface Props {
      34              :     query: QueryExecutionState;
      35              :     onClose: () => void;
      36              :     dismissOnClickOutside?: boolean;
      37              :     dependencies?: ShellQueryResultOverlayDependencies;
      38              : }
      39              : 
      40            1 : const MAX_OVERLAY_HEIGHT = 600;
      41            1 : const MIN_PLAN_OVERLAY_HEIGHT = 'min(360px, 80vh)';
      42            1 : const IGNORE_OUTSIDE_CLICK = () => {};
      43            1 : const CloseIcon = SymbolIcon('x_16');
      44              : 
      45            1 : const enum ResultTab {
      46            1 :     Data = 0,
      47            1 :     Plan = 1,
      48            1 : }
      49              : 
      50            1 : export const ShellQueryResultOverlay: React.FC<Props> = ({
      51            1 :     query,
      52            1 :     onClose,
      53            1 :     dismissOnClickOutside = true,
      54            1 :     dependencies = DEFAULT_DEPENDENCIES,
      55            1 : }) => {
      56            1 :     const ResultDetails = dependencies.QueryResultDetails;
      57            1 :     const ProfileViewComponent = dependencies.QueryProfileView;
      58            1 :     const closeRef = React.useRef<HTMLButtonElement>(null);
      59            1 :     const dialogRef = React.useRef<HTMLElement>(null);
      60            1 :     const [lockedHeight, setLockedHeight] = React.useState<{ queryId: number; height: number } | null>(null);
      61            1 :     const currentLockedHeight = lockedHeight?.queryId === query.queryId ? lockedHeight.height : null;
      62            1 :     const planText = React.useMemo(() => getPlanResultText(query.resultTable), [query.resultTable]);
      63            1 :     const planEngine = detectPlanEngine(planText) ?? dashql.buffers.profile.ProfileEngine.HYPER;
      64            1 :     const { plan, execution, rotate } = dependencies.useQueryProfile(planEngine, planText);
      65            1 :     const hasPlan = plan != null;
      66            1 :     const [selectedTab, setSelectedTab] = React.useState<ResultTab>(ResultTab.Data);
      67            1 :     const selectedPlanQueryIdRef = React.useRef<number | null>(null);
      68            1 :     React.useLayoutEffect(() => {
      69            1 :         if (hasPlan && selectedPlanQueryIdRef.current !== query.queryId) {
      70            1 :             const height = dialogRef.current?.getBoundingClientRect().height;
      71            1 :             setLockedHeight(height == null ? null : { queryId: query.queryId, height });
      72            1 :             selectedPlanQueryIdRef.current = query.queryId;
      73            1 :             setSelectedTab(ResultTab.Plan);
      74            1 :         } else if (!hasPlan && selectedTab === ResultTab.Plan) {
      75            0 :             setLockedHeight(null);
      76            0 :             setSelectedTab(ResultTab.Data);
      77            0 :         }
      78            1 :     }, [hasPlan, query.queryId, selectedTab]);
      79              : 
      80              :     // Only the plan tab can be expanded to fill the window
      81            1 :     const [planExpanded, setPlanExpanded] = React.useState(false);
      82            1 :     const expanded = planExpanded && selectedTab === ResultTab.Plan;
      83              : 
      84            1 :     const closeButton = React.useMemo(() => (
      85            1 :         <IconButton
      86            1 :             ref={closeRef}
      87            1 :             variant={ButtonVariant.Invisible}
      88            1 :             size={ButtonSize.Small}
      89            1 :             aria-label="Close shell query results"
      90            1 :             onClick={onClose}
      91              :         >
      92            1 :             <CloseIcon size={16} />
      93            1 :         </IconButton>
      94            1 :     ), [onClose]);
      95            1 :     const planActions = React.useMemo(() => (
      96            1 :         <>
      97            1 :             <IconButton
      98            1 :                 variant={ButtonVariant.Invisible}
      99            1 :                 size={ButtonSize.Small}
     100            1 :                 aria-label={planExpanded ? 'Restore plan size' : 'Expand plan'}
     101            1 :                 aria-pressed={planExpanded}
     102            1 :                 data-testid="plan-expand-toggle"
     103            1 :                 onClick={() => setPlanExpanded(e => !e)}
     104              :             >
     105            1 :                 {planExpanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
     106            1 :             </IconButton>
     107            1 :             {closeButton}
     108            1 :         </>
     109            1 :     ), [closeButton, planExpanded]);
     110            1 :     const tabProps = React.useMemo<Record<ResultTab, VerticalTabProps>>(() => ({
     111            1 :         [ResultTab.Data]: {
     112            1 :             tabId: ResultTab.Data,
     113            1 :             icon: `${icons}#table_24`,
     114            1 :             labelShort: 'Data',
     115            1 :             ariaLabel: 'Query results',
     116            1 :             description: 'Query results',
     117            1 :         },
     118            1 :         [ResultTab.Plan]: {
     119            1 :             tabId: ResultTab.Plan,
     120            1 :             icon: `${icons}#plan`,
     121            1 :             labelShort: 'Plan',
     122            1 :             ariaLabel: 'Query plan',
     123            1 :             description: 'Query plan',
     124            1 :             disabled: !hasPlan,
     125            1 :         },
     126            1 :     }), [hasPlan]);
     127            1 :     const tabKeys = React.useMemo(() => hasPlan ? [ResultTab.Data, ResultTab.Plan] : [ResultTab.Data], [hasPlan]);
     128            1 :     const tabRenderers = React.useMemo(() => ({
     129            1 :         [ResultTab.Data]: () => (
     130            1 :             <ResultDetails
     131            1 :                 query={query}
     132            1 :                 debugMode={false}
     133            1 :                 fitHeight
     134            1 :                 maxHeight={MAX_OVERLAY_HEIGHT}
     135            1 :                 columnHeader={TableColumnHeader.WithColumnPlots}
     136            1 :                 actions={closeButton}
     137            1 :             />
     138              :         ),
     139            1 :         [ResultTab.Plan]: () => (
     140            1 :             <div className={styles.plan_tab}>
     141            1 :                 <TabHeader title="Query Plan" actions={planActions} />
     142            1 :                 <div className={styles.plan_body}>{plan != null && <ProfileViewComponent plan={plan} execution={execution} onRotate={rotate} />}</div>
     143            1 :             </div>
     144              :         ),
     145            1 :     }), [closeButton, planActions, plan, execution, rotate, ProfileViewComponent, query, ResultDetails]);
     146            1 :     useFocusTrap({
     147            1 :         containerRef: dialogRef as React.RefObject<HTMLElement>,
     148            1 :         initialFocusRef: dialogRef as React.RefObject<HTMLElement>,
     149            1 :         restoreFocusOnCleanUp: true,
     150            1 :     });
     151            1 :     return (
     152            1 :         <Overlay
     153            1 :             centered
     154            1 :             width={OverlaySize.XXL}
     155            1 :             height={OverlaySize.AUTO}
     156            1 :             maxHeight={OverlaySize.XL}
     157            1 :             expanded={expanded}
     158            1 :             initialFocusRef={dialogRef as React.RefObject<HTMLElement>}
     159            1 :             onEscape={onClose}
     160            1 :             onClickOutside={dismissOnClickOutside ? onClose : IGNORE_OUTSIDE_CLICK}
     161              :         >
     162            1 :             <section
     163            1 :                 ref={dialogRef}
     164            1 :                 className={classNames(styles.card, {
     165            1 :                     [styles.card_locked_height]: currentLockedHeight != null && !expanded,
     166            1 :                     [styles.card_expanded]: expanded,
     167            1 :                 })}
     168            1 :                 role="dialog"
     169            1 :                 aria-modal="true"
     170            1 :                 aria-label="Shell query results"
     171            1 :                 tabIndex={-1}
     172            1 :                 style={expanded ? undefined : {
     173            1 :                     height: currentLockedHeight ?? undefined,
     174            1 :                     minHeight: selectedTab === ResultTab.Plan ? MIN_PLAN_OVERLAY_HEIGHT : undefined,
     175            1 :                 }}
     176              :             >
     177            1 :                 <VerticalTabs
     178            1 :                     className={styles.tabs}
     179            1 :                     variant={VerticalTabVariant.Stacked}
     180            1 :                     tabKeys={tabKeys}
     181            1 :                     tabProps={tabProps}
     182            1 :                     tabRenderers={tabRenderers}
     183            1 :                     selectedTab={selectedTab}
     184            1 :                     selectTab={tab => setSelectedTab(tab as ResultTab)}
     185            1 :                 />
     186            1 :             </section>
     187            1 :         </Overlay>
     188              :     );
     189            1 : };
        

Generated by: LCOV version 2.0-1