LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - query_result_action_menu.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 100.0 % 93 93
Test Date: 2026-11-09 20:22:13

            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. The standalone shell also has no storage or scripts, so the hooks that need
      45              : /// them must not run for its results.
      46            1 : export function QueryResultActionMenu({ query }: Props): React.ReactElement | null {
      47            1 :     const sessionId = query.queryMetadata.sessionId;
      48            1 :     const owner = query.queryMetadata.resultOwner;
      49            1 :     if (sessionId == null || owner == null) return null;
      50            1 :     return <ScriptResultActionMenu query={query} sessionId={sessionId} owner={owner} />;
      51            1 : }
      52              : 
      53              : interface ScriptResultProps extends Props {
      54              :     sessionId: string;
      55              :     owner: string;
      56              : }
      57              : 
      58            1 : function ScriptResultActionMenu({ query, sessionId, owner }: ScriptResultProps): React.ReactElement {
      59            1 :     const readRegistry = useSessionScriptsRegistryReader();
      60            1 :     const dispatchSessionScripts = useSessionScriptsDispatch();
      61            1 :     const config = useAppConfig();
      62            1 :     const [open, setOpen] = React.useState(false);
      63              :     // The rewrite is computed when the menu opens, the item says why a script cannot get it
      64            1 :     const [projection, setProjection] = React.useState<{ scriptKey: number; text: string } | ResultProjectionError | null>(null);
      65            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
      66              : 
      67            1 :     const hasResult = query.status === QueryExecutionStatus.SUCCEEDED && query.resultTable != null;
      68              : 
      69            1 :     const openMenu = () => {
      70            1 :         setProjection(null);
      71            1 :         setOpen(true);
      72            1 :         const sessionScripts = readRegistry().sessionScriptsMap.get(sessionId);
      73            1 :         const script = sessionScripts == null ? null : findResultScript(sessionScripts, owner);
      74            1 :         if (sessionScripts == null || script == null || query.resultTable == null) return;
      75            1 :         const formatConfig = createScriptFormatConfig(
      76            1 :             dashql.buffers.formatting.FormattingMode.PRETTY,
      77            1 :             false,
      78            1 :             undefined,
      79            1 :             getKeywordCase(config?.settings),
      80            1 :             formattingDialectForConnector(script.connection.connectorInfo.connectorType),
      81            1 :         );
      82            1 :         const columnNames = query.resultTable.schema.fields.map(field => field.name);
      83            1 :         const text = addResultProjection(sessionScripts.instance, script.scriptSession.getText(), columnNames, formatConfig);
      84              :         // The errors are strings as well
      85            1 :         setProjection(isProjectionError(text) ? text : { scriptKey: script.scriptKey, text });
      86            1 :     };
      87            1 :     const applyProjection = () => {
      88            1 :         setOpen(false);
      89            1 :         if (projection == null || typeof projection !== 'object') return;
      90              :         // Staged as a diff like the formatting, the user accepts or rejects it in the editor
      91            1 :         void dispatchSessionScripts(sessionId, {
      92            1 :             type: SET_SCRIPT_TEXT,
      93            1 :             value: { scriptKey: projection.scriptKey, text: projection.text, withDiff: true },
      94            1 :         });
      95            1 :     };
      96            1 :     const projectionError = projection != null && typeof projection !== 'object' ? PROJECTION_ERRORS[projection] : null;
      97              : 
      98            1 :     return (
      99            1 :         <AnchoredOverlay
     100            1 :             open={open}
     101            1 :             onOpen={openMenu}
     102            1 :             onClose={() => setOpen(false)}
     103            1 :             side={AnchorSide.OutsideBottom}
     104            1 :             align={AnchorAlignment.End}
     105            1 :             anchorOffset={4}
     106            1 :             anchorRef={triggerRef}
     107            1 :             returnFocusRef={triggerRef}
     108            1 :             renderAnchor={(anchorProps) => (
     109            1 :                 <IconButton
     110            1 :                     {...anchorProps}
     111            1 :                     ref={triggerRef}
     112            1 :                     variant={ButtonVariant.Invisible}
     113            1 :                     size={ButtonSize.Small}
     114            1 :                     aria-label="More actions for the query result"
     115            1 :                     data-testid="query-result-more-actions"
     116            1 :                     disabled={!hasResult}
     117              :                 >
     118            1 :                     <MoreIcon size={16} />
     119            1 :                 </IconButton>
     120              :             )}
     121              :         >
     122            1 :             <div className={styles.menu} role="dialog" aria-label="Query result actions">
     123            1 :                 <ActionList.List aria-label="Query result actions">
     124            1 :                     <ActionList.ListItem
     125            1 :                         data-testid="query-result-project-columns"
     126            1 :                         disabled={projection == null || typeof projection !== 'object'}
     127            1 :                         onClick={applyProjection}
     128              :                     >
     129            1 :                         <ActionList.Leading><ProjectionIcon size={16} /></ActionList.Leading>
     130            1 :                         <ActionList.ItemText>
     131            1 :                             <ActionList.ItemTextTitle>Project Columns</ActionList.ItemTextTitle>
     132            1 :                             {projectionError != null ? <ActionList.ItemTextDescription>{projectionError}</ActionList.ItemTextDescription> : undefined}
     133            1 :                         </ActionList.ItemText>
     134            1 :                     </ActionList.ListItem>
     135            1 :                 </ActionList.List>
     136            1 :             </div>
     137            1 :         </AnchoredOverlay>
     138              :     );
     139            1 : }
        

Generated by: LCOV version 2.0-1