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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './session_feed.module.css';
       3              : import type * as dashql from '@ankoh/dashql-core';
       4              : 
       5              : import type { Icon } from '../../ui/foundations/symbol_icon.js';
       6              : import { GraphIcon, PaperAirplaneIcon, PencilIcon, SquareFillIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
       7              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       8              : import { useRowContextMenu } from '../../ui/foundations/row_context_menu.js';
       9              : import { ConnectionHealth } from '../../connections/connection_state.js';
      10              : import type { ConnectorType } from '../../connections/connector_info.js';
      11              : import { useCancelQuery } from '../../connections/query_executor.js';
      12              : import { queryIsDone, type QueryExecutionState } from '../../query/query_execution_state.js';
      13              : import { ExecuteConnectionGuard, SessionConnectionAttentionContext } from '../session_connection_attention.js';
      14              : import { useSessionExecution } from '../scripts/session_execution_provider.js';
      15              : import type { ScriptData } from '../scripts/session_scripts.js';
      16              : import { scriptDisplayName } from '../scripts/script_types.js';
      17              : import { ScriptDiagnosticsButton } from '../scripts/script_diagnostics.js';
      18              : import { ScriptStatisticsBar } from '../scripts/script_statistics_bar.js';
      19              : import type { ScriptReferenceReport } from '../scripts/script_reference_check.js';
      20              : import { ScriptActionMenu, type ScriptStorage } from '../scripts/script_action_menu.js';
      21              : import { ScriptConnectionPicker, type ScriptConnectionView } from '../scripts/script_connection_picker.js';
      22              : import { SnippetsOverlay } from '../snippets/snippets_overlay.js';
      23              : import { ChartBuilderOverlay } from '../chart_builder/chart_builder_overlay.js';
      24              : import { ScriptMentionTag } from './script_mention_tag.js';
      25              : import { classNames } from '../../utils/classnames.js';
      26              : 
      27            1 : const MoveUpIcon: Icon = SymbolIcon('chevron_up_16');
      28            1 : const MoveDownIcon: Icon = SymbolIcon('chevron_down_16');
      29            1 : const TrashIcon: Icon = SymbolIcon('trash_16');
      30              : 
      31              : export interface FeedEntryHeaderDependencies {
      32              :     useCancelQuery: typeof useCancelQuery;
      33              :     ScriptDiagnosticsButton: typeof ScriptDiagnosticsButton;
      34              :     ScriptStatisticsBar: typeof ScriptStatisticsBar;
      35              :     ChartBuilderOverlay: typeof ChartBuilderOverlay;
      36              : }
      37              : 
      38            1 : export const DEFAULT_FEED_ENTRY_HEADER_DEPENDENCIES: FeedEntryHeaderDependencies = {
      39            1 :     useCancelQuery,
      40            1 :     ScriptDiagnosticsButton,
      41            1 :     ScriptStatisticsBar,
      42            1 :     ChartBuilderOverlay,
      43            1 : };
      44              : 
      45              : export interface FeedEntryHeaderProps {
      46              :     sessionId: string;
      47              :     scriptData: ScriptData | undefined;
      48              :     scriptFileName: string;
      49              :     /// The connection the script runs on, null while it is not registered
      50              :     connection: ScriptConnectionView | null;
      51              :     connectorType: ConnectorType;
      52              :     /// The latest query of the script
      53              :     queryState: QueryExecutionState | null;
      54              :     /// A focused entry shows its run button in the base color
      55              :     focused: boolean;
      56              :     canExecute: boolean;
      57              :     scriptDebugMode: boolean;
      58              :     isFormattable: boolean;
      59              :     /// Formatting needs the editor of the script
      60              :     canFormat: boolean;
      61              :     /// The checked script references of the script
      62              :     scriptReferences: ScriptReferenceReport | null;
      63              :     /// Can a question of the composer target the script?
      64              :     canTarget: boolean;
      65              :     onTarget: () => void;
      66              :     onExecute: () => void;
      67              :     onPickConnection: (connectionId: string) => void;
      68              :     /// Rename the script, the name is the new display name
      69              :     onRename: (name: string) => void;
      70              :     onDelete: () => void;
      71              :     /// Open the script in an editor, an edit button left of the script menu offers it. Views without editors pass it,
      72              :     /// e.g. the dataflow.
      73              :     onEdit?: () => void;
      74              :     onFormat: (mode: dashql.buffers.formatting.FormattingMode) => void;
      75              :     /// Apply a snippet, the snippets button only shows with this handler
      76              :     onApplySnippet?: (text: string) => void;
      77              :     onSetVisSpec: (spec: string) => void;
      78              :     /// The storage path of the script, shown by the action menu
      79              :     storage: ScriptStorage;
      80              :     /// Add a script above or below, in the feed order
      81              :     onAddAbove?: () => void;
      82              :     onAddBelow?: () => void;
      83              :     /// Move the script in the feed order, the buttons only show with these handlers
      84              :     onMoveUp?: () => void;
      85              :     onMoveDown?: () => void;
      86              :     canMoveUp?: boolean;
      87              :     canMoveDown?: boolean;
      88              :     className?: string;
      89              :     dependencies?: FeedEntryHeaderDependencies;
      90              : }
      91              : 
      92              : /// The header of a script entry: run, connection, diagnostics, the name tag and the script actions.
      93              : /// The feed cards, the boxes of the dataflow and the script editor dialog share it.
      94            1 : export function FeedEntryHeader(props: FeedEntryHeaderProps): React.ReactElement {
      95            1 :     const dependencies = props.dependencies ?? DEFAULT_FEED_ENTRY_HEADER_DEPENDENCIES;
      96            1 :     const sessionExecution = useSessionExecution(props.sessionId);
      97            1 :     const cancelQuery = dependencies.useCancelQuery();
      98            1 :     const connectionAttention = React.useContext(SessionConnectionAttentionContext);
      99            1 :     const queryState = props.queryState;
     100            1 :     const queryActive = queryState != null && !queryIsDone(queryState.status);
     101            1 :     const hasPendingDiff = props.scriptData?.pendingDiff != null;
     102            1 :     const connectionId = props.connection?.connectionId ?? props.scriptData?.connection.connectionId ?? null;
     103            1 :     const connectionHealth = props.connection?.connectionHealth ?? null;
     104              :     // A script on a connection that is not connected keeps a greyed play button that pulses the connections in the bar
     105            1 :     const needsConnection = !queryActive && connectionId != null && connectionHealth !== ConnectionHealth.ONLINE;
     106            1 :     const displayName = scriptDisplayName(props.scriptFileName);
     107              :     // Is the name being edited in the tag of the entry? Rename in the script menu starts it
     108            1 :     const [renaming, setRenaming] = React.useState(false);
     109              :     // A right click on the action bar opens the script menu
     110            1 :     const contextMenu = useRowContextMenu();
     111            1 :     const [chartBuilderOpen, setChartBuilderOpen] = React.useState(false);
     112            1 :     const closeChartBuilder = React.useCallback(() => setChartBuilderOpen(false), []);
     113              : 
     114            1 :     return (
     115            1 :         <div className={classNames(styles.feed_entry_action_bar, props.className)} onContextMenu={contextMenu.onContextMenu}>
     116            1 :             <ExecuteConnectionGuard sessionId={props.sessionId} blocked={!queryActive && connectionHealth !== ConnectionHealth.ONLINE}>
     117            1 :             <IconButton
     118            1 :                 className={needsConnection ? styles.feed_entry_execute_offline : props.focused ? styles.feed_entry_execute_focused : styles.feed_entry_execute_unfocused}
     119            1 :                 variant={ButtonVariant.Invisible}
     120            1 :                 size={ButtonSize.Small}
     121            1 :                 aria-label={queryActive ? `Stop ${displayName} query` : `Execute ${displayName} query`}
     122            1 :                 data-testid="script-execute"
     123            1 :                 aria-current={props.focused ? 'true' : undefined}
     124            1 :                 disabled={!queryActive && (sessionExecution.runActive || (needsConnection ? props.scriptData == null : !props.canExecute))}
     125            1 :                 onClick={() => {
     126            1 :                     if (queryActive && props.scriptData?.latestQueryId != null) {
     127            0 :                         if (sessionExecution.busy) sessionExecution.stop();
     128            0 :                         else if (connectionId != null) cancelQuery(connectionId, props.scriptData.latestQueryId);
     129            1 :                     } else if (needsConnection) {
     130            1 :                         connectionAttention?.request(props.sessionId);
     131              :                     } else {
     132            1 :                         props.onExecute();
     133              :                     }
     134            1 :                 }}
     135              :             >
     136            1 :                 {queryActive ? <SquareFillIcon size={12} /> : <PaperAirplaneIcon size={14} />}
     137            1 :             </IconButton>
     138            1 :             </ExecuteConnectionGuard>
     139            1 :             <span className={styles.feed_entry_action_separator} aria-hidden="true" />
     140            1 :             <div className={styles.feed_entry_file_name}>
     141            1 :                 <ScriptConnectionPicker
     142            1 :                     sessionId={props.sessionId}
     143            1 :                     scriptName={displayName}
     144            1 :                     connection={props.connection}
     145            1 :                     disabled={props.scriptData == null || queryActive || sessionExecution.busy}
     146            1 :                     onPick={props.onPickConnection}
     147            1 :                     entryHeader
     148            1 :                 />
     149            1 :             </div>
     150            1 :             {props.scriptDebugMode && props.scriptData != null && (
     151            1 :                 <div className={styles.feed_entry_stats_bar} data-entry-optional="1">
     152            1 :                     <dependencies.ScriptStatisticsBar stats={props.scriptData.statistics} />
     153            1 :                 </div>
     154              :             )}
     155            1 :             {props.scriptData != null && (
     156            1 :                 <dependencies.ScriptDiagnosticsButton
     157            1 :                     scriptData={props.scriptData}
     158            1 :                     isFormattable={props.isFormattable}
     159            1 :                     connectorType={props.connectorType}
     160            1 :                     iconSize={14}
     161            1 :                     references={props.scriptReferences}
     162            1 :                 />
     163              :             )}
     164            1 :             {props.scriptData?.annotations.manual && (
     165            1 :                 <span
     166            1 :                     className={styles.feed_entry_manual}
     167            1 :                     title="Runs only on request (@manual): Execute All and input changes skip this script"
     168            1 :                     data-testid="script-manual"
     169            1 :                 >
     170              :                     manual
     171            1 :                 </span>
     172              :             )}
     173            1 :             <ScriptMentionTag
     174            1 :                 scriptName={displayName}
     175            1 :                 canTarget={props.canTarget}
     176            1 :                 onTarget={props.onTarget}
     177            1 :                 renaming={renaming}
     178            1 :                 onRenamingChange={setRenaming}
     179            1 :                 onRename={props.onRename}
     180            1 :             />
     181            1 :             <IconButton
     182            1 :                 variant={chartBuilderOpen ? ButtonVariant.Default : ButtonVariant.Invisible}
     183            1 :                 size={ButtonSize.Small}
     184            1 :                 onClick={(event) => { event.stopPropagation(); setChartBuilderOpen(true); }}
     185            1 :                 aria-label={`Build a chart for ${displayName}`}
     186            1 :                 aria-haspopup="dialog"
     187            1 :                 aria-expanded={chartBuilderOpen}
     188            1 :                 data-testid="script-chart"
     189            1 :                 data-entry-optional="2"
     190            1 :                 disabled={props.scriptData == null || hasPendingDiff}
     191              :             >
     192            1 :                 <GraphIcon size={14} />
     193            1 :             </IconButton>
     194            1 :             {chartBuilderOpen && props.scriptData != null && (
     195            1 :                 <dependencies.ChartBuilderOverlay
     196            1 :                     scriptName={displayName}
     197            1 :                     visualizeQuery={props.scriptData.annotations.visualizeQuery}
     198            1 :                     queryState={queryState}
     199            1 :                     onChange={props.onSetVisSpec}
     200            1 :                     onClose={closeChartBuilder}
     201            1 :                 />
     202              :             )}
     203            1 :             {props.onApplySnippet != null && (
     204            1 :                 <span className={styles.feed_entry_optional} data-entry-optional="2">
     205            1 :                     <SnippetsOverlay
     206            1 :                         scriptName={displayName}
     207            1 :                         connectorType={props.connectorType}
     208            1 :                         disabled={props.scriptData == null || hasPendingDiff}
     209            1 :                         onApply={props.onApplySnippet}
     210            1 :                         iconSize={14}
     211            1 :                     />
     212            1 :                 </span>
     213              :             )}
     214            1 :             {props.onEdit != null && (
     215            1 :                 <IconButton
     216            1 :                     variant={ButtonVariant.Invisible}
     217            1 :                     size={ButtonSize.Small}
     218            1 :                     onClick={(event) => { event.stopPropagation(); props.onEdit?.(); }}
     219            1 :                     aria-label={`Edit ${displayName} script`}
     220            1 :                     data-testid="script-edit"
     221            1 :                     disabled={props.scriptData == null}
     222              :                 >
     223            1 :                     <PencilIcon size={14} />
     224            1 :                 </IconButton>
     225              :             )}
     226            1 :             <ScriptActionMenu
     227            1 :                 scriptName={displayName}
     228            1 :                 formatDisabled={!props.isFormattable || hasPendingDiff || !props.canFormat}
     229            1 :                 onFormat={props.onFormat}
     230            1 :                 onDelete={props.onDelete}
     231            1 :                 onRename={() => setRenaming(true)}
     232            1 :                 onAddAbove={props.onAddAbove}
     233            1 :                 onAddBelow={props.onAddBelow}
     234            1 :                 storage={props.storage}
     235            1 :                 iconSize={14}
     236            1 :                 contextMenu={contextMenu}
     237            1 :             />
     238            1 :             <span className={styles.feed_entry_action_separator} aria-hidden="true" data-entry-optional="1" />
     239            1 :             <IconButton
     240            1 :                 variant={ButtonVariant.Invisible}
     241            1 :                 size={ButtonSize.Small}
     242            1 :                 onClick={(event) => { event.stopPropagation(); props.onDelete(); }}
     243            1 :                 aria-label={`Delete ${displayName} script`}
     244            1 :                 data-testid="script-delete"
     245            1 :                 data-entry-optional="1"
     246              :             >
     247            1 :                 <TrashIcon size={14} />
     248            1 :             </IconButton>
     249            1 :             {props.onMoveUp != null && (
     250            1 :                 <IconButton
     251            1 :                     variant={ButtonVariant.Invisible}
     252            1 :                     size={ButtonSize.Small}
     253            1 :                     onClick={(event) => { event.stopPropagation(); props.onMoveUp?.(); }}
     254            1 :                     aria-label="Move script up"
     255            1 :                     data-testid="script-move-up"
     256            1 :                     disabled={!props.canMoveUp}
     257              :                 >
     258            1 :                     <MoveUpIcon size={14} />
     259            1 :                 </IconButton>
     260              :             )}
     261            1 :             {props.onMoveDown != null && (
     262            1 :                 <IconButton
     263            1 :                     variant={ButtonVariant.Invisible}
     264            1 :                     size={ButtonSize.Small}
     265            1 :                     onClick={(event) => { event.stopPropagation(); props.onMoveDown?.(); }}
     266            1 :                     aria-label="Move script down"
     267            1 :                     data-testid="script-move-down"
     268            1 :                     disabled={!props.canMoveDown}
     269              :                 >
     270            1 :                     <MoveDownIcon size={14} />
     271            1 :                 </IconButton>
     272              :             )}
     273            1 :         </div>
     274              :     );
     275            1 : }
        

Generated by: LCOV version 2.0-1