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.
45 1 : export function QueryResultActionMenu({ query }: Props): React.ReactElement | null {
46 1 : const readRegistry = useSessionScriptsRegistryReader();
47 1 : const dispatchSessionScripts = useSessionScriptsDispatch();
48 1 : const config = useAppConfig();
49 1 : const [open, setOpen] = React.useState(false);
50 : // The rewrite is computed when the menu opens, the item says why a script cannot get it
51 1 : const [projection, setProjection] = React.useState<{ scriptKey: number; text: string } | ResultProjectionError | null>(null);
52 1 : const triggerRef = React.useRef<HTMLButtonElement>(null);
53 :
54 1 : const sessionId = query.queryMetadata.sessionId;
55 1 : const owner = query.queryMetadata.resultOwner;
56 1 : if (sessionId == null || owner == null) return null;
57 1 : const hasResult = query.status === QueryExecutionStatus.SUCCEEDED && query.resultTable != null;
58 :
59 1 : const openMenu = () => {
60 1 : setProjection(null);
61 1 : setOpen(true);
62 1 : const sessionScripts = readRegistry().sessionScriptsMap.get(sessionId);
63 1 : const script = sessionScripts == null ? null : findResultScript(sessionScripts, owner);
64 1 : if (sessionScripts == null || script == null || query.resultTable == null) return;
65 1 : const formatConfig = createScriptFormatConfig(
66 1 : dashql.buffers.formatting.FormattingMode.PRETTY,
67 1 : false,
68 1 : undefined,
69 1 : getKeywordCase(config?.settings),
70 1 : formattingDialectForConnector(script.connection.connectorInfo.connectorType),
71 1 : );
72 1 : const columnNames = query.resultTable.schema.fields.map(field => field.name);
73 1 : const text = addResultProjection(sessionScripts.instance, script.scriptSession.getText(), columnNames, formatConfig);
74 : // The errors are strings as well
75 1 : setProjection(isProjectionError(text) ? text : { scriptKey: script.scriptKey, text });
76 1 : };
77 1 : const applyProjection = () => {
78 1 : setOpen(false);
79 1 : if (projection == null || typeof projection !== 'object') return;
80 : // Staged as a diff like the formatting, the user accepts or rejects it in the editor
81 1 : void dispatchSessionScripts(sessionId, {
82 1 : type: SET_SCRIPT_TEXT,
83 1 : value: { scriptKey: projection.scriptKey, text: projection.text, withDiff: true },
84 1 : });
85 1 : };
86 1 : const projectionError = projection != null && typeof projection !== 'object' ? PROJECTION_ERRORS[projection] : null;
87 :
88 1 : return (
89 1 : <AnchoredOverlay
90 1 : open={open}
91 1 : onOpen={openMenu}
92 1 : onClose={() => setOpen(false)}
93 1 : side={AnchorSide.OutsideBottom}
94 1 : align={AnchorAlignment.End}
95 1 : anchorOffset={4}
96 1 : anchorRef={triggerRef}
97 1 : returnFocusRef={triggerRef}
98 1 : renderAnchor={(anchorProps) => (
99 1 : <IconButton
100 1 : {...anchorProps}
101 1 : ref={triggerRef}
102 1 : variant={ButtonVariant.Invisible}
103 1 : size={ButtonSize.Small}
104 1 : aria-label="More actions for the query result"
105 1 : data-testid="query-result-more-actions"
106 1 : disabled={!hasResult}
107 : >
108 1 : <MoreIcon size={16} />
109 1 : </IconButton>
110 : )}
111 : >
112 1 : <div className={styles.menu} role="dialog" aria-label="Query result actions">
113 1 : <ActionList.List aria-label="Query result actions">
114 1 : <ActionList.ListItem
115 1 : data-testid="query-result-project-columns"
116 1 : disabled={projection == null || typeof projection !== 'object'}
117 1 : onClick={applyProjection}
118 : >
119 1 : <ActionList.Leading><ProjectionIcon size={16} /></ActionList.Leading>
120 1 : <ActionList.ItemText>
121 1 : <ActionList.ItemTextTitle>Project Columns</ActionList.ItemTextTitle>
122 1 : {projectionError != null ? <ActionList.ItemTextDescription>{projectionError}</ActionList.ItemTextDescription> : undefined}
123 1 : </ActionList.ItemText>
124 1 : </ActionList.ListItem>
125 1 : </ActionList.List>
126 1 : </div>
127 1 : </AnchoredOverlay>
128 : );
129 1 : }
|