Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as styles from '../../app/action_menu.module.css';
4 :
5 : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
6 : import { ButtonSize, ButtonVariant, IconButton } 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 { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
11 : import { useAppConfig } from '../../config/app_config.js';
12 : import { SET_SCRIPT_TEXT, scriptResultOwner, type ScriptData, type SessionScripts } from '../../session/scripts/session_scripts.js';
13 : import { useSessionScriptsDispatch, useSessionScriptsRegistryReader } from '../../session/scripts/session_scripts_registry.js';
14 : import { createScriptFormatConfig, formattingDialectForConnector, getKeywordCase } from '../../session/scripts/script_format.js';
15 : import { addResultProjection, ResultProjectionError } from './result_projection.js';
16 :
17 1 : const MoreIcon = SymbolIcon('kebab_horizontal');
18 1 : const ProjectionIcon = SymbolIcon('columns_16');
19 :
20 1 : const PROJECTION_ERRORS: Record<ResultProjectionError, string> = {
21 1 : [ResultProjectionError.NotParsed]: 'The script does not parse',
22 1 : [ResultProjectionError.NoQuery]: 'The last statement is no query',
23 1 : [ResultProjectionError.NoColumns]: 'The result has no columns',
24 1 : [ResultProjectionError.DuplicateColumns]: 'Two columns have the same name',
25 1 : };
26 :
27 1 : function isProjectionError(text: string | ResultProjectionError): text is ResultProjectionError {
28 1 : return Object.prototype.hasOwnProperty.call(PROJECTION_ERRORS, text);
29 1 : }
30 :
31 : /// The script of a session that owns a query result
32 1 : function findResultScript(sessionScripts: SessionScripts, owner: string): ScriptData | null {
33 1 : for (const script of Object.values(sessionScripts.scripts)) {
34 1 : if (scriptResultOwner(sessionScripts.sessionId, script.scriptKey) === owner) return script;
35 : }
36 1 : return null;
37 1 : }
38 :
39 : interface Props {
40 : query: QueryExecutionState;
41 : }
42 :
43 : /// The actions of a query result that rewrite the script of the result. Results without a script, e.g. of the
44 : /// shell or the catalog, have no menu. The standalone shell also has no storage or scripts, so the hooks that need
45 : /// them must not run for its results.
46 1 : export function QueryResultActionMenu({ query }: Props): React.ReactElement | null {
47 1 : const sessionId = query.queryMetadata.sessionId;
48 1 : const owner = query.queryMetadata.resultOwner;
49 1 : if (sessionId == null || owner == null) return null;
50 1 : return <ScriptResultActionMenu query={query} sessionId={sessionId} owner={owner} />;
51 1 : }
52 :
53 : interface ScriptResultProps extends Props {
54 : sessionId: string;
55 : owner: string;
56 : }
57 :
58 1 : function ScriptResultActionMenu({ query, sessionId, owner }: ScriptResultProps): React.ReactElement {
59 1 : const readRegistry = useSessionScriptsRegistryReader();
60 1 : const dispatchSessionScripts = useSessionScriptsDispatch();
61 1 : const config = useAppConfig();
62 1 : const [open, setOpen] = React.useState(false);
63 : // The rewrite is computed when the menu opens, the item says why a script cannot get it
64 1 : const [projection, setProjection] = React.useState<{ scriptKey: number; text: string } | ResultProjectionError | null>(null);
65 1 : const triggerRef = React.useRef<HTMLButtonElement>(null);
66 :
67 1 : const hasResult = query.status === QueryExecutionStatus.SUCCEEDED && query.resultTable != null;
68 :
69 1 : const openMenu = () => {
70 1 : setProjection(null);
71 1 : setOpen(true);
72 1 : const sessionScripts = readRegistry().sessionScriptsMap.get(sessionId);
73 1 : const script = sessionScripts == null ? null : findResultScript(sessionScripts, owner);
74 1 : if (sessionScripts == null || script == null || query.resultTable == null) return;
75 1 : const formatConfig = createScriptFormatConfig(
76 1 : dashql.buffers.formatting.FormattingMode.PRETTY,
77 1 : false,
78 1 : undefined,
79 1 : getKeywordCase(config?.settings),
80 1 : formattingDialectForConnector(script.connection.connectorInfo.connectorType),
81 1 : );
82 1 : const columnNames = query.resultTable.schema.fields.map(field => field.name);
83 1 : const text = addResultProjection(sessionScripts.instance, script.scriptSession.getText(), columnNames, formatConfig);
84 : // The errors are strings as well
85 1 : setProjection(isProjectionError(text) ? text : { scriptKey: script.scriptKey, text });
86 1 : };
87 1 : const applyProjection = () => {
88 1 : setOpen(false);
89 1 : if (projection == null || typeof projection !== 'object') return;
90 : // Staged as a diff like the formatting, the user accepts or rejects it in the editor
91 1 : void dispatchSessionScripts(sessionId, {
92 1 : type: SET_SCRIPT_TEXT,
93 1 : value: { scriptKey: projection.scriptKey, text: projection.text, withDiff: true },
94 1 : });
95 1 : };
96 1 : const projectionError = projection != null && typeof projection !== 'object' ? PROJECTION_ERRORS[projection] : null;
97 :
98 1 : return (
99 1 : <AnchoredOverlay
100 1 : open={open}
101 1 : onOpen={openMenu}
102 1 : onClose={() => setOpen(false)}
103 1 : side={AnchorSide.OutsideBottom}
104 1 : align={AnchorAlignment.End}
105 1 : anchorOffset={4}
106 1 : anchorRef={triggerRef}
107 1 : returnFocusRef={triggerRef}
108 1 : renderAnchor={(anchorProps) => (
109 1 : <IconButton
110 1 : {...anchorProps}
111 1 : ref={triggerRef}
112 1 : variant={ButtonVariant.Invisible}
113 1 : size={ButtonSize.Small}
114 1 : aria-label="More actions for the query result"
115 1 : data-testid="query-result-more-actions"
116 1 : disabled={!hasResult}
117 : >
118 1 : <MoreIcon size={16} />
119 1 : </IconButton>
120 : )}
121 : >
122 1 : <div className={styles.menu} role="dialog" aria-label="Query result actions">
123 1 : <ActionList.List aria-label="Query result actions">
124 1 : <ActionList.ListItem
125 1 : data-testid="query-result-project-columns"
126 1 : disabled={projection == null || typeof projection !== 'object'}
127 1 : onClick={applyProjection}
128 : >
129 1 : <ActionList.Leading><ProjectionIcon size={16} /></ActionList.Leading>
130 1 : <ActionList.ItemText>
131 1 : <ActionList.ItemTextTitle>Project Columns</ActionList.ItemTextTitle>
132 1 : {projectionError != null ? <ActionList.ItemTextDescription>{projectionError}</ActionList.ItemTextDescription> : undefined}
133 1 : </ActionList.ItemText>
134 1 : </ActionList.ListItem>
135 1 : </ActionList.List>
136 1 : </div>
137 1 : </AnchoredOverlay>
138 : );
139 1 : }
|