diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx index 17c84a36648ee..3b6b2e67b6249 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx @@ -23,13 +23,14 @@ import { css, styled, usePrevious } from '@superset-ui/core'; import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab'; import { FullSQLEditor as AceEditor } from 'src/components/AsyncAceEditor'; +import type { KeyboardShortcut } from 'src/SqlLab/components/KeyboardShortcutButton'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import { useAnnotations } from './useAnnotations'; import { useKeywords } from './useKeywords'; type HotKey = { - key: string; - descr: string; + key: KeyboardShortcut; + descr?: string; name: string; func: (aceEditor: IAceEditor) => void; }; diff --git a/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/KeyboardShortcutButton.test.tsx b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/KeyboardShortcutButton.test.tsx new file mode 100644 index 0000000000000..9582b9cab3b0a --- /dev/null +++ b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/KeyboardShortcutButton.test.tsx @@ -0,0 +1,34 @@ +/** + * 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. + */ +import React from 'react'; +import { fireEvent, render } from 'spec/helpers/testing-library'; +import KeyboardShortcutButton, { KEY_MAP } from '.'; + +test('renders shortcut description', () => { + const { getByText, getByRole } = render( + Show shortcuts, + ); + fireEvent.click(getByRole('button')); + expect(getByText('Keyboard shortcuts')).toBeInTheDocument(); + Object.keys(KEY_MAP) + .filter(key => Boolean(KEY_MAP[key])) + .forEach(key => { + expect(getByText(key)).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx new file mode 100644 index 0000000000000..306e69e7608c1 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx @@ -0,0 +1,129 @@ +/** + * 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. + */ +import React from 'react'; +import { styled, t, css } from '@superset-ui/core'; +import ModalTrigger from 'src/components/ModalTrigger'; +import { detectOS } from 'src/utils/common'; + +const userOS = detectOS(); + +export enum KeyboardShortcut { + CTRL_R = 'ctrl+r', + CTRL_ENTER = 'ctrl+enter', + CTRL_SHIFT_ENTER = 'ctrl+shift+enter', + CTRL_P = 'ctrl+p', + CTRL_Q = 'ctrl+q', + CTRL_E = 'ctrl+e', + CTRL_T = 'ctrl+t', + CTRL_X = 'ctrl+x', + ALT_ENTER = 'alt+enter', + CMD_F = 'cmd+f', + CMD_OPT_F = 'cmd+opt+f', + CTRL_F = 'ctrl+f', + CTRL_H = 'ctrl+h', +} + +export const KEY_MAP = { + [KeyboardShortcut.CTRL_R]: t('Run query'), + [KeyboardShortcut.CTRL_ENTER]: t('Run query'), + [KeyboardShortcut.ALT_ENTER]: t('Run query'), + [KeyboardShortcut.CTRL_SHIFT_ENTER]: t('Run current query'), + [KeyboardShortcut.CTRL_X]: userOS === 'MacOS' ? t('Stop query') : undefined, + [KeyboardShortcut.CTRL_E]: userOS !== 'MacOS' ? t('Stop query') : undefined, + [KeyboardShortcut.CTRL_Q]: userOS === 'Windows' ? t('New tab') : undefined, + [KeyboardShortcut.CTRL_T]: userOS !== 'Windows' ? t('New tab') : undefined, + [KeyboardShortcut.CTRL_P]: t('Previous Line'), + // default ace editor shortcuts + [KeyboardShortcut.CMD_F]: userOS === 'MacOS' ? t('Find') : undefined, + [KeyboardShortcut.CTRL_F]: userOS !== 'MacOS' ? t('Find') : undefined, + [KeyboardShortcut.CMD_OPT_F]: userOS === 'MacOS' ? t('Replace') : undefined, + [KeyboardShortcut.CTRL_H]: userOS !== 'MacOS' ? t('Replace') : undefined, +}; + +const KeyMapByCommand = Object.entries(KEY_MAP).reduce( + (acc, [shortcut, command]) => { + if (command) { + acc[command] = [...(acc[command] || []), shortcut]; + } + return acc; + }, + {} as Record, +); + +const ShortcutDescription = styled.span` + font-size: ${({ theme }) => theme.typography.sizes.m}px; + color: ${({ theme }) => theme.colors.text.help}; + padding-left: ${({ theme }) => theme.gridUnit * 2}px; +`; + +const ShortcutWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${({ theme }) => theme.gridUnit}px; + padding: ${({ theme }) => theme.gridUnit * 2}px; +`; + +const ShortcutCode = styled.code` + font-size: ${({ theme }) => theme.typography.sizes.s}px; + color: ${({ theme }) => theme.colors.grayscale.dark1}; + border-radius: ${({ theme }) => theme.borderRadius}px; + padding: ${({ theme }) => `${theme.gridUnit}px ${theme.gridUnit * 2}px`}; +`; + +const KeyboardShortcutButton: React.FC<{}> = ({ children }) => ( + + {Object.entries(KeyMapByCommand).map(([description, shortcuts]) => ( +
+
+ {description} +
+
+ + {shortcuts.map(shortcut => ( + {shortcut} + ))} + +
+
+ ))} + + } + triggerNode={children} + /> +); + +export default KeyboardShortcutButton; diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 08ddf2b0fb65a..83bb80d997a28 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -103,6 +103,10 @@ import SqlEditorLeftBar, { ExtendedTable } from '../SqlEditorLeftBar'; import AceEditorWrapper from '../AceEditorWrapper'; import RunQueryActionButton from '../RunQueryActionButton'; import QueryLimitSelect from '../QueryLimitSelect'; +import KeyboardShortcutButton, { + KEY_MAP, + KeyboardShortcut, +} from '../KeyboardShortcutButton'; const bootstrapData = getBootstrapData(); const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES; @@ -114,6 +118,7 @@ const StyledToolbar = styled.div` justify-content: space-between; border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; border-top: 0; + column-gap: ${({ theme }) => theme.gridUnit}px; form { margin-block-end: 0; @@ -333,8 +338,8 @@ const SqlEditor: React.FC = ({ return [ { name: 'runQuery1', - key: 'ctrl+r', - descr: t('Run query'), + key: KeyboardShortcut.CTRL_R, + descr: KEY_MAP[KeyboardShortcut.CTRL_R], func: () => { if (queryEditor.sql.trim() !== '') { startQuery(); @@ -343,8 +348,8 @@ const SqlEditor: React.FC = ({ }, { name: 'runQuery2', - key: 'ctrl+enter', - descr: t('Run query'), + key: KeyboardShortcut.CTRL_ENTER, + descr: KEY_MAP[KeyboardShortcut.CTRL_ENTER], func: () => { if (queryEditor.sql.trim() !== '') { startQuery(); @@ -353,16 +358,30 @@ const SqlEditor: React.FC = ({ }, { name: 'newTab', - key: userOS === 'Windows' ? 'ctrl+q' : 'ctrl+t', - descr: t('New tab'), + ...(userOS === 'Windows' + ? { + key: KeyboardShortcut.CTRL_Q, + descr: KEY_MAP[KeyboardShortcut.CTRL_Q], + } + : { + key: KeyboardShortcut.CTRL_T, + descr: KEY_MAP[KeyboardShortcut.CTRL_T], + }), func: () => { dispatch(addNewQueryEditor()); }, }, { name: 'stopQuery', - key: userOS === 'MacOS' ? 'ctrl+x' : 'ctrl+e', - descr: t('Stop query'), + ...(userOS === 'MacOS' + ? { + key: KeyboardShortcut.CTRL_X, + descr: KEY_MAP[KeyboardShortcut.CTRL_X], + } + : { + key: KeyboardShortcut.CTRL_E, + descr: KEY_MAP[KeyboardShortcut.CTRL_E], + }), func: stopQuery, }, ]; @@ -376,8 +395,8 @@ const SqlEditor: React.FC = ({ ...getHotkeyConfig(), { name: 'runQuery3', - key: 'ctrl+shift+enter', - descr: t('Run current query'), + key: KeyboardShortcut.CTRL_SHIFT_ENTER, + descr: KEY_MAP[KeyboardShortcut.CTRL_SHIFT_ENTER], func: (editor: AceEditor['editor']) => { if (!editor.getValue().trim()) { return; @@ -434,8 +453,8 @@ const SqlEditor: React.FC = ({ if (userOS === 'MacOS') { base.push({ name: 'previousLine', - key: 'ctrl+p', - descr: t('Previous Line'), + key: KeyboardShortcut.CTRL_P, + descr: KEY_MAP[KeyboardShortcut.CTRL_P], func: editor => { editor.navigateUp(); }, @@ -617,6 +636,11 @@ const SqlEditor: React.FC = ({ /> )} + + + {t('Keyboard shortcuts')} + + ); };