LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_action_menu.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 96.6 % 149 144
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 { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       6              : import * as ActionList from '../../ui/foundations/action_list.js';
       7              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
       8              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
       9              : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
      10              : import { type RowContextMenu, useRowContextMenuOpener } from '../../ui/foundations/row_context_menu.js';
      11              : 
      12              : /// Where a script is stored
      13              : export interface ScriptStorage {
      14              :     /// The file name of the script, e.g. "01_extract.sql"
      15              :     fileName: string;
      16              :     /// The full path of the script file, resolved when the storage view opens
      17              :     resolvePath: () => string;
      18              :     /// Rename the script file
      19              :     onRename: (name: string) => void;
      20              : }
      21              : 
      22              : interface Props {
      23              :     scriptName: string;
      24              :     formatDisabled: boolean;
      25              :     onFormat: (mode: dashql.buffers.formatting.FormattingMode) => void;
      26              :     onDelete: () => void;
      27              :     /// Edit the name of the script in place, the menu offers a rename action
      28              :     onRename?: () => void;
      29              :     /// Add a script above or below the script, the menu offers the add actions
      30              :     onAddAbove?: () => void;
      31              :     onAddBelow?: () => void;
      32              :     /// The storage of the script, the menu offers its storage view
      33              :     storage?: ScriptStorage;
      34              :     /// The size of the icon of the trigger, 16 by default
      35              :     iconSize?: number;
      36              :     /// The right click of the row that opens the menu
      37              :     contextMenu?: RowContextMenu;
      38              : }
      39              : 
      40            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
      41            1 : const AddIcon = SymbolIcon('plus_16');
      42            1 : const FormatIcon = SymbolIcon('pencil_ai_16');
      43            1 : const RenameIcon = SymbolIcon('pencil_16');
      44            1 : const TrashIcon = SymbolIcon('trash_16');
      45            1 : const StorageIcon = SymbolIcon('file_directory_16');
      46            1 : const CloseIcon = SymbolIcon('x_16');
      47              : 
      48            1 : export const ScriptActionMenu: React.FC<Props> = (props) => {
      49            1 :     const [open, setOpen] = React.useState(false);
      50              :     // The menu switches to the storage view in place
      51            1 :     const [view, setView] = React.useState<'actions' | 'storage'>('actions');
      52            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
      53            1 :     useRowContextMenuOpener(props.contextMenu, () => { setView('actions'); setOpen(true); });
      54            1 :     const run = React.useCallback((action: () => void) => {
      55            1 :         setOpen(false);
      56            1 :         action();
      57            1 :     }, []);
      58              : 
      59            1 :     return (
      60            1 :         <AnchoredOverlay
      61            1 :             open={open}
      62            1 :             onOpen={() => { setView('actions'); setOpen(true); }}
      63            1 :             onClose={() => setOpen(false)}
      64            1 :             side={AnchorSide.OutsideBottom}
      65            1 :             align={AnchorAlignment.End}
      66            1 :             anchorOffset={4}
      67            1 :             anchorRef={triggerRef}
      68            1 :             returnFocusRef={triggerRef}
      69            1 :             renderAnchor={(anchorProps) => (
      70            1 :                 <IconButton
      71            1 :                     {...anchorProps}
      72            1 :                     ref={triggerRef}
      73            1 :                     data-testid="script-more-actions"
      74            1 :                     variant={ButtonVariant.Invisible}
      75            1 :                     size={ButtonSize.Small}
      76            1 :                     aria-label={`More actions for ${props.scriptName} script`}
      77              :                 >
      78            1 :                     <MoreIcon size={props.iconSize ?? 16} />
      79            1 :                 </IconButton>
      80              :             )}
      81              :         >
      82            1 :             {view === 'storage' && props.storage != null ? (
      83            1 :                 <ScriptStorageView scriptName={props.scriptName} storage={props.storage} onClose={() => setOpen(false)} />
      84              :             ) : (
      85            1 :             <div className={styles.menu} role="dialog" aria-label={`Actions for ${props.scriptName} script`}>
      86            1 :                 <ActionList.List aria-label={`Actions for ${props.scriptName} script`}>
      87              :                     {/* The list takes elements only, the fragments wrap the optional items */}
      88            1 :                     <>{props.onAddAbove != null && (
      89            1 :                         <ActionList.ListItem data-testid="script-menu-item" data-action="add-above" onClick={() => run(props.onAddAbove!)}>
      90            1 :                             <ActionList.Leading><AddIcon size={16} /></ActionList.Leading>
      91            1 :                             <ActionList.ItemText>Add Above</ActionList.ItemText>
      92            1 :                         </ActionList.ListItem>
      93            1 :                     )}</>
      94            1 :                     <>{props.onAddBelow != null && (
      95            1 :                         <ActionList.ListItem data-testid="script-menu-item" data-action="add-below" onClick={() => run(props.onAddBelow!)}>
      96            1 :                             <ActionList.Leading><AddIcon size={16} /></ActionList.Leading>
      97            1 :                             <ActionList.ItemText>Add Below</ActionList.ItemText>
      98            1 :                         </ActionList.ListItem>
      99            1 :                     )}</>
     100            1 :                     <ActionList.ListItem
     101            1 :                         data-testid="script-menu-item"
     102            1 :                         data-action="format-pretty"
     103            1 :                         disabled={props.formatDisabled}
     104            1 :                         onClick={() => run(() => props.onFormat(dashql.buffers.formatting.FormattingMode.PRETTY))}
     105              :                     >
     106            1 :                         <ActionList.Leading><FormatIcon size={16} /></ActionList.Leading>
     107            1 :                         <ActionList.ItemText>Format Pretty</ActionList.ItemText>
     108            1 :                     </ActionList.ListItem>
     109            1 :                     <ActionList.ListItem
     110            1 :                         data-testid="script-menu-item"
     111            1 :                         data-action="format-compact"
     112            1 :                         disabled={props.formatDisabled}
     113            1 :                         onClick={() => run(() => props.onFormat(dashql.buffers.formatting.FormattingMode.COMPACT))}
     114              :                     >
     115            1 :                         <ActionList.Leading><FormatIcon size={16} /></ActionList.Leading>
     116            1 :                         <ActionList.ItemText>Format Compact</ActionList.ItemText>
     117            1 :                     </ActionList.ListItem>
     118            1 :                     <>{props.onRename != null && (
     119            1 :                         <ActionList.ListItem data-testid="script-menu-item" data-action="rename" onClick={() => run(props.onRename!)}>
     120            1 :                             <ActionList.Leading><RenameIcon size={16} /></ActionList.Leading>
     121            1 :                             <ActionList.ItemText>Rename</ActionList.ItemText>
     122            1 :                         </ActionList.ListItem>
     123            1 :                     )}</>
     124            1 :                     <>{props.storage != null && (
     125            1 :                         <ActionList.ListItem data-testid="script-menu-item" data-action="storage" onClick={() => setView('storage')}>
     126            1 :                             <ActionList.Leading><StorageIcon size={16} /></ActionList.Leading>
     127            1 :                             <ActionList.ItemText>Storage</ActionList.ItemText>
     128            1 :                         </ActionList.ListItem>
     129            1 :                     )}</>
     130            1 :                     <ActionList.ListItem
     131            1 :                         data-testid="script-menu-item"
     132            1 :                         data-action="delete"
     133            1 :                         className={styles.delete_action}
     134            1 :                         onClick={() => run(props.onDelete)}
     135              :                     >
     136            1 :                         <ActionList.Leading><TrashIcon size={16} /></ActionList.Leading>
     137            1 :                         <ActionList.ItemText>Delete</ActionList.ItemText>
     138            1 :                     </ActionList.ListItem>
     139            1 :                 </ActionList.List>
     140            1 :             </div>
     141              :             )}
     142            1 :         </AnchoredOverlay>
     143              :     );
     144            1 : };
     145              : 
     146              : /// Where a script is stored, the file name can be edited
     147            1 : function ScriptStorageView(props: { scriptName: string; storage: ScriptStorage; onClose: () => void }) {
     148            1 :     const path = React.useMemo(() => props.storage.resolvePath(), [props.storage]);
     149            1 :     const inputRef = React.useRef<HTMLInputElement>(null);
     150            1 :     const skipBlurCommitRef = React.useRef(false);
     151            1 :     React.useEffect(() => {
     152            1 :         if (inputRef.current != null) inputRef.current.value = props.storage.fileName;
     153            1 :     }, [props.storage.fileName]);
     154            1 :     const commit = (input: HTMLInputElement) => {
     155            1 :         const name = input.value.trim();
     156            1 :         if (name && name !== props.storage.fileName) props.storage.onRename(name);
     157            1 :     };
     158            1 :     const commitOnBlur = (event: React.FocusEvent<HTMLInputElement>) => {
     159            0 :         if (!skipBlurCommitRef.current) commit(event.currentTarget);
     160            0 :     };
     161              :     // Drop the focus without a commit. blur() fires its event synchronously, and none at all while the
     162              :     // window lacks focus, so the keys cannot leave it to the event to commit or to reset the flag.
     163            1 :     const blurWithoutCommit = (input: HTMLInputElement) => {
     164            1 :         skipBlurCommitRef.current = true;
     165            1 :         input.blur();
     166            1 :         skipBlurCommitRef.current = false;
     167            1 :     };
     168            1 :     const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
     169            1 :         if (event.key === 'Enter') {
     170            1 :             commit(event.currentTarget);
     171            1 :             blurWithoutCommit(event.currentTarget);
     172            1 :         } else if (event.key === 'Escape') {
     173              :             // Abandon the edit
     174            0 :             event.currentTarget.value = props.storage.fileName;
     175            0 :             blurWithoutCommit(event.currentTarget);
     176            0 :         }
     177            1 :         event.stopPropagation();
     178            1 :     };
     179            1 :     return (
     180            1 :         <div className={styles.storage} role="dialog" aria-label={`Storage of ${props.scriptName} script`} data-testid="script-storage">
     181            1 :             <div className={styles.storage_header}>
     182            1 :                 <span className={styles.storage_title}>Script Storage</span>
     183            1 :                 <IconButton variant={ButtonVariant.Invisible} size={ButtonSize.Small} aria-label="Close script storage" data-testid="script-storage-close" onClick={props.onClose}>
     184            1 :                     <CloseIcon size={16} />
     185            1 :                 </IconButton>
     186            1 :             </div>
     187            1 :             <div className={styles.storage_body}>
     188            1 :                 <label className={styles.storage_label} htmlFor={`script-file-${props.storage.fileName}`}>File</label>
     189            1 :                 <input
     190            1 :                     id={`script-file-${props.storage.fileName}`}
     191            1 :                     ref={inputRef}
     192            1 :                     className={styles.storage_input}
     193            1 :                     type="text"
     194            1 :                     aria-label="Script file name"
     195            1 :                     data-testid="script-file-name-input"
     196            1 :                     defaultValue={props.storage.fileName}
     197            1 :                     spellCheck={false}
     198            1 :                     onBlur={commitOnBlur}
     199            1 :                     onKeyDown={onKeyDown}
     200            1 :                     onPointerDown={event => event.stopPropagation()}
     201            1 :                 />
     202            1 :                 <span className={styles.storage_label}>Path</span>
     203            1 :                 <span className={styles.storage_path} title={path}>{path}</span>
     204            1 :             </div>
     205            1 :         </div>
     206              :     );
     207            1 : }
        

Generated by: LCOV version 2.0-1