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