LCOV - code coverage report
Current view: top level - dashql-app/src/connections - connection_settings_overlay.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 83.2 % 208 173
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import * as styles from './connection_settings_overlay.module.css';
       4              : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
       5              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
       6              : import { OverlaySize } from '../ui/foundations/overlay.js';
       7              : import { Button, ButtonSize, ButtonVariant } from '../ui/foundations/button.js';
       8              : import { SymbolIcon } from '../ui/foundations/symbol_icon.js';
       9              : import { ConnectorType } from './connector_info.js';
      10              : import { useConnectionById, useConnectionDraft, useConnectionReferencesSelector } from './connection_registry.js';
      11              : import { connectionSaveSucceeded, failedSessionCopies, useConnectionSave, type ConnectionSaveResult } from './connection_persistence.js';
      12              : import { stringifyError } from '../platform/logger/logger.js';
      13              : import { ConnectionHealth, connectionCachesQueryResults, SET_CONNECTION_QUERY_RESULT_CACHING, type ConnectionState } from './connection_state.js';
      14              : import { ToggleSwitch } from '../ui/foundations/toggle_switch.js';
      15              : import { HyperConnectorSettings } from './hyper_connection_settings.js';
      16              : import { TrinoConnectorSettings } from './trino_connection_settings.js';
      17              : import { SalesforceConnectorSettings } from './salesforce_connection_settings.js';
      18              : 
      19            1 : const CheckIcon = SymbolIcon('check_16');
      20              : 
      21              : interface Props {
      22              :     /// The connection to configure
      23              :     connectionId: string | null;
      24              :     isOpen: boolean;
      25              :     onClose: () => void;
      26              :     anchorRef: React.RefObject<HTMLElement | null>;
      27              :     headerTitle?: string;
      28              :     onConnected?: (database: ConnectionState) => void;
      29              :     side?: AnchorSide;
      30              :     /// Renders the name of a session, the session id if absent
      31              :     renderSessionName?: (sessionId: string) => React.ReactNode;
      32              : }
      33              : 
      34            1 : export const ConnectionSettingsOverlay: React.FC<Props> = (props: Props) => {
      35            1 :     const [conn, modifyConn] = useConnectionById(props.connectionId);
      36            1 :     const { paramsDraft, discard } = useConnectionDraft(props.connectionId);
      37            1 :     const { saving, result: saveResult, save, retry } = useConnectionSave(props.connectionId);
      38            1 :     const connecting = conn?.connectionHealth === ConnectionHealth.CONNECTING;
      39              : 
      40            1 :     const currentConnectorType = conn?.connectorInfo.connectorType ?? ConnectorType.HYPER;
      41              : 
      42            1 :     const completedDatabaseId = React.useRef<string | null>(null);
      43            1 :     React.useEffect(() => {
      44            1 :         if (!props.isOpen || conn?.connectionHealth !== ConnectionHealth.ONLINE || props.onConnected == null) return;
      45            1 :         if (completedDatabaseId.current === conn.connectionId) return;
      46            1 :         completedDatabaseId.current = conn.connectionId;
      47            1 :         void props.onConnected(conn);
      48            1 :     }, [conn, props.isOpen, props.onConnected]);
      49            1 :     React.useEffect(() => {
      50            1 :         if (!props.isOpen) completedDatabaseId.current = null;
      51            1 :     }, [props.isOpen]);
      52              : 
      53              :     // Saving writes every session that references the connection, the overlay stays open until all
      54              :     // writes are done.
      55            1 :     const onClose = props.onClose;
      56            1 :     const close = React.useCallback(() => {
      57            0 :         if (!saving) onClose();
      58            1 :     }, [saving, onClose]);
      59              : 
      60            1 :     useCloseOnConnect({
      61            1 :         isOpen: props.isOpen,
      62            1 :         connectionId: props.connectionId,
      63            1 :         health: conn?.connectionHealth,
      64            1 :         saving,
      65            1 :         saveResult,
      66            1 :         onClose,
      67            1 :     });
      68              : 
      69            1 :     const connectionId = props.connectionId;
      70            1 :     const sessionIds = useConnectionReferencesSelector(React.useCallback(
      71            1 :         references => connectionId == null ? '' : references.sessionsOf(connectionId).join(','),
      72            1 :         [connectionId],
      73            1 :     ));
      74            1 :     const sessionCount = sessionIds === '' ? 0 : sessionIds.split(',').length;
      75              : 
      76            1 :     return (
      77            1 :         <AnchoredOverlay
      78            1 :             renderAnchor={null}
      79            1 :             anchorRef={props.anchorRef as React.RefObject<HTMLElement | null>}
      80            1 :             open={props.isOpen}
      81            1 :             onClose={close}
      82            1 :             side={props.side ?? AnchorSide.OutsideRight}
      83            1 :             align={AnchorAlignment.Start}
      84            1 :             minWidth={OverlaySize.L}
      85            1 :             maxWidth={OverlaySize.XXL}
      86            1 :             height={OverlaySize.XL}
      87            1 :             maxHeight={OverlaySize.XL}
      88              :         >
      89            1 :             <div className={styles.overlay_container}>
      90            1 :                 <div className={styles.content}>
      91            1 :                     {currentConnectorType === ConnectorType.HYPER && <HyperConnectorSettings connectionId={props.connectionId} onClose={saving ? undefined : props.onClose} />}
      92            1 :                     {currentConnectorType === ConnectorType.TRINO && <TrinoConnectorSettings connectionId={props.connectionId} onClose={saving ? undefined : props.onClose} />}
      93            1 :                     {currentConnectorType === ConnectorType.SALESFORCE_DATA_CLOUD && <SalesforceConnectorSettings connectionId={props.connectionId} connector={ConnectorType.SALESFORCE_DATA_CLOUD} onClose={saving ? undefined : props.onClose} />}
      94            1 :                     {currentConnectorType === ConnectorType.SIDB && <SalesforceConnectorSettings connectionId={props.connectionId} connector={ConnectorType.SIDB} onClose={saving ? undefined : props.onClose} />}
      95            1 :                 </div>
      96            1 :                 <QueryResultCachingRow
      97            1 :                     caching={conn != null && connectionCachesQueryResults(conn)}
      98            1 :                     disabled={conn == null}
      99            1 :                     toggle={() => {
     100            0 :                         if (conn != null) modifyConn({ type: SET_CONNECTION_QUERY_RESULT_CACHING, value: !connectionCachesQueryResults(conn) });
     101            0 :                     }}
     102            1 :                 />
     103            1 :                 <ConnectionSaveBar
     104            1 :                     dirty={paramsDraft != null}
     105            1 :                     saving={saving}
     106            1 :                     connecting={connecting}
     107            1 :                     online={conn?.connectionHealth === ConnectionHealth.ONLINE}
     108            1 :                     sessionCount={sessionCount}
     109            1 :                     result={saveResult}
     110            1 :                     save={() => { void save(); }}
     111            1 :                     discard={discard}
     112            1 :                     retry={retry}
     113            1 :                     renderSessionName={props.renderSessionName}
     114            1 :                 />
     115            1 :             </div>
     116            1 :         </AnchoredOverlay>
     117              :     );
     118            1 : };
     119              : 
     120              : /// Does the connection cache query results? The choice applies right away, it is no connection parameter.
     121            0 : function QueryResultCachingRow(props: { caching: boolean; disabled: boolean; toggle: () => void }) {
     122            0 :     const labelId = React.useId();
     123            0 :     return (
     124            0 :         <div className={styles.caching}>
     125            0 :             <div className={styles.caching_text}>
     126            0 :                 <div id={labelId} className={styles.caching_label}>Cache query results</div>
     127            0 :                 <div className={styles.caching_caption}>Reuse the results of unchanged scripts instead of querying the connection again</div>
     128            0 :             </div>
     129            0 :             <ToggleSwitch size="small" checked={props.caching} disabled={props.disabled} onClick={props.toggle} aria-labelledby={labelId} />
     130            0 :         </div>
     131              :     );
     132            0 : }
     133              : 
     134              : export interface CloseOnConnectProps {
     135              :     /// Is the overlay open?
     136              :     isOpen: boolean;
     137              :     /// The configured connection
     138              :     connectionId: string | null;
     139              :     /// The health of the connection
     140              :     health: ConnectionHealth | undefined;
     141              :     /// Is the connection being saved?
     142              :     saving: boolean;
     143              :     /// The outcome of the last save
     144              :     saveResult: ConnectionSaveResult | null;
     145              :     /// Close the overlay
     146              :     onClose: () => void;
     147              : }
     148              : 
     149              : /// Close the overlay once a connect seen while open comes online, unless the save bar reports a failure
     150            1 : export function useCloseOnConnect(props: CloseOnConnectProps): void {
     151            1 :     const { isOpen, connectionId, health, saving, saveResult, onClose } = props;
     152            1 :     const connectingId = React.useRef<string | null>(null);
     153            1 :     React.useEffect(() => {
     154            1 :         if (!isOpen || connectionId == null) {
     155            1 :             connectingId.current = null;
     156            1 :         } else if (health === ConnectionHealth.CONNECTING) {
     157            1 :             connectingId.current = connectionId;
     158            1 :         } else if (health !== ConnectionHealth.ONLINE || connectingId.current !== connectionId) {
     159            1 :             connectingId.current = null;
     160            1 :         } else if (!saving && (saveResult == null || connectionSaveSucceeded(saveResult))) {
     161            1 :             connectingId.current = null;
     162            1 :             onClose();
     163            1 :         }
     164            1 :     }, [isOpen, connectionId, health, saving, saveResult, onClose]);
     165            1 : }
     166              : 
     167              : /// How long the bar confirms a save
     168            1 : const SAVED_NOTICE_DURATION = 2500;
     169              : 
     170              : /// Phrase a number of sessions
     171            1 : function sessions(count: number): string {
     172            1 :     return count === 1 ? 'one session' : `${count} sessions`;
     173            1 : }
     174              : 
     175              : export interface ConnectionSaveBarProps {
     176              :     /// Are there unsaved edits?
     177              :     dirty: boolean;
     178              :     /// Is the connection being saved?
     179              :     saving: boolean;
     180              :     /// Is the connection being set up?
     181              :     connecting: boolean;
     182              :     /// Is the connection online?
     183              :     online: boolean;
     184              :     /// The number of sessions that use the connection
     185              :     sessionCount: number;
     186              :     /// The outcome of the last save
     187              :     result: ConnectionSaveResult | null;
     188              :     /// Save the edits
     189              :     save: () => void;
     190              :     /// Discard the edits
     191              :     discard: () => void;
     192              :     /// Write the copies of sessions again
     193              :     retry: (sessionIds: string[]) => Promise<ConnectionSaveResult | null>;
     194              :     /// Renders the name of a session
     195              :     renderSessionName?: (sessionId: string) => React.ReactNode;
     196              : }
     197              : 
     198              : /// The unsaved edits of a connection, the actions to save or discard them and the outcome of saving
     199            1 : export function ConnectionSaveBar(props: ConnectionSaveBarProps): React.ReactElement | null {
     200            1 :     const label = props.renderSessionName ?? ((sessionId: string) => sessionId);
     201            1 :     const { saving, result } = props;
     202              : 
     203              :     // Confirm a save that finished while the bar is shown
     204            1 :     const [prevSaving, setPrevSaving] = React.useState(saving);
     205            1 :     const [savedResult, setSavedResult] = React.useState<ConnectionSaveResult | null>(null);
     206            1 :     if (prevSaving !== saving) {
     207            1 :         setPrevSaving(saving);
     208            1 :         setSavedResult(!saving && result != null && connectionSaveSucceeded(result) ? result : null);
     209            1 :     }
     210            1 :     React.useEffect(() => {
     211            1 :         if (savedResult == null) return;
     212            1 :         const timer = setTimeout(() => setSavedResult(null), SAVED_NOTICE_DURATION);
     213            1 :         return () => clearTimeout(timer);
     214            1 :     }, [savedResult]);
     215            1 :     const savedNotice = savedResult != null && savedResult === result;
     216              : 
     217            1 :     const draftActions = props.dirty && (
     218            1 :         <div className={styles.footer_actions}>
     219            1 :             <Button
     220            1 :                 variant={ButtonVariant.Invisible}
     221            1 :                 size={ButtonSize.Small}
     222            1 :                 title="Discard changes and restore saved connection settings"
     223            1 :                 data-testid="connection-discard"
     224            1 :                 disabled={props.connecting || saving}
     225            1 :                 onClick={props.discard}
     226            1 :             >Discard</Button>
     227            1 :             <Button
     228            1 :                 variant={ButtonVariant.Primary}
     229            1 :                 size={ButtonSize.Small}
     230            1 :                 title="Save the connection settings for every session that uses the connection"
     231            1 :                 data-testid="connection-save"
     232            1 :                 disabled={props.connecting || saving}
     233            1 :                 onClick={props.save}
     234            1 :             >{saving ? 'Saving…' : 'Save'}</Button>
     235            1 :         </div>
     236              :     );
     237              : 
     238            1 :     if (result != null && !saving && result.manifest.status !== 'written') {
     239            1 :         const reason = result.manifest.status === 'failed' ? stringifyError(result.manifest.error) : 'storage is busy';
     240            1 :         return (
     241            1 :             <div className={styles.footer} role="alert" data-testid="connection-save-bar" data-state="save-failed">
     242            1 :                 <div className={styles.footer_error}>The connection could not be saved: {reason}</div>
     243            1 :                 {draftActions}
     244            1 :             </div>
     245              :         );
     246            1 :     }
     247            1 :     const failed = result == null ? [] : failedSessionCopies(result);
     248            1 :     if (failed.length > 0 && !saving) {
     249            0 :         return (
     250            0 :             <div className={styles.footer} role="alert" data-testid="connection-save-bar" data-state="sessions-failed">
     251            0 :                 <div className={styles.footer_error}>
     252            0 :                     The connection is saved, but {sessions(failed.length)} could not be updated.
     253              :                     They are updated when they are loaded next time.
     254            0 :                     <ul className={styles.footer_list}>
     255            0 :                         {failed.map(n => (
     256            0 :                             <li key={n.sessionId}>
     257            0 :                                 {label(n.sessionId)}: {n.result.status === 'failed' ? stringifyError(n.result.error) : 'storage is busy'}
     258            0 :                             </li>
     259            0 :                         ))}
     260            0 :                     </ul>
     261            0 :                 </div>
     262            0 :                 <div className={styles.footer_actions}>
     263            0 :                     <Button
     264            0 :                         size={ButtonSize.Small}
     265            0 :                         data-testid="connection-save-retry"
     266            0 :                         onClick={() => { void props.retry(failed.map(n => n.sessionId)); }}
     267            0 :                     >Retry</Button>
     268            0 :                     {draftActions}
     269            0 :                 </div>
     270            0 :             </div>
     271              :         );
     272              :     }
     273            1 :     if (saving) {
     274            1 :         return (
     275            1 :             <div className={styles.footer} role="status" data-testid="connection-save-bar" data-state="saving">
     276            1 :                 <div className={styles.footer_notice}>
     277            1 :                     {props.sessionCount > 1 ? `Saving the connection for ${sessions(props.sessionCount)}…` : 'Saving the connection…'}
     278            1 :                 </div>
     279            1 :                 {draftActions}
     280            1 :             </div>
     281              :         );
     282              :     }
     283            1 :     if (props.dirty) {
     284            1 :         let hint: string | null = null;
     285            1 :         if (props.online) {
     286            1 :             hint = 'The open connection keeps the previous settings until you reconnect.';
     287            1 :         } else if (!props.connecting) {
     288            1 :             hint = 'Connecting saves them too.';
     289              :         }
     290            1 :         return (
     291            1 :             <div className={styles.footer} role="status" data-testid="connection-save-bar" data-state="unsaved">
     292            1 :                 <div className={styles.footer_notice}>
     293            1 :                     <span className={styles.footer_dirty}>
     294            1 :                         <span className={styles.footer_dirty_dot} aria-hidden="true" />
     295              :                         Unsaved changes
     296            1 :                     </span>
     297            1 :                     {props.sessionCount > 1 && ` · applies to ${sessions(props.sessionCount)}`}
     298            1 :                     {hint != null && <div className={styles.footer_hint} data-testid="connection-save-hint" data-hint={props.online ? 'reconnect' : 'connect'}>{hint}</div>}
     299            1 :                 </div>
     300            1 :                 {draftActions}
     301            1 :             </div>
     302              :         );
     303            1 :     }
     304            1 :     if (savedNotice) {
     305            1 :         return (
     306            1 :             <div className={styles.footer} role="status" data-testid="connection-save-bar" data-state="saved">
     307            1 :                 <div className={styles.footer_saved}>
     308            1 :                     <CheckIcon />
     309            1 :                     {props.sessionCount > 1 ? `Saved for ${sessions(props.sessionCount)}` : 'Saved'}
     310            1 :                 </div>
     311            1 :             </div>
     312              :         );
     313              :     }
     314            1 :     return null;
     315            1 : }
        

Generated by: LCOV version 2.0-1