Files
superset2/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
Mehmet Salih Yavuz 41a22d7918 chore: Upgrade to React 18 (#38563)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-04 19:19:36 +03:00

1091 lines
32 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable jsx-a11y/anchor-is-valid */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import {
useState,
useEffect,
useMemo,
useRef,
useCallback,
ChangeEvent,
FC,
} from 'react';
import type { editors } from '@apache-superset/core';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import { t } from '@apache-superset/core/translation';
import {
FeatureFlag,
isFeatureEnabled,
getExtensionsRegistry,
QueryResponse,
Query,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import type {
QueryEditor,
SqlLabRootState,
CursorPosition,
} from 'src/SqlLab/types';
import type { DatabaseObject } from 'src/features/databases/types';
import { debounce, isEmpty } from 'lodash';
import Mousetrap from 'mousetrap';
import {
Button,
Divider,
EmptyState,
Input,
Modal,
} from '@superset-ui/core/components';
import { Splitter } from 'src/components/Splitter';
import { Skeleton } from '@superset-ui/core/components/Skeleton';
import { Switch } from '@superset-ui/core/components/Switch';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { detectOS } from 'src/utils/common';
import {
addNewQueryEditor,
CtasEnum,
estimateQueryCost,
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
queryEditorSetSql,
queryEditorSetCursorPosition,
queryEditorSetAndSaveSql,
queryEditorSetTemplateParams,
runQueryFromSqlEditor,
saveQuery,
addSavedQueryToTabState,
scheduleQuery,
setActiveSouthPaneTab,
updateSavedQuery,
formatQuery,
fetchQueryEditor,
switchQueryEditor,
} from 'src/SqlLab/actions/sqlLab';
import {
SQL_EDITOR_GUTTER_HEIGHT,
INITIAL_NORTH_PERCENT,
SET_QUERY_EDITOR_SQL_DEBOUNCE_MS,
} from 'src/SqlLab/constants';
import {
getItem,
LocalStorageKeys,
setItem,
} from 'src/utils/localStorageHelpers';
import getBootstrapData from 'src/utils/getBootstrapData';
import useLogAction from 'src/logger/useLogAction';
import {
LOG_ACTIONS_SQLLAB_CREATE_TABLE_AS,
LOG_ACTIONS_SQLLAB_CREATE_VIEW_AS,
LOG_ACTIONS_SQLLAB_ESTIMATE_QUERY_COST,
LOG_ACTIONS_SQLLAB_FORMAT_SQL,
LOG_ACTIONS_SQLLAB_LOAD_TAB_STATE,
LOG_ACTIONS_SQLLAB_RUN_QUERY,
LOG_ACTIONS_SQLLAB_STOP_QUERY,
Logger,
} from 'src/logger/LogUtils';
import { CopyToClipboard } from 'src/components';
import TemplateParamsEditor from '../TemplateParamsEditor';
import SouthPane from '../SouthPane';
import SaveQuery, { QueryPayload } from '../SaveQuery';
import ScheduleQueryButton from '../ScheduleQueryButton';
import EstimateQueryCostButton from '../EstimateQueryCostButton';
import ShareSqlLabQuery from '../ShareSqlLabQuery';
import EditorWrapper from '../EditorWrapper';
import RunQueryActionButton from '../RunQueryActionButton';
import QueryLimitSelect from '../QueryLimitSelect';
import KeyboardShortcutButton, {
KEY_MAP,
KeyboardShortcut,
} from '../KeyboardShortcutButton';
import SqlEditorTopBar from '../SqlEditorTopBar';
const bootstrapData = getBootstrapData();
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
const StyledToolbar = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 2}px;
background: ${({ theme }) => theme.colorBgContainer};
display: flex;
justify-content: space-between;
border: 1px solid ${({ theme }) => theme.colorBorder};
border-top: 0;
column-gap: ${({ theme }) => theme.sizeUnit}px;
form {
margin-block-end: 0;
}
.leftItems,
.rightItems {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
& > span {
margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
display: inline-block;
&:last-child {
margin-right: 0;
}
}
}
.limitDropdown {
white-space: nowrap;
}
`;
const StyledSqlEditor = styled.div`
${({ theme }) => css`
height: 100%;
.queryPane {
padding: 0;
+ .ant-splitter-bar .ant-splitter-bar-dragger {
&::before {
height: 1px;
background-color: ${theme.colorBorder};
transform: translateX(-50%) !important;
}
&::after {
height: ${SQL_EDITOR_GUTTER_HEIGHT}px;
background: transparent;
border-top: 1px solid ${theme.colorBorder};
border-bottom: 1px solid ${theme.colorBorder};
transform: translate(-50%, -2px);
}
}
}
.north-pane {
padding: ${theme.sizeUnit * 2}px 0 0 0;
height: 100%;
margin: 0 ${theme.sizeUnit * 4}px;
}
.SouthPane {
& .ant-tabs-tabpane {
margin: 0 ${theme.sizeUnit * 4}px;
& .ant-tabs {
margin: 0 ${theme.sizeUnit * -4}px;
}
}
& .ant-tabs-tab {
box-shadow: none !important;
background: transparent !important;
border-color: transparent !important;
margin-top: ${theme.sizeUnit * 2}px !important;
&.ant-tabs-tab-active {
border-bottom-color: ${theme.colorPrimary} !important;
& .ant-tabs-tab-btn {
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorTextBase} !important;
text-shadow: none !important;
}
}
}
}
.sql-container {
flex: 1 1 auto;
margin: 0 ${theme.sizeUnit * -4}px;
box-shadow: 0 0 0 1px ${theme.colorBorder};
}
`}
`;
const extensionsRegistry = getExtensionsRegistry();
export type Props = {
queryEditor: QueryEditor;
defaultQueryLimit: number;
maxRow: number;
displayLimit: number;
saveQueryWarning: string | null;
scheduleQueryWarning: string | null;
};
const SqlEditor: FC<Props> = ({
queryEditor,
defaultQueryLimit,
maxRow,
displayLimit,
saveQueryWarning,
scheduleQueryWarning,
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
useSelector<
SqlLabRootState,
{
database?: DatabaseObject;
latestQuery?: QueryResponse;
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
latestQueryId = unsavedQueryEditor.latestQueryId || latestQueryId;
hideLeftBar =
typeof unsavedQueryEditor.hideLeftBar === 'boolean'
? unsavedQueryEditor.hideLeftBar
: hideLeftBar;
}
return {
hasSqlStatement: Boolean(queryEditor.sql?.trim().length > 0),
database: databases[dbId || ''],
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
};
}, shallowEqual);
const logAction = useLogAction({ queryEditorId: queryEditor.id });
const isActive = currentQueryEditorId === queryEditor.id;
const [autorun, setAutorun] = useState(queryEditor.autorun);
const [ctas, setCtas] = useState('');
const [northPercent, setNorthPercent] = useState(
queryEditor.northPercent || INITIAL_NORTH_PERCENT,
);
const [autocompleteEnabled, setAutocompleteEnabled] = useState(
getItem(LocalStorageKeys.SqllabIsAutocompleteEnabled, true),
);
const [renderHTMLEnabled, setRenderHTMLEnabled] = useState(
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, true),
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const currentSQL = useRef<string>(queryEditor.sql);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
[database],
);
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
const startQuery = useCallback(
(
ctasArg = false,
ctas_method: (typeof CtasEnum)[keyof typeof CtasEnum] = CtasEnum.Table,
) => {
if (!database) {
return;
}
dispatch(
runQueryFromSqlEditor(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
database as any,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
},
[ctas, database, defaultQueryLimit, dispatch, queryEditor],
);
const formatCurrentQuery = useCallback(
(useShortcut?: boolean) => {
logAction(LOG_ACTIONS_SQLLAB_FORMAT_SQL, {
shortcut: Boolean(useShortcut),
});
dispatch(formatQuery(queryEditor));
},
[dispatch, queryEditor, logAction],
);
const stopQuery = useCallback(() => {
if (latestQuery && ['running', 'pending'].indexOf(latestQuery.state) >= 0) {
dispatch(postStopQuery(latestQuery));
}
return false;
}, [dispatch, latestQuery]);
const runQuery = () => {
if (database) {
startQuery();
}
};
useEffect(() => {
if (autorun) {
setAutorun(false);
dispatch(queryEditorSetAutorun(queryEditor, false));
startQuery();
}
}, [autorun, dispatch, queryEditor, startQuery]);
const getHotkeyConfig = useCallback(() => {
// Get the user's OS
const userOS = detectOS();
return [
{
name: 'runQuery1',
key: KeyboardShortcut.CtrlR,
descr: KEY_MAP[KeyboardShortcut.CtrlR],
func: () => {
if (queryEditor.sql.trim() !== '') {
logAction(LOG_ACTIONS_SQLLAB_RUN_QUERY, { shortcut: true });
startQuery();
}
},
},
{
name: 'runQuery2',
key: KeyboardShortcut.CtrlEnter,
descr: KEY_MAP[KeyboardShortcut.CtrlEnter],
func: () => {
if (queryEditor.sql.trim() !== '') {
logAction(LOG_ACTIONS_SQLLAB_RUN_QUERY, { shortcut: true });
startQuery();
}
},
},
{
name: 'newTab',
...(userOS === 'Windows'
? {
key: KeyboardShortcut.CtrlQ,
descr: KEY_MAP[KeyboardShortcut.CtrlQ],
}
: {
key: KeyboardShortcut.CtrlT,
descr: KEY_MAP[KeyboardShortcut.CtrlT],
}),
func: () => {
Logger.markTimeOrigin();
dispatch(addNewQueryEditor());
},
},
{
name: 'stopQuery',
...(userOS === 'MacOS'
? {
key: KeyboardShortcut.CtrlX,
descr: KEY_MAP[KeyboardShortcut.CtrlX],
}
: {
key: KeyboardShortcut.CtrlE,
descr: KEY_MAP[KeyboardShortcut.CtrlE],
}),
func: () => {
logAction(LOG_ACTIONS_SQLLAB_STOP_QUERY, { shortcut: true });
stopQuery();
},
},
{
name: 'formatQuery',
key: KeyboardShortcut.CtrlShiftF,
descr: KEY_MAP[KeyboardShortcut.CtrlShiftF],
func: () => {
formatCurrentQuery(true);
},
},
{
name: 'switchTabToLeft',
key: KeyboardShortcut.CtrlLeft,
descr: KEY_MAP[KeyboardShortcut.CtrlLeft],
func: () => {
dispatch(switchQueryEditor(true));
},
},
{
name: 'switchTabToRight',
key: KeyboardShortcut.CtrlRight,
descr: KEY_MAP[KeyboardShortcut.CtrlRight],
func: () => {
dispatch(switchQueryEditor(false));
},
},
];
}, [dispatch, queryEditor.sql, startQuery, stopQuery, formatCurrentQuery]);
const hotkeys = useMemo(() => {
// Get all hotkeys including editor hotkeys
// Get the user's OS
const userOS = detectOS();
type EditorHandle = editors.EditorHandle;
/**
* Find the position of a semicolon in the given direction from a starting position.
* Returns the position after the semicolon (for backwards) or at the semicolon (for forwards).
*/
const findSemicolon = (
lines: string[],
fromLine: number,
fromColumn: number,
backwards: boolean,
): { line: number; column: number } | null => {
if (backwards) {
// Search backwards: start from current position going up
for (let line = fromLine; line >= 0; line -= 1) {
const lineText = lines[line];
const searchEnd = line === fromLine ? fromColumn : lineText.length;
// Search from right to left within the line
const idx = lineText.lastIndexOf(';', searchEnd - 1);
if (idx !== -1) {
// Return position after the semicolon
return { line, column: idx + 1 };
}
}
} else {
// Search forwards: start from current position going down
for (let line = fromLine; line < lines.length; line += 1) {
const lineText = lines[line];
const searchStart = line === fromLine ? fromColumn + 1 : 0;
const idx = lineText.indexOf(';', searchStart);
if (idx !== -1) {
// Return position at the semicolon (end of statement)
return { line, column: idx + 1 };
}
}
}
return null;
};
const base = [
...getHotkeyConfig(),
{
name: 'runQuery3',
key: KeyboardShortcut.CtrlShiftEnter,
descr: KEY_MAP[KeyboardShortcut.CtrlShiftEnter],
func: (editor: EditorHandle) => {
const value = editor.getValue();
if (!value.trim()) {
return;
}
const lines = value.split('\n');
const cursorPosition = editor.getCursorPosition();
const totalLines = lines.length;
// Find the end of the statement (next semicolon or end of file)
const semicolonEnd = findSemicolon(
lines,
cursorPosition.line,
cursorPosition.column,
false,
);
let end: { line: number; column: number };
if (semicolonEnd && semicolonEnd.line >= cursorPosition.line) {
end = semicolonEnd;
} else {
// No semicolon found forward, use end of file
const lastLineIndex = totalLines - 1;
end = {
line: lastLineIndex,
column: lines[lastLineIndex]?.length ?? 0,
};
}
// Find the start of the statement (previous semicolon or start of file)
const semicolonStart = findSemicolon(
lines,
cursorPosition.line,
cursorPosition.column,
true,
);
let start: { line: number; column: number } | undefined;
if (semicolonStart) {
start = semicolonStart;
}
// Determine the starting line
let currentLine = start?.line;
if (
currentLine === undefined ||
currentLine > cursorPosition.line ||
(currentLine === cursorPosition.line &&
(start?.column || 0) > cursorPosition.column)
) {
currentLine = 0;
}
// Skip empty lines to find actual content
let content =
currentLine === start?.line && start
? lines[currentLine].slice(start.column).trim()
: (lines[currentLine]?.trim() ?? '');
while (!content && currentLine < totalLines - 1) {
currentLine += 1;
content = lines[currentLine]?.trim() ?? '';
}
// Adjust start if we skipped lines
if (start === undefined || currentLine !== start.line) {
start = { line: currentLine, column: 0 };
}
// Set selection and run query
editor.setSelection({
start: start ?? { line: 0, column: 0 },
end,
});
startQuery();
// Clear selection and restore cursor position
editor.setSelection({
start: cursorPosition,
end: cursorPosition,
});
editor.moveCursorToPosition(cursorPosition);
editor.scrollToLine(cursorPosition.line);
},
},
];
if (userOS === 'MacOS') {
base.push({
name: 'previousLine',
key: KeyboardShortcut.CtrlP,
descr: KEY_MAP[KeyboardShortcut.CtrlP],
func: (editor: EditorHandle) => {
const pos = editor.getCursorPosition();
if (pos.line > 0) {
editor.moveCursorToPosition({
line: pos.line - 1,
column: pos.column,
});
}
},
});
}
return base;
}, [getHotkeyConfig, startQuery]);
const onBeforeUnload = useEffectEvent(event => {
if (
database?.extra_json?.cancel_query_on_windows_unload &&
latestQuery?.state === 'running'
) {
event.preventDefault();
stopQuery();
}
});
const shouldLoadQueryEditor =
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
!queryEditor.loaded;
const loadQueryEditor = useEffectEvent(() => {
const duration = Logger.getTimestamp();
logAction(LOG_ACTIONS_SQLLAB_LOAD_TAB_STATE, {
duration,
queryEditorId: queryEditor.id,
inLocalStorage: Boolean(queryEditor.inLocalStorage),
hasLoaded: !shouldLoadQueryEditor,
});
if (shouldLoadQueryEditor) {
dispatch(fetchQueryEditor(queryEditor, displayLimit));
}
});
useEffect(() => {
if (isActive) {
loadQueryEditor();
window.addEventListener('beforeunload', onBeforeUnload);
}
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
// TODO: Remove useEffectEvent deps once https://github.com/facebook/react/pull/25881 is released
}, [onBeforeUnload, loadQueryEditor, isActive]);
useEffect(() => {
// setup hotkeys
const hotkeys = getHotkeyConfig();
if (isActive) {
// MouseTrap always override the same key
// Unbind (reset) will be called when App component unmount
hotkeys.forEach(keyConfig => {
Mousetrap.bind([keyConfig.key], keyConfig.func);
});
}
}, [getHotkeyConfig, latestQuery, isActive]);
const onResizeStart = () => {
// Set the heights on the ace editor and the ace content area after drag starts
// to smooth out the visual transition to the new heights when drag ends
const editorEl = document.getElementsByClassName(
'ace_content',
)[0] as HTMLElement;
if (editorEl) {
editorEl.style.height = '100%';
}
};
const onResizeEnd = ([nHeight, sHeight]: number[]) => {
const northPercent = Math.round((nHeight * 100) / (nHeight + sHeight));
const southPercent = 100 - northPercent;
setNorthPercent(northPercent);
dispatch(persistEditorHeight(queryEditor, northPercent, southPercent));
};
const setQueryEditorAndSaveSql = useCallback(
(sql: string) => {
dispatch(queryEditorSetAndSaveSql(queryEditor, sql, undefined));
},
[dispatch, queryEditor],
);
const setQueryEditorAndSaveSqlWithDebounce = useMemo(
() => debounce(setQueryEditorAndSaveSql, SET_QUERY_EDITOR_SQL_DEBOUNCE_MS),
[setQueryEditorAndSaveSql],
);
const onSqlChanged = useEffectEvent((sql: string) => {
currentSQL.current = sql;
dispatch(queryEditorSetSql(queryEditor, sql, undefined));
});
const getQueryCostEstimate = () => {
logAction(LOG_ACTIONS_SQLLAB_ESTIMATE_QUERY_COST, { shortcut: false });
if (database) {
dispatch(estimateQueryCost(queryEditor));
}
};
const handleToggleAutocompleteEnabled = () => {
setItem(LocalStorageKeys.SqllabIsAutocompleteEnabled, !autocompleteEnabled);
setAutocompleteEnabled(!autocompleteEnabled);
};
const handleToggleRenderHTMLEnabled = () => {
setItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, !renderHTMLEnabled);
setRenderHTMLEnabled(!renderHTMLEnabled);
};
const createTableAs = () => {
startQuery(true, CtasEnum.Table);
setShowCreateAsModal(false);
setCtas('');
};
const createViewAs = () => {
startQuery(true, CtasEnum.View);
setShowCreateAsModal(false);
setCtas('');
};
const ctasChanged = (event: ChangeEvent<HTMLInputElement>) => {
setCtas(event.target.value);
};
const getSecondaryMenuItems = () => {
const qe = queryEditor;
const successful = latestQuery?.state === 'success';
const scheduleToolTip = successful
? t('Schedule the query periodically')
: t('You must run the query successfully first');
const menuItems: MenuItemType[] = [
{
key: 'render-html',
label: (
<div css={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{t('Render HTML')}</span>{' '}
<Switch
checked={renderHTMLEnabled}
onChange={(checked, event) => {
event.stopPropagation();
handleToggleRenderHTMLEnabled();
}}
/>
</div>
),
},
{
key: 'autocomplete',
label: (
<div css={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{t('Autocomplete')}</span>
<Switch
checked={autocompleteEnabled}
onChange={(checked, event) => {
event.stopPropagation();
handleToggleAutocompleteEnabled();
}}
/>
</div>
),
},
isFeatureEnabled(FeatureFlag.EnableTemplateProcessing) && {
key: 'template-params',
label: (
<TemplateParamsEditor
language="json"
onChange={params => {
dispatch(queryEditorSetTemplateParams(qe, params));
}}
queryEditorId={qe.id}
/>
),
},
{
key: 'format-sql',
label: t('Format SQL'),
onClick: () => formatCurrentQuery(),
},
!isEmpty(scheduledQueriesConf) && {
key: 'schedule-query',
label: (
<ScheduleQueryButton
defaultLabel={qe.name}
sql={qe.sql}
onSchedule={(query: Query) => dispatch(scheduleQuery(query))}
schema={qe.schema}
dbId={qe.dbId}
scheduleQueryWarning={scheduleQueryWarning}
tooltip={scheduleToolTip}
disabled={!successful}
/>
),
},
{
key: 'keyboard-shortcuts',
label: (
<KeyboardShortcutButton>
{t('Keyboard shortcuts')}
</KeyboardShortcutButton>
),
},
].filter(Boolean) as MenuItemType[];
return menuItems;
};
const onSaveQuery = async (query: QueryPayload, clientId: string) => {
const savedQuery = await dispatch(saveQuery(query, clientId));
dispatch(
addSavedQueryToTabState(
queryEditor,
savedQuery as unknown as { remoteId: string },
),
);
};
const renderEditorPrimaryAction = () => {
const { allow_ctas: allowCTAS, allow_cvas: allowCVAS } = database || {};
const showMenu = allowCTAS || allowCVAS;
const menuItems: MenuItemType[] = [
allowCTAS && {
key: '1',
label: t('CREATE TABLE AS'),
onClick: () => {
logAction(LOG_ACTIONS_SQLLAB_CREATE_TABLE_AS, {
shortcut: false,
});
setShowCreateAsModal(true);
setCreateAs(CtasEnum.Table);
},
},
allowCVAS && {
key: '2',
label: t('CREATE VIEW AS'),
onClick: () => {
logAction(LOG_ACTIONS_SQLLAB_CREATE_VIEW_AS, {
shortcut: false,
});
setShowCreateAsModal(true);
setCreateAs(CtasEnum.View);
},
},
].filter(Boolean) as MenuItemType[];
const runMenuBtn = <Menu items={menuItems} />;
return (
<>
<RunQueryActionButton
queryEditorId={queryEditor.id}
queryState={latestQuery?.state}
runQuery={runQuery}
stopQuery={stopQuery}
overlayCreateAsMenu={showMenu ? runMenuBtn : null}
/>
<QueryLimitSelect
queryEditorId={queryEditor.id}
maxRow={maxRow}
defaultQueryLimit={defaultQueryLimit}
/>
<Divider type="vertical" />
{isFeatureEnabled(FeatureFlag.EstimateQueryCost) &&
database?.allows_cost_estimate && (
<EstimateQueryCostButton
getEstimate={getQueryCostEstimate}
queryEditorId={queryEditor.id}
tooltip={t('Estimate the cost before running a query')}
/>
)}
<SaveQuery
queryEditorId={queryEditor.id}
columns={latestQuery?.results?.columns || []}
onSave={onSaveQuery}
onUpdate={(query, remoteId) =>
dispatch(updateSavedQuery(query, remoteId))
}
saveQueryWarning={saveQueryWarning}
database={database}
/>
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
</>
);
};
const renderEmptyAlert = () => (
<StyledToolbar className="sql-toolbar" id="js-sql-toolbar">
<Alert
type="warning"
message={t(
'The database that was used to generate this query could not be found',
)}
description={t(
'Choose one of the available databases on the left panel.',
)}
closable={false}
/>
</StyledToolbar>
);
const handleCursorPositionChange = (newPosition: CursorPosition) => {
dispatch(queryEditorSetCursorPosition(queryEditor, newPosition));
};
const copyQuery = (callback: (text: string) => void) => {
callback(currentSQL.current);
};
const renderCopyQueryButton = () => (
<Button type="primary">{t('COPY QUERY')}</Button>
);
const renderDatasetWarning = () => (
<Alert
css={css`
margin-bottom: ${theme.sizeUnit * 2}px;
padding-top: ${theme.sizeUnit * 4}px;
.ant-alert-action {
align-self: center;
}
`}
type="info"
action={
<CopyToClipboard
wrapText={false}
copyNode={renderCopyQueryButton()}
getText={copyQuery}
/>
}
description={
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
`}
>
<div
css={css`
display: flex;
flex-direction: column;
`}
>
<p
css={css`
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorPrimaryText};
margin: 0px;
`}
>
{' '}
{t(`You are editing a query from the virtual dataset `) +
queryEditor.name}
</p>
<p
css={css`
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorPrimaryText};
margin: 0px;
`}
>
{t(
'After making the changes, copy the query and paste in the virtual dataset SQL snippet settings.',
)}{' '}
</p>
</div>
</div>
}
message=""
/>
);
const queryPane = () => (
<Splitter
layout="vertical"
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
>
<Splitter.Panel
min={queryEditor.isDataset ? 400 : 200}
defaultSize={`${northPercent}%`}
className="queryPane"
>
<div className="north-pane">
{showEmptyState ? (
renderEmptyAlert()
) : (
<SqlEditorTopBar
queryEditorId={queryEditor.id}
defaultPrimaryActions={renderEditorPrimaryAction()}
defaultSecondaryActions={getSecondaryMenuItems()}
/>
)}
{queryEditor.isDataset && renderDatasetWarning()}
<div className="sql-container">
<AutoSizer disableWidth>
{({ height }) =>
isActive && (
<EditorWrapper
autocomplete={autocompleteEnabled}
onBlur={onSqlChanged}
onChange={onSqlChanged}
queryEditorId={queryEditor.id}
onCursorPositionChange={handleCursorPositionChange}
height={`${height}px`}
hotkeys={hotkeys}
/>
)
}
</AutoSizer>
</div>
{SqlFormExtension && (
<SqlFormExtension
queryEditorId={queryEditor.id}
setQueryEditorAndSaveSqlWithDebounce={
setQueryEditorAndSaveSqlWithDebounce
}
startQuery={startQuery}
/>
)}
</div>
</Splitter.Panel>
<Splitter.Panel className="queryPane">
<SouthPane
queryEditorId={queryEditor.id}
latestQueryId={latestQuery?.id}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
/>
</Splitter.Panel>
</Splitter>
);
const createViewModalTitle =
createAs === CtasEnum.View ? 'CREATE VIEW AS' : 'CREATE TABLE AS';
const createModalPlaceHolder =
createAs === CtasEnum.View
? t('Specify name to CREATE VIEW AS schema in: public')
: t('Specify name to CREATE TABLE AS schema in: public');
return (
<StyledSqlEditor className="SqlEditor">
{shouldLoadQueryEditor ? (
<div
data-test="sqlEditor-loading"
css={css`
flex: 1;
padding: ${theme.sizeUnit * 4}px;
`}
>
<Skeleton active />
</div>
) : showEmptyState && !hasSqlStatement ? (
<EmptyState
image="vector.svg"
size="large"
title={t('Select a database to write a query')}
description={t(
'Choose one of the available databases from the panel on the left.',
)}
/>
) : (
queryPane()
)}
<Modal
show={showCreateAsModal}
name={t(createViewModalTitle)}
title={t(createViewModalTitle)}
onHide={() => setShowCreateAsModal(false)}
footer={
<>
<Button onClick={() => setShowCreateAsModal(false)}>
{t('Cancel')}
</Button>
{createAs === CtasEnum.Table && (
<Button
buttonStyle="primary"
disabled={ctas.length === 0}
onClick={createTableAs}
>
{t('Create')}
</Button>
)}
{createAs === CtasEnum.View && (
<Button
buttonStyle="primary"
disabled={ctas.length === 0}
onClick={createViewAs}
>
{t('Create')}
</Button>
)}
</>
}
>
<span>{t('Name')}</span>
<Input placeholder={createModalPlaceHolder} onChange={ctasChanged} />
</Modal>
</StyledSqlEditor>
);
};
export default SqlEditor;