LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - query_result_action_menu.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 90 90
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 * as styles from '../../app/action_menu.module.css';
       4              : 
       5              : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
       6              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       7              : import * as ActionList from '../../ui/foundations/action_list.js';
       8              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
       9              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
      10              : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      11              : import { useAppConfig } from '../../config/app_config.js';
      12              : import { SET_SCRIPT_TEXT, scriptResultOwner, type ScriptData, type SessionScripts } from '../../session/scripts/session_scripts.js';
      13              : import { useSessionScriptsDispatch, useSessionScriptsRegistryReader } from '../../session/scripts/session_scripts_registry.js';
      14              : import { createScriptFormatConfig, formattingDialectForConnector, getKeywordCase } from '../../session/scripts/script_format.js';
      15              : import { addResultProjection, ResultProjectionError } from './result_projection.js';
      16              : 
      17            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
      18            1 : const ProjectionIcon = SymbolIcon('columns_16');
      19              : 
      20            1 : const PROJECTION_ERRORS: Record<ResultProjectionError, string> = {
      21            1 :     [ResultProjectionError.NotParsed]: 'The script does not parse',
      22            1 :     [ResultProjectionError.NoQuery]: 'The last statement is no query',
      23            1 :     [ResultProjectionError.NoColumns]: 'The result has no columns',
      24            1 :     [ResultProjectionError.DuplicateColumns]: 'Two columns have the same name',
      25            1 : };
      26              : 
      27            1 : function isProjectionError(text: string | ResultProjectionError): text is ResultProjectionError {
      28            1 :     return Object.prototype.hasOwnProperty.call(PROJECTION_ERRORS, text);
      29            1 : }
      30              : 
      31              : /// The script of a session that owns a query result
      32            1 : function findResultScript(sessionScripts: SessionScripts, owner: string): ScriptData | null {
      33            1 :     for (const script of Object.values(sessionScripts.scripts)) {
      34            1 :         if (scriptResultOwner(sessionScripts.sessionId, script.scriptKey) === owner) return script;
      35              :     }
      36            1 :     return null;
      37            1 : }
      38              : 
      39              : interface Props {
      40              :     query: QueryExecutionState;
      41              : }
      42              : 
      43              : /// The actions of a query result that rewrite the script of the result. Results without a script, e.g. of the
      44              : /// shell or the catalog, have no menu.
      45            1 : export function QueryResultActionMenu({ query }: Props): React.ReactElement | null {
      46            1 :     const readRegistry = useSessionScriptsRegistryReader();
      47            1 :     const dispatchSessionScripts = useSessionScriptsDispatch();
      48            1 :     const config = useAppConfig();
      49            1 :     const [open, setOpen] = React.useState(false);
      50              :     // The rewrite is computed when the menu opens, the item says why a script cannot get it
      51            1 :     const [projection, setProjection] = React.useState<{ scriptKey: number; text: string } | ResultProjectionError | null>(null);
      52            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
      53              : 
      54            1 :     const sessionId = query.queryMetadata.sessionId;
      55            1 :     const owner = query.queryMetadata.resultOwner;
      56            1 :     if (sessionId == null || owner == null) return null;
      57            1 :     const hasResult = query.status === QueryExecutionStatus.SUCCEEDED && query.resultTable != null;
      58              : 
      59            1 :     const openMenu = () => {
      60            1 :         setProjection(null);
      61            1 :         setOpen(true);
      62            1 :         const sessionScripts = readRegistry().sessionScriptsMap.get(sessionId);
      63            1 :         const script = sessionScripts == null ? null : findResultScript(sessionScripts, owner);
      64            1 :         if (sessionScripts == null || script == null || query.resultTable == null) return;
      65            1 :         const formatConfig = createScriptFormatConfig(
      66            1 :             dashql.buffers.formatting.FormattingMode.PRETTY,
      67            1 :             false,
      68            1 :             undefined,
      69            1 :             getKeywordCase(config?.settings),
      70            1 :             formattingDialectForConnector(script.connection.connectorInfo.connectorType),
      71            1 :         );
      72            1 :         const columnNames = query.resultTable.schema.fields.map(field => field.name);
      73            1 :         const text = addResultProjection(sessionScripts.instance, script.scriptSession.getText(), columnNames, formatConfig);
      74              :         // The errors are strings as well
      75            1 :         setProjection(isProjectionError(text) ? text : { scriptKey: script.scriptKey, text });
      76            1 :     };
      77            1 :     const applyProjection = () => {
      78            1 :         setOpen(false);
      79            1 :         if (projection == null || typeof projection !== 'object') return;
      80              :         // Staged as a diff like the formatting, the user accepts or rejects it in the editor
      81            1 :         void dispatchSessionScripts(sessionId, {
      82            1 :             type: SET_SCRIPT_TEXT,
      83            1 :             value: { scriptKey: projection.scriptKey, text: projection.text, withDiff: true },
      84            1 :         });
      85            1 :     };
      86            1 :     const projectionError = projection != null && typeof projection !== 'object' ? PROJECTION_ERRORS[projection] : null;
      87              : 
      88            1 :     return (
      89            1 :         <AnchoredOverlay
      90            1 :             open={open}
      91            1 :             onOpen={openMenu}
      92            1 :             onClose={() => setOpen(false)}
      93            1 :             side={AnchorSide.OutsideBottom}
      94            1 :             align={AnchorAlignment.End}
      95            1 :             anchorOffset={4}
      96            1 :             anchorRef={triggerRef}
      97            1 :             returnFocusRef={triggerRef}
      98            1 :             renderAnchor={(anchorProps) => (
      99            1 :                 <IconButton
     100            1 :                     {...anchorProps}
     101            1 :                     ref={triggerRef}
     102            1 :                     variant={ButtonVariant.Invisible}
     103            1 :                     size={ButtonSize.Small}
     104            1 :                     aria-label="More actions for the query result"
     105            1 :                     data-testid="query-result-more-actions"
     106            1 :                     disabled={!hasResult}
     107              :                 >
     108            1 :                     <MoreIcon size={16} />
     109            1 :                 </IconButton>
     110              :             )}
     111              :         >
     112            1 :             <div className={styles.menu} role="dialog" aria-label="Query result actions">
     113            1 :                 <ActionList.List aria-label="Query result actions">
     114            1 :                     <ActionList.ListItem
     115            1 :                         data-testid="query-result-project-columns"
     116            1 :                         disabled={projection == null || typeof projection !== 'object'}
     117            1 :                         onClick={applyProjection}
     118              :                     >
     119            1 :                         <ActionList.Leading><ProjectionIcon size={16} /></ActionList.Leading>
     120            1 :                         <ActionList.ItemText>
     121            1 :                             <ActionList.ItemTextTitle>Project Columns</ActionList.ItemTextTitle>
     122            1 :                             {projectionError != null ? <ActionList.ItemTextDescription>{projectionError}</ActionList.ItemTextDescription> : undefined}
     123            1 :                         </ActionList.ItemText>
     124            1 :                     </ActionList.ListItem>
     125            1 :                 </ActionList.List>
     126            1 :             </div>
     127            1 :         </AnchoredOverlay>
     128              :     );
     129            1 : }
        

Generated by: LCOV version 2.0-1