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 : }
|