LCOV - code coverage report
Current view: top level - dashql-app/src/session/feed - feed_entry_footer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.8 % 168 166
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './feed_entry_footer.module.css';
       3              : 
       4              : import icons from '@ankoh/dashql-svg-symbols';
       5              : 
       6              : import { QueryExecutionState } from '../../query/query_execution_state.js';
       7              : import { QueryResultView } from '../../views/query_result/query_result_view.js';
       8              : import { TableColumnHeader } from '../../views/query_result/data_table_cell.js';
       9              : import { classNames } from '../../utils/classnames.js';
      10              : import { VisualizationDispatch } from '../../views/visualization/visualization_dispatch.js';
      11              : import { ResolvedVisualizeQuery } from '../scripts/script_types.js';
      12              : import { TraceLogPanel } from '../trace_log_panel.js';
      13              : import { hasQueryProgress, QueryProgressPanel } from '../../views/query_progress/query_progress_panel.js';
      14              : import { TabHeader, useResultRowCount, formatRowCountDetail } from '../tab_header.js';
      15              : import { QueryResultToolbar, useQueryResultRowCounts } from '../../views/query_result/query_result_toolbar.js';
      16              : import { QueryResultDownloadButton } from '../../views/query_result/query_result_download_button.js';
      17              : import { QueryResultActionMenu } from '../../views/query_result/query_result_action_menu.js';
      18              : import { QueryResultHeader } from '../../views/query_result/query_result_header.js';
      19              : import { ChartDownloadButton } from '../../views/visualization/chart_download_button.js';
      20              : 
      21            1 : const FEED_LIMIT_RESULT_ROWS = 6;
      22              : /// The Log tab's viewport auto-expands to fit its rows and caps at this many (then scrolls).
      23            1 : const FEED_LIMIT_LOG_ROWS = 6;
      24              : /// Cap the Vega-Lite chart height in the feed footer so a single vis entry doesn't dominate the
      25              : /// feed while scrolling. Only the vega-lite renderer honors this.
      26            1 : const FEED_VISUALIZATION_HEIGHT = 180;
      27              : /// The height of the plan in the Progress tab. The plan and its statistics are fetched while the tab is open.
      28            1 : const FEED_PROFILE_HEIGHT = 240;
      29              : 
      30              : /// The result tabs of a feed entry. The tab strip lives in the entry status bar and the panel of the
      31              : /// selected tab below it, so the row owns the selection through `useFeedEntryTabs`.
      32            1 : export enum FeedEntryTab {
      33            1 :     ExecutionLog = 0,
      34            1 :     Table = 1,
      35            1 :     Visualization = 2,
      36            1 :     Progress = 3,
      37            1 : }
      38              : 
      39              : export interface FeedEntryTabProps {
      40              :     tab: FeedEntryTab;
      41              :     icon: string;
      42              :     description: string;
      43              : }
      44              : 
      45            1 : const FEED_ENTRY_TAB_PROPS: Record<FeedEntryTab, FeedEntryTabProps> = {
      46            1 :     [FeedEntryTab.Progress]: {
      47            1 :         tab: FeedEntryTab.Progress,
      48            1 :         icon: `${icons}#pulse_24`,
      49            1 :         description: 'Query progress',
      50            1 :     },
      51            1 :     [FeedEntryTab.ExecutionLog]: {
      52            1 :         tab: FeedEntryTab.ExecutionLog,
      53            1 :         icon: `${icons}#log_24`,
      54            1 :         description: 'Execution log',
      55            1 :     },
      56            1 :     [FeedEntryTab.Table]: {
      57            1 :         tab: FeedEntryTab.Table,
      58            1 :         icon: `${icons}#table_24`,
      59            1 :         description: 'Query results',
      60            1 :     },
      61            1 :     [FeedEntryTab.Visualization]: {
      62            1 :         tab: FeedEntryTab.Visualization,
      63            1 :         icon: `${icons}#graph_24`,
      64            1 :         description: 'Visualization',
      65            1 :     },
      66            1 : };
      67              : 
      68              : export interface FeedEntryTabs {
      69              :     /// The usable tabs, in display order
      70              :     tabs: FeedEntryTabProps[];
      71              :     selectedTab: FeedEntryTab;
      72              :     selectTab: (tab: FeedEntryTab) => void;
      73              : }
      74              : 
      75              : /// Track the tabs of a feed entry result. A query with structured progress (Trino) selects its progress
      76              : /// tab, which sits right of the log. A result that arrives switches to its table (or to its chart
      77              : /// when the script visualizes it), a result that goes away falls back to the progress or the log. The
      78              : /// Details view passes the tab it was opened on as `initialTab`.
      79            1 : export function useFeedEntryTabs(
      80            1 :     queryState: QueryExecutionState | null,
      81            1 :     visualizeQuery: ResolvedVisualizeQuery | null,
      82            1 :     dependencies: Pick<FeedEntryFooterDependencies, 'useResultRowCount'> = DEFAULT_DEPENDENCIES,
      83            1 :     initialTab?: FeedEntryTab,
      84            1 : ): FeedEntryTabs {
      85            1 :     const { hasResult } = dependencies.useResultRowCount(queryState);
      86            1 :     const hasVisualization = hasResult && visualizeQuery != null && visualizeQuery.renderer !== 'table';
      87            1 :     const hasLog = queryState?.traceId != null;
      88            1 :     const hasProgress = hasQueryProgress(queryState);
      89            1 :     const fallbackTab = hasProgress ? FeedEntryTab.Progress : FeedEntryTab.ExecutionLog;
      90              : 
      91            1 :     const [selectedTab, selectTab] = React.useState<FeedEntryTab>(
      92            1 :         () => initialTab != null ? initialTab
      93            1 :             : hasVisualization ? FeedEntryTab.Visualization
      94            1 :             : hasResult ? FeedEntryTab.Table
      95            1 :                 : fallbackTab
      96            1 :     );
      97              : 
      98            1 :     const prevHasResult = React.useRef(hasResult);
      99            1 :     const prevHasVisualization = React.useRef(hasVisualization);
     100            1 :     const prevHasProgress = React.useRef(hasProgress);
     101            1 :     React.useEffect(() => {
     102            1 :         if (hasResult && !prevHasResult.current) {
     103            1 :             selectTab(hasVisualization ? FeedEntryTab.Visualization : FeedEntryTab.Table);
     104            1 :         } else if (!hasResult && prevHasResult.current) {
     105            1 :             selectTab(fallbackTab);
     106            1 :         } else if (hasVisualization && !prevHasVisualization.current) {
     107              :             // A chart for the shown result, e.g. written by the agent after it ran the query
     108            1 :             selectTab(FeedEntryTab.Visualization);
     109            1 :         } else if (hasProgress && !prevHasProgress.current && !hasResult) {
     110              :             // The first progress of a running query replaces the log
     111            1 :             selectTab(current => current === FeedEntryTab.ExecutionLog ? FeedEntryTab.Progress : current);
     112              :         }
     113            1 :         prevHasResult.current = hasResult;
     114            1 :         prevHasVisualization.current = hasVisualization;
     115            1 :         prevHasProgress.current = hasProgress;
     116            1 :     }, [hasResult, hasVisualization, hasProgress, fallbackTab]);
     117              : 
     118              :     // Only surface the tabs that are actually usable
     119            1 :     const tabs = React.useMemo(() => {
     120            1 :         const tabs: FeedEntryTabProps[] = [];
     121            1 :         if (hasLog) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.ExecutionLog]);
     122            1 :         if (hasProgress) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Progress]);
     123            1 :         if (hasResult) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Table]);
     124            1 :         if (hasVisualization) tabs.push(FEED_ENTRY_TAB_PROPS[FeedEntryTab.Visualization]);
     125            1 :         return tabs;
     126            1 :     }, [hasProgress, hasLog, hasResult, hasVisualization]);
     127            1 :     React.useEffect(() => {
     128            1 :         if (tabs.length > 0 && !tabs.some(t => t.tab === selectedTab)) {
     129            1 :             selectTab(tabs[0].tab);
     130              :         }
     131            1 :     }, [tabs, selectedTab]);
     132              : 
     133            1 :     return { tabs, selectedTab, selectTab };
     134            1 : }
     135              : 
     136              : interface FeedEntryTabStripProps {
     137              :     tabs: FeedEntryTabs;
     138              :     /// Is the result shown? A collapsed result selects none of the tabs.
     139              :     expanded?: boolean;
     140              :     /// Called after a tab was picked, e.g. to expand a collapsed result
     141              :     onSelect?: () => void;
     142              : }
     143              : 
     144              : /// The result tabs as a compact strip for the entry status bar
     145            1 : export const FeedEntryTabStrip: React.FC<FeedEntryTabStripProps> = ({ tabs, expanded = true, onSelect }) => {
     146            1 :     if (tabs.tabs.length === 0) {
     147            0 :         return null;
     148              :     }
     149            1 :     return (
     150            1 :         <div className={styles.tab_strip} role="tablist" aria-label="Result views">
     151            1 :             {tabs.tabs.map(t => {
     152            1 :                 const active = expanded && t.tab === tabs.selectedTab;
     153            1 :                 return (
     154            1 :                     <button
     155            1 :                         key={t.tab}
     156            1 :                         type="button"
     157            1 :                         role="tab"
     158            1 :                         className={classNames(styles.tab, { [styles.tab_active]: active })}
     159            1 :                         aria-selected={active}
     160            1 :                         aria-label={t.description}
     161            1 :                         title={t.description}
     162            1 :                         data-tab={t.tab}
     163            1 :                         onClick={() => {
     164            1 :                             tabs.selectTab(t.tab);
     165            1 :                             onSelect?.();
     166            1 :                         }}
     167              :                     >
     168            1 :                         <svg width="14px" height="14px">
     169            1 :                             <use xlinkHref={t.icon} />
     170            1 :                         </svg>
     171            1 :                     </button>
     172              :                 );
     173            1 :             })}
     174            1 :         </div>
     175              :     );
     176            1 : };
     177              : 
     178              : export interface FeedEntryFooterDependencies {
     179              :     useResultRowCount: typeof useResultRowCount;
     180              :     useQueryResultRowCounts: typeof useQueryResultRowCounts;
     181              :     QueryResultToolbar: typeof QueryResultToolbar;
     182              :     QueryResultDownloadButton: typeof QueryResultDownloadButton;
     183              :     QueryResultActionMenu: typeof QueryResultActionMenu;
     184              :     ChartDownloadButton: typeof ChartDownloadButton;
     185              :     QueryResultView: typeof QueryResultView;
     186              : }
     187              : 
     188            1 : const DEFAULT_DEPENDENCIES: FeedEntryFooterDependencies = {
     189            1 :     useResultRowCount,
     190            1 :     useQueryResultRowCounts,
     191            1 :     QueryResultToolbar,
     192            1 :     QueryResultDownloadButton,
     193            1 :     QueryResultActionMenu,
     194            1 :     ChartDownloadButton,
     195            1 :     QueryResultView,
     196            1 : };
     197              : 
     198              : interface FeedEntryFooterProps {
     199              :     sessionId: string;
     200              :     /// The latest query execution for this script.
     201              :     queryState: QueryExecutionState | null;
     202              :     visualizeQuery: ResolvedVisualizeQuery | null;
     203              :     /// The tab to show, picked in the tab strip of the entry status bar
     204              :     selectedTab: FeedEntryTab;
     205              :     /// Open the full result table, makes the header and the column names of the preview clickable
     206              :     onShowTable?: () => void;
     207              :     dependencies?: FeedEntryFooterDependencies;
     208              : }
     209              : 
     210            1 : export const FeedEntryFooter: React.FC<FeedEntryFooterProps> = (props) => {
     211            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
     212            1 :     const { totalRows } = dependencies.useResultRowCount(props.queryState);
     213            1 :     const queryTraceId = props.queryState?.traceId ?? null;
     214              : 
     215              :     // The visualization renders the full cloud (no feed row cap), so the header just shows the
     216              :     // total row count.
     217            1 :     const pointCountDetail = formatRowCountDetail(totalRows);
     218              : 
     219            1 :     switch (props.selectedTab) {
     220            1 :         case FeedEntryTab.Progress: return props.queryState == null ? null : (
     221            1 :             <QueryProgressPanel query={props.queryState} showProfile profileHeight={FEED_PROFILE_HEIGHT} />
     222              :         );
     223            1 :         case FeedEntryTab.ExecutionLog: return (
     224            1 :             <TraceLogPanel
     225            1 :                 traceId={queryTraceId}
     226            1 :                 title="Execution Logs"
     227            1 :                 maxRows={FEED_LIMIT_LOG_ROWS}
     228            1 :             />
     229              :         );
     230            1 :         case FeedEntryTab.Table: return props.queryState == null ? null : (
     231            1 :             <>
     232            1 :                 <QueryResultHeader
     233            1 :                     query={props.queryState}
     234            1 :                     maxRows={FEED_LIMIT_RESULT_ROWS}
     235            1 :                     onShowTable={props.onShowTable}
     236            1 :                     dependencies={dependencies}
     237            1 :                 />
     238            1 :                 <dependencies.QueryResultView
     239            1 :                     query={props.queryState}
     240            1 :                     debugMode={false}
     241            1 :                     maxRows={FEED_LIMIT_RESULT_ROWS}
     242            1 :                     columnHeader={TableColumnHeader.OnlyColumnName}
     243            1 :                     cellBackground="transparent"
     244            1 :                     onShowTable={props.onShowTable}
     245            1 :                     compact
     246            1 :                 />
     247            1 :             </>
     248              :         );
     249            1 :         case FeedEntryTab.Visualization: return (
     250            1 :             <div className={styles.visualization_tab}>
     251            1 :                 <TabHeader
     252            1 :                     title="Visualization"
     253            1 :                     detail={pointCountDetail}
     254            1 :                     actions={props.queryState != null && props.visualizeQuery?.renderer === 'vegalite' ? (
     255            1 :                         <dependencies.ChartDownloadButton query={props.queryState} spec={props.visualizeQuery.vegaLiteSpec} />
     256            0 :                     ) : undefined}
     257            1 :                 />
     258            1 :                 {props.queryState != null && (
     259            1 :                     <div className={styles.visualization_body}>
     260            1 :                         <VisualizationDispatch
     261            1 :                             query={props.queryState}
     262            1 :                             visualizeQuery={props.visualizeQuery}
     263            1 :                             height={FEED_VISUALIZATION_HEIGHT}
     264            1 :                         />
     265            1 :                     </div>
     266              :                 )}
     267            1 :             </div>
     268              :         );
     269            1 :     }
     270            1 : };
        

Generated by: LCOV version 2.0-1