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

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : import * as React from 'react';
       3              : import * as styles from './query_result_header.module.css';
       4              : 
       5              : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
       6              : import { TabHeader } from '../../session/tab_header.js';
       7              : import { QueryResultView } from './query_result_view.js';
       8              : import { TableColumnHeader } from './data_table_cell.js';
       9              : import { QueryResultToolbar, useQueryResultRowCounts } from './query_result_toolbar.js';
      10              : import { QueryResultDownloadButton } from './query_result_download_button.js';
      11              : import { QueryResultActionMenu } from './query_result_action_menu.js';
      12              : import { fittingRowCount } from './data_table.js';
      13              : import { QueryStatusPlaceholder } from '../card_placeholder.js';
      14              : import { formatBytes } from '../../utils/format.js';
      15              : 
      16              : export interface QueryResultHeaderDependencies {
      17              :     useQueryResultRowCounts: typeof useQueryResultRowCounts;
      18              :     QueryResultToolbar: typeof QueryResultToolbar;
      19              :     QueryResultDownloadButton: typeof QueryResultDownloadButton;
      20              :     QueryResultActionMenu: typeof QueryResultActionMenu;
      21              : }
      22              : 
      23            1 : const DEFAULT_HEADER_DEPENDENCIES: QueryResultHeaderDependencies = {
      24            1 :     useQueryResultRowCounts,
      25            1 :     QueryResultToolbar,
      26            1 :     QueryResultDownloadButton,
      27            1 :     QueryResultActionMenu,
      28            1 : };
      29              : 
      30              : /// The row count of a result table: "3 rows", "2 of 3 rows" while searching, "6 of 1000 rows" for a
      31              : /// preview that shows at most `maxRows` rows. Null while the total is unknown.
      32            1 : export function formatResultRowCount(counts: ReturnType<typeof useQueryResultRowCounts>, maxRows: number = Infinity): string | null {
      33            1 :     const { totalRows, currentRows, matchingRows } = counts;
      34            1 :     if (totalRows == null) return null;
      35            1 :     if (matchingRows != null) {
      36            1 :         return matchingRows > maxRows
      37            1 :             ? `${maxRows} of ${matchingRows} matching rows`
      38            1 :             : `${matchingRows} of ${currentRows ?? totalRows} rows`;
      39              :     }
      40            1 :     return totalRows > maxRows
      41            1 :         ? `${maxRows} of ${totalRows} rows`
      42            1 :         : `${totalRows} ${totalRows === 1 ? 'row' : 'rows'}`;
      43            1 : }
      44              : 
      45              : /// The bytes of a result table, weakly keyed by the table so a size never outlives it.
      46            1 : const resultTableBytes = new WeakMap<arrow.Table, number>();
      47              : 
      48              : /// The bytes the buffers of a result table hold in memory. Sums the buffer sizes of every column of every batch without
      49              : /// reading a row, and adds every dictionary once since the batches of a column share it.
      50            1 : export function getResultTableBytes(table: arrow.Table): number {
      51            1 :     const cached = resultTableBytes.get(table);
      52            1 :     if (cached !== undefined) return cached;
      53            1 :     const dictionaries = new Set<arrow.Vector>();
      54            1 :     const collectDictionaries = (data: arrow.Data) => {
      55            1 :         if (data.dictionary != null) dictionaries.add(data.dictionary);
      56            1 :         for (const child of data.children) collectDictionaries(child);
      57            1 :     };
      58            1 :     let bytes = 0;
      59            1 :     for (const batch of table.batches) {
      60            1 :         bytes += batch.data.byteLength;
      61            1 :         collectDictionaries(batch.data);
      62            1 :     }
      63            1 :     for (const dictionary of dictionaries) {
      64            1 :         for (const data of dictionary.data) bytes += data.byteLength;
      65              :     }
      66            1 :     resultTableBytes.set(table, bytes);
      67            1 :     return bytes;
      68            1 : }
      69              : 
      70              : /// The detail of a result header: the row count followed by the size of the result table, "3 rows, 1.2 kB".
      71              : /// Null while the row count is unknown, only the row count while the result table is not there yet.
      72            1 : export function formatResultDetail(rowCount: string | null, resultTable: arrow.Table | null): string | null {
      73            1 :     if (rowCount == null || resultTable == null) return rowCount;
      74            1 :     return `${rowCount}, ${formatBytes(getResultTableBytes(resultTable))}`;
      75            1 : }
      76              : 
      77              : interface HeaderProps {
      78              :     query: QueryExecutionState;
      79              :     /// The rows the table below shows at most, a preview of more rows counts as "6 of 1000 rows"
      80              :     maxRows?: number;
      81              :     /// Open the full result table, makes the header clickable
      82              :     onShowTable?: () => void;
      83              :     dependencies?: QueryResultHeaderDependencies;
      84              : }
      85              : 
      86              : /// The header over a result table: the title, the row count, the search, the download and the actions.
      87              : /// The feed entries, the dashboard cards and the dataflow boxes show the same header.
      88            1 : export function QueryResultHeader(props: HeaderProps): React.ReactElement {
      89            1 :     const dependencies = props.dependencies ?? DEFAULT_HEADER_DEPENDENCIES;
      90            1 :     const counts = dependencies.useQueryResultRowCounts(props.query);
      91            1 :     const maxRows = props.maxRows ?? Infinity;
      92            1 :     const displayedRows = counts.matchingRows ?? counts.totalRows;
      93            1 :     return (
      94            1 :         <TabHeader
      95            1 :             title="Query Results"
      96            1 :             detail={formatResultDetail(formatResultRowCount(counts, maxRows), props.query.resultTable)}
      97            1 :             onClick={props.onShowTable}
      98            1 :             detailIsLink={displayedRows != null && displayedRows > maxRows}
      99            1 :             actions={(
     100            1 :                 <>
     101            1 :                     <dependencies.QueryResultToolbar query={props.query} />
     102            1 :                     <dependencies.QueryResultDownloadButton query={props.query} />
     103            1 :                     <dependencies.QueryResultActionMenu query={props.query} />
     104            1 :                 </>
     105              :             )}
     106            1 :         />
     107              :     );
     108            1 : }
     109              : 
     110              : interface PanelProps {
     111              :     query: QueryExecutionState | null;
     112              :     cellBackground?: string;
     113              :     /// Open the full result table, makes the row count in the header and the column names clickable
     114              :     onShowTable?: () => void;
     115              :     dependencies?: QueryResultPanelDependencies;
     116              : }
     117              : 
     118              : export interface QueryResultPanelDependencies extends QueryResultHeaderDependencies {
     119              :     QueryResultView: typeof QueryResultView;
     120              : }
     121              : 
     122            1 : const DEFAULT_PANEL_DEPENDENCIES: QueryResultPanelDependencies = {
     123            1 :     ...DEFAULT_HEADER_DEPENDENCIES,
     124            1 :     QueryResultView,
     125            1 : };
     126              : 
     127              : /// A result table with its header that fills its host, for the table renderer of a script in the dashboard and the dataflow.
     128              : /// The table shows the rows that fit into the host and does not scroll, like the preview of a feed entry. It fills the host,
     129              : /// a last row that does not fit is cut off at the bottom.
     130            1 : export function QueryResultPanel(props: PanelProps): React.ReactElement {
     131            1 :     const dependencies = props.dependencies ?? DEFAULT_PANEL_DEPENDENCIES;
     132            1 :     const bodyRef = React.useRef<HTMLDivElement | null>(null);
     133            1 :     const [bodyHeight, setBodyHeight] = React.useState<number | null>(null);
     134              :     // Measured before the first paint, the table never shows more rows than fit
     135            1 :     React.useLayoutEffect(() => {
     136            1 :         const body = bodyRef.current;
     137            1 :         if (body == null) return;
     138            1 :         setBodyHeight(body.clientHeight);
     139            1 :         const observer = new ResizeObserver(entries => setBodyHeight(entries[0]?.contentRect.height ?? body.clientHeight));
     140            1 :         observer.observe(body);
     141            1 :         return () => observer.disconnect();
     142            1 :     }, []);
     143            1 :     const maxRows = bodyHeight == null ? 0 : fittingRowCount(bodyHeight);
     144            1 :     return (
     145            1 :         <div className={styles.panel}>
     146              :             {/* The header stays while a query runs, like in the feed: the table may show before the query succeeds and must
     147              :                 not lose the height of the header then */}
     148            1 :             {props.query != null && <QueryResultHeader query={props.query} maxRows={maxRows} onShowTable={props.onShowTable} dependencies={dependencies} />}
     149            1 :             <div ref={bodyRef} className={styles.panel_body}>
     150              :                 {/* Before the first run and while a query has no rows yet, the body shows the logo with the status below it */}
     151            1 :                 {props.query == null || (props.query.resultTable == null && props.query.status !== QueryExecutionStatus.SUCCEEDED)
     152            1 :                     ? <QueryStatusPlaceholder query={props.query} />
     153            1 :                     : <dependencies.QueryResultView
     154            1 :                         query={props.query}
     155            1 :                         debugMode={false}
     156            1 :                         maxRows={maxRows}
     157            1 :                         columnHeader={TableColumnHeader.OnlyColumnName}
     158            1 :                         cellBackground={props.cellBackground}
     159            1 :                         onShowTable={props.onShowTable}
     160            1 :                         fitHeight={false}
     161            1 :                         fillHeight
     162            1 :                         compact
     163            1 :                     />}
     164            1 :             </div>
     165            1 :         </div>
     166              :     );
     167            1 : }
        

Generated by: LCOV version 2.0-1