LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - script_connection_picker.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 80.9 % 178 144
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as core from '@ankoh/dashql-core';
       3              : import * as styles from './script_connection_picker.module.css';
       4              : import symbols from '@ankoh/dashql-svg-symbols';
       5              : 
       6              : import { Button, ButtonSize, ButtonVariant } 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 { ConnectionHealth, type ConnectionState } from '../../connections/connection_state.js';
      11              : import { CONNECTOR_INFOS, ConnectorType, type ConnectorInfo } from '../../connections/connector_info.js';
      12              : import { ATTACHABLE_CONNECTORS, useConnectionAttach } from '../../connections/attached_connection.js';
      13              : import { ConnectionSettingsOverlay } from '../../connections/connection_settings_overlay.js';
      14              : import {
      15              :     resolveSessionConnections,
      16              :     useConnectionReferences,
      17              :     useConnectionRegistry,
      18              :     useConnectionRegistryStore,
      19              : } from '../../connections/connection_registry.js';
      20              : import { scriptConnectionOf, SET_PENDING_CONNECTION, SET_SCRIPT_CONNECTION } from './session_scripts.js';
      21              : import type { ModifySessionScripts } from './session_scripts_registry.js';
      22              : import { CONNECTION_HEALTH_COLORS } from '../../connections/connection_status.js';
      23              : import { connectionLabelParts, connectionLabelText, connectionOrdinals, type ConnectionLabelParts } from '../../connections/connection_label.js';
      24              : import { ConnectionLabelView } from '../../connections/connection_label_view.js';
      25              : import { useSessionScriptsRegistry } from './session_scripts_registry.js';
      26              : import { listedConnections } from '../../connections/connection_tree.js';
      27              : import { CONNECTOR_ICON_STYLE, useConnectorIcon } from '../../connections/connector_icon.js';
      28              : 
      29              : /// The fields of a connection that a script shows
      30              : export interface ScriptConnectionView {
      31              :     /// The connection id
      32              :     connectionId: string;
      33              :     /// The health of the connection
      34              :     connectionHealth: ConnectionHealth;
      35              :     /// The connection details
      36              :     details: ConnectionState['details'];
      37              :     /// The connector info
      38              :     connectorInfo: ConnectorInfo;
      39              :     /// The label of the connection as plain text
      40              :     label: string;
      41              :     /// The parts of the label of the connection
      42              :     labelParts: ConnectionLabelParts;
      43              : }
      44              : 
      45              : /// The view of a connection
      46            1 : export function scriptConnectionView(connection: ConnectionState, labelParts: ConnectionLabelParts = connectionLabelParts(connection)): ScriptConnectionView {
      47            1 :     return {
      48            1 :         connectionId: connection.connectionId,
      49            1 :         connectionHealth: connection.connectionHealth,
      50            1 :         details: connection.details,
      51            1 :         connectorInfo: connection.connectorInfo,
      52            1 :         label: connectionLabelText(labelParts),
      53            1 :         labelParts,
      54            1 :     };
      55            1 : }
      56              : 
      57              : /// Run a script of a session on a connection.
      58              : /// The session uses the connections of its scripts, the session file and the connection registry
      59              : /// follow the script.
      60            1 : export function usePickScriptConnection(_sessionId: string, modifySessionScripts: ModifySessionScripts): (fileName: string, connectionId: string) => void {
      61            1 :     const registryStore = useConnectionRegistryStore();
      62            1 :     return React.useCallback((fileName: string, connectionId: string) => {
      63            0 :         const connection = registryStore.current.connections.get(connectionId);
      64            0 :         if (connection == null) return;
      65            0 :         modifySessionScripts({ type: SET_SCRIPT_CONNECTION, value: { fileName, connection: scriptConnectionOf(connection) } });
      66            1 :     }, [modifySessionScripts, registryStore]);
      67            1 : }
      68              : 
      69              : /// Create the next script of a session on a connection
      70            1 : export function usePickPendingConnection(modifySessionScripts: ModifySessionScripts): (connectionId: string) => void {
      71            1 :     const registryStore = useConnectionRegistryStore();
      72            1 :     return React.useCallback((connectionId: string) => {
      73            0 :         const connection = registryStore.current.connections.get(connectionId);
      74            0 :         if (connection == null) return;
      75            0 :         modifySessionScripts({ type: SET_PENDING_CONNECTION, value: scriptConnectionOf(connection) });
      76            1 :     }, [modifySessionScripts, registryStore]);
      77            1 : }
      78              : 
      79              : /// Create the next script of a session on a new connection of a connector, returns the id of the connection
      80            1 : export function useAttachPendingConnection(instance: core.DashQL, modifySessionScripts: ModifySessionScripts): (connector: ConnectorType) => string {
      81            1 :     const attach = useConnectionAttach();
      82            1 :     return React.useCallback((connector: ConnectorType) => {
      83            0 :         const connection = attach(instance, connector);
      84            0 :         modifySessionScripts({ type: SET_PENDING_CONNECTION, value: scriptConnectionOf(connection) });
      85            0 :         return connection.connectionId;
      86            1 :     }, [attach, instance, modifySessionScripts]);
      87            1 : }
      88              : 
      89              : /// The logo of the database of a connector, see useConnectorIcon
      90            1 : function ConnectorIcon(props: { connectorInfo: ConnectorInfo, size?: number }) {
      91            1 :     const connectorIcon = useConnectorIcon();
      92            1 :     const size = `${props.size ?? 16}px`;
      93            1 :     return (
      94            1 :         <svg className={styles.connector_icon} width={size} height={size} aria-hidden="true">
      95            1 :             <use xlinkHref={`${symbols}#${connectorIcon(props.connectorInfo)}`} style={CONNECTOR_ICON_STYLE} />
      96            1 :         </svg>
      97              :     );
      98            1 : }
      99              : 
     100              : /// A dot in the color of the connection health
     101            1 : export function HealthDot(props: { health: ConnectionHealth | null }) {
     102            1 :     return (
     103            1 :         <svg className={styles.health} width="16px" height="16px" viewBox="0 0 16 16" aria-hidden="true">
     104            1 :             <circle cx="8" cy="8" r="4" style={{ fill: CONNECTION_HEALTH_COLORS[props.health ?? ConnectionHealth.NOT_STARTED] }} />
     105            1 :         </svg>
     106              :     );
     107            1 : }
     108              : 
     109              : interface Props {
     110              :     /// The session of the script
     111              :     sessionId: string;
     112              :     /// The display name of the script
     113              :     scriptName: string;
     114              :     /// The connection the script runs on, null while it is not registered
     115              :     connection: ScriptConnectionView | null;
     116              :     /// Picking is disabled, e.g. while the script runs
     117              :     disabled: boolean;
     118              :     /// Run the script on another connection
     119              :     onPick: (connectionId: string) => void;
     120              :     /// Run the script on a new connection of a connector, returns the id of the connection
     121              :     onAttach?: (connector: ConnectorType) => string;
     122              :     /// The trigger sits in the header of an entry card, it shows the logo of the database instead of
     123              :     /// the health dot
     124              :     entryHeader?: boolean;
     125              :     /// The test id of the trigger
     126              :     testId?: string;
     127              :     dependencies?: ScriptConnectionPickerDependencies;
     128              : }
     129              : 
     130              : export interface ScriptConnectionPickerDependencies {
     131              :     ConnectionSettingsOverlay: typeof ConnectionSettingsOverlay;
     132              : }
     133              : 
     134            1 : const DEFAULT_DEPENDENCIES: ScriptConnectionPickerDependencies = {
     135            1 :     ConnectionSettingsOverlay,
     136            1 : };
     137              : 
     138              : /// Pick the connection that a script is analyzed and executed with
     139            1 : export const ScriptConnectionPicker: React.FC<Props> = (props) => {
     140            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
     141            1 :     const [open, setOpen] = React.useState(false);
     142              :     // The settings of an attached connection open right away, the user configures it there
     143            1 :     const [settingsConnectionId, setSettingsConnectionId] = React.useState<string | null>(null);
     144            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     145            1 :     const label = props.connection?.label ?? 'Unknown connection';
     146            1 :     const onAttach = props.onAttach;
     147            1 :     const attach = React.useMemo(() => onAttach == null ? undefined : (connector: ConnectorType) => {
     148            1 :         setOpen(false);
     149            1 :         setSettingsConnectionId(onAttach(connector));
     150            1 :     }, [onAttach]);
     151            1 :     return (
     152            1 :         <>
     153            1 :             <AnchoredOverlay
     154            1 :                 open={open}
     155            1 :                 onOpen={() => setOpen(true)}
     156            1 :                 onClose={() => setOpen(false)}
     157            1 :                 side={AnchorSide.OutsideBottom}
     158            1 :                 align={AnchorAlignment.End}
     159            1 :                 anchorOffset={4}
     160            1 :                 anchorRef={triggerRef}
     161            1 :                 returnFocusRef={triggerRef}
     162            1 :                 renderAnchor={(anchorProps) => (
     163            1 :                     <Button
     164            1 :                         {...anchorProps}
     165            1 :                         ref={triggerRef}
     166            1 :                         className={styles.trigger}
     167            1 :                         variant={ButtonVariant.Invisible}
     168            1 :                         size={ButtonSize.Small}
     169            1 :                         disabled={props.disabled}
     170            1 :                         leadingVisual={props.connection == null ? undefined : props.entryHeader
     171            1 :                             ? <ConnectorIcon connectorInfo={props.connection.connectorInfo} size={14} />
     172            1 :                             : <HealthDot health={props.connection.connectionHealth} />}
     173            1 :                         aria-label={`Connection of ${props.scriptName} script: ${label}`}
     174            1 :                         data-testid={props.testId ?? "script-connection-picker"}
     175            1 :                         title={label}
     176              :                     >
     177            1 :                         <span className={styles.trigger_label}>{props.connection?.labelParts.name ?? label}</span>
     178            1 :                     </Button>
     179              :                 )}
     180              :             >
     181            1 :                 <ConnectionMenu
     182            1 :                     sessionId={props.sessionId}
     183            1 :                     label={`${props.scriptName} script`}
     184            1 :                     connectionId={props.connection?.connectionId ?? null}
     185            1 :                     onPick={connectionId => {
     186            0 :                         setOpen(false);
     187            0 :                         if (connectionId !== props.connection?.connectionId) props.onPick(connectionId);
     188            0 :                     }}
     189            1 :                     onAttach={attach}
     190            1 :                 />
     191            1 :             </AnchoredOverlay>
     192              :             {/* Mounted only while open, the overlay subscribes to the connection */}
     193            1 :             {settingsConnectionId != null && (
     194            1 :                 <dependencies.ConnectionSettingsOverlay
     195            1 :                     connectionId={settingsConnectionId}
     196            1 :                     isOpen
     197            1 :                     onClose={() => setSettingsConnectionId(null)}
     198            1 :                     anchorRef={triggerRef}
     199            1 :                     side={AnchorSide.OutsideBottom}
     200            1 :                 />
     201              :             )}
     202            1 :         </>
     203              :     );
     204            1 : };
     205              : 
     206              : interface MenuProps {
     207              :     /// The session whose connections come first
     208              :     sessionId: string;
     209              :     /// What picks the connection, e.g. "Main script", named in the aria labels
     210              :     label: string;
     211              :     /// The picked connection
     212              :     connectionId: string | null;
     213              :     /// Pick a connection
     214              :     onPick: (connectionId: string) => void;
     215              :     /// Pick a new connection of a connector
     216              :     onAttach?: (connector: ConnectorType) => void;
     217              : }
     218              : 
     219              : /// The connections a script or a shell can run on, the connections of the session first.
     220              : /// Subscribes to the registries only while it is open.
     221            1 : export function ConnectionMenu(props: MenuProps) {
     222            1 :     const connectorIcon = useConnectorIcon();
     223            1 :     const [connections] = useConnectionRegistry();
     224              :     // Re-render when the sessions use other connections
     225            1 :     const [scripts] = useSessionScriptsRegistry();
     226            1 :     const references = useConnectionReferences();
     227            1 :     const { sessionConnections, otherConnections, ordinals } = React.useMemo(() => {
     228            1 :         const sessionConnections = resolveSessionConnections(connections, references, props.sessionId) ?? [];
     229            1 :         const listed = listedConnections(connections, references, sessionConnections);
     230            1 :         return {
     231            1 :             sessionConnections,
     232            1 :             otherConnections: listed.slice(sessionConnections.length),
     233            1 :             ordinals: connectionOrdinals(connections, references),
     234            1 :         };
     235            1 :     }, [connections, references, scripts, props.sessionId]);
     236            1 :     const renderItem = (connection: ConnectionState) => (
     237            0 :         <ActionList.ListItem
     238            0 :             key={connection.connectionId}
     239            0 :             aria-current={connection.connectionId === props.connectionId ? 'true' : undefined}
     240            0 :             onClick={() => props.onPick(connection.connectionId)}
     241              :         >
     242            0 :             <ActionList.Leading>
     243            0 :                 <ConnectorIcon connectorInfo={connection.connectorInfo} />
     244            0 :             </ActionList.Leading>
     245            0 :             <ActionList.ItemText>
     246            0 :                 <ActionList.ItemTextTitle>
     247            0 :                     <ConnectionLabelView parts={connectionLabelParts(connection, ordinals.get(connection.connectionId) ?? null)} />
     248            0 :                 </ActionList.ItemTextTitle>
     249            0 :             </ActionList.ItemText>
     250            0 :             <ActionList.Trailing><HealthDot health={connection.connectionHealth} /></ActionList.Trailing>
     251            0 :         </ActionList.ListItem>
     252              :     );
     253            1 :     return (
     254            1 :         <div className={styles.menu} role="dialog" aria-label={`Connection of ${props.label}`}>
     255            1 :             <ActionList.List aria-label={`Connections for ${props.label}`}>
     256              :                 {/* The list takes elements only, the fragments wrap the optional groups */}
     257            1 :                 <>{sessionConnections.length > 0 && (
     258            0 :                     <>
     259            0 :                         <ActionList.GroupHeading>This session</ActionList.GroupHeading>
     260            0 :                         {sessionConnections.map(renderItem)}
     261            0 :                     </>
     262            1 :                 )}</>
     263            1 :                 <>{otherConnections.length > 0 && (
     264            0 :                     <>
     265            0 :                         <ActionList.GroupHeading>Other connections</ActionList.GroupHeading>
     266            0 :                         {otherConnections.map(renderItem)}
     267            0 :                     </>
     268            1 :                 )}</>
     269            1 :                 <>{props.onAttach != null && (
     270            1 :                     <>
     271            1 :                         <ActionList.GroupHeading>Attach database</ActionList.GroupHeading>
     272            1 :                         {ATTACHABLE_CONNECTORS.map(connector => (
     273            1 :                             <ActionList.ListItem
     274            1 :                                 key={connector}
     275            1 :                                 data-testid="attach-connection-option"
     276            1 :                                 data-connector={connector}
     277            1 :                                 onClick={() => props.onAttach!(connector)}
     278              :                             >
     279            1 :                                 <ActionList.Leading>
     280            1 :                                     <svg className={styles.connector_icon} width="16px" height="16px" aria-hidden="true">
     281            1 :                                         <use xlinkHref={`${symbols}#${connectorIcon(CONNECTOR_INFOS[connector])}`} style={CONNECTOR_ICON_STYLE} />
     282            1 :                                     </svg>
     283            1 :                                 </ActionList.Leading>
     284            1 :                                 <ActionList.ItemText>
     285            1 :                                     {connector === ConnectorType.HYPER ? 'Hyper server' : CONNECTOR_INFOS[connector].names.displayShort}
     286            1 :                                 </ActionList.ItemText>
     287            1 :                             </ActionList.ListItem>
     288            1 :                         ))}
     289            1 :                     </>
     290            1 :                 )}</>
     291            1 :             </ActionList.List>
     292            1 :         </div>
     293              :     );
     294            1 : }
        

Generated by: LCOV version 2.0-1