diff --git a/package-lock.json b/package-lock.json index e1d58a3245..6859dbf9ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", "history": "^4.10.1", + "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", "monaco-editor": "^0.52.2", "numeral": "^2.0.6", @@ -13421,6 +13422,15 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "node_modules/hotkeys-js": { + "version": "3.13.9", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz", + "integrity": "sha512-3TRCj9u9KUH6cKo25w4KIdBfdBfNRjfUwrljCLDC2XhmPDG0SjAZFcFZekpUZFmXzfYoGhFDcdx2gX/vUVtztQ==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", diff --git a/package.json b/package.json index 80134c3289..1a9035db75 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", "history": "^4.10.1", + "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", "monaco-editor": "^0.52.2", "numeral": "^2.0.6", diff --git a/src/containers/AsideNavigation/AsideNavigation.scss b/src/containers/AsideNavigation/AsideNavigation.scss index 97300eeff8..d408d209b9 100644 --- a/src/containers/AsideNavigation/AsideNavigation.scss +++ b/src/containers/AsideNavigation/AsideNavigation.scss @@ -19,4 +19,9 @@ width: 300px; padding: 10px; } + + &__hotkeys-panel-title { + display: flex; + gap: var(--g-spacing-2); + } } diff --git a/src/containers/AsideNavigation/AsideNavigation.tsx b/src/containers/AsideNavigation/AsideNavigation.tsx index 66f000156d..c17cd18d45 100644 --- a/src/containers/AsideNavigation/AsideNavigation.tsx +++ b/src/containers/AsideNavigation/AsideNavigation.tsx @@ -2,15 +2,18 @@ import React from 'react'; import {CircleQuestion, Gear, Person} from '@gravity-ui/icons'; import type {MenuItem} from '@gravity-ui/navigation'; -import {AsideHeader, FooterItem} from '@gravity-ui/navigation'; +import {AsideHeader, FooterItem, HotkeysPanel} from '@gravity-ui/navigation'; +import {Hotkey} from '@gravity-ui/uikit'; import type {IconData} from '@gravity-ui/uikit'; +import hotkeys from 'hotkeys-js'; import {useHistory} from 'react-router-dom'; -import {settingsManager} from '../../services/settings'; import {cn} from '../../utils/cn'; -import {ASIDE_HEADER_COMPACT_KEY, LANGUAGE_KEY} from '../../utils/constants'; +import {ASIDE_HEADER_COMPACT_KEY} from '../../utils/constants'; import {useSetting} from '../../utils/hooks'; +import {InformationPopup} from './InformationPopup'; +import {HOTKEYS, SHORTCUTS_HOTKEY} from './constants'; import i18n from './i18n'; import userSecret from '../../assets/icons/user-secret.svg'; @@ -62,26 +65,86 @@ export interface AsideNavigationProps { enum Panel { UserSettings = 'UserSettings', + Information = 'Information', + Hotkeys = 'Hotkeys', } -function getDocumentationLink() { - // Use saved language from settings if it's present, otherwise use browser language - const lang = settingsManager.readUserSettingsValue(LANGUAGE_KEY, navigator.language); +/** + * HotkeysPanelWrapper creates a render cycle separation between mounting and visibility change. + * This is necessary for smooth animations as HotkeysPanel uses CSSTransition internally. + * + * When a component is both mounted and set to visible at once, CSSTransition can't + * properly sequence its transition classes (panel → panel-active) because it's already active when mounted + * and counts transition as it has already happened. + * This wrapper ensures the component mounts first, then sets visible=true in a subsequent render cycle + * to make transition actually happen. + */ +function HotkeysPanelWrapper({ + visiblePanel, + closePanel, +}: { + visiblePanel?: Panel; + closePanel: () => void; +}) { + const [visible, setVisible] = React.useState(false); + + React.useEffect(() => { + setVisible(visiblePanel === Panel.Hotkeys); + }, [visiblePanel]); - if (lang === 'ru') { - return 'https://ydb.tech/docs/ru/'; - } - - return 'https://ydb.tech/docs/en/'; + return ( + + {i18n('help-center.footer.shortcuts')} + + + } + onClose={closePanel} + /> + ); } export function AsideNavigation(props: AsideNavigationProps) { const history = useHistory(); const [visiblePanel, setVisiblePanel] = React.useState(); - + const [informationPopupVisible, setInformationPopupVisible] = React.useState(false); const [compact, setIsCompact] = useSetting(ASIDE_HEADER_COMPACT_KEY); + const toggleInformationPopup = () => setInformationPopupVisible((prev) => !prev); + + const closeInformationPopup = React.useCallback(() => setInformationPopupVisible(false), []); + + const openHotkeysPanel = React.useCallback(() => { + closeInformationPopup(); + setVisiblePanel(Panel.Hotkeys); + }, [closeInformationPopup]); + + const closePanel = React.useCallback(() => { + setVisiblePanel(undefined); + }, []); + + const renderInformationPopup = () => { + return ; + }; + + React.useEffect(() => { + // Register hotkey for keyboard shortcuts + hotkeys(SHORTCUTS_HOTKEY, openHotkeysPanel); + + // Add listener for custom event from Monaco editor + window.addEventListener('openKeyboardShortcutsPanel', openHotkeysPanel); + + return () => { + hotkeys.unbind(SHORTCUTS_HOTKEY); + window.removeEventListener('openKeyboardShortcutsPanel', openHotkeysPanel); + }; + }, [openHotkeysPanel]); + return ( { - window.open(getDocumentationLink(), '_blank', 'noreferrer'); - }, + current: informationPopupVisible, + onItemClick: toggleInformationPopup, }} + enableTooltip={!informationPopupVisible} + popupVisible={informationPopupVisible} + onClosePopup={closeInformationPopup} + renderPopupContent={renderInformationPopup} /> + ), + }, ]} - onClosePanel={() => { - setVisiblePanel(undefined); - }} + onClosePanel={closePanel} /> ); diff --git a/src/containers/AsideNavigation/InformationPopup/InformationPopup.scss b/src/containers/AsideNavigation/InformationPopup/InformationPopup.scss new file mode 100644 index 0000000000..c70125376e --- /dev/null +++ b/src/containers/AsideNavigation/InformationPopup/InformationPopup.scss @@ -0,0 +1,105 @@ +@import '../../../styles/mixins.scss'; + +:root { + --information-popup-padding: 16px; + --information-popup-header-padding: 16px; +} + +.information-popup { + &__content { + position: relative; + + display: flex; + flex-direction: column; + + box-sizing: border-box; + width: 280px; + padding: var(--information-popup-header-padding) 0 0 0; + } + + &__docs, + &__footer { + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + &__docs { + padding-bottom: 8px; + } + + &__footer { + position: relative; + + padding: 12px 0 8px; + + border-top: 1px solid var(--g-color-line-generic); + background-color: var(--g-color-base-generic); + } + + &__title { + flex-shrink: 0; + + margin-bottom: 4px; + padding: 4px var(--information-popup-padding); + } + + &__docs-list-wrap { + display: flex; + flex-direction: column; + flex-shrink: 0; + + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } + } + + &__docs-link, + &__shortcuts-item { + display: flex; + flex-grow: 1; + align-items: center; + + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 8px var(--information-popup-padding); + + line-height: var(--g-text-body-1-line-height); + cursor: pointer; + + &:hover { + background-color: var(--g-color-base-simple-hover); + } + } + + &__shortcuts-item { + justify-content: space-between; + } + + &__docs-link { + &, + &:hover, + &:active, + &:visited, + &:focus { + text-decoration: none; + + color: inherit; + outline: none; + } + } + + &__item-icon-wrap { + width: 16px; + height: 16px; + margin-right: 10px; + } + + &__shortcuts-content { + display: flex; + align-items: center; + } +} diff --git a/src/containers/AsideNavigation/InformationPopup/InformationPopup.tsx b/src/containers/AsideNavigation/InformationPopup/InformationPopup.tsx new file mode 100644 index 0000000000..d62df84c39 --- /dev/null +++ b/src/containers/AsideNavigation/InformationPopup/InformationPopup.tsx @@ -0,0 +1,73 @@ +import {Keyboard} from '@gravity-ui/icons'; +import {Flex, Hotkey, Icon, Link, List, Text} from '@gravity-ui/uikit'; + +import {settingsManager} from '../../../services/settings'; +import {cn} from '../../../utils/cn'; +import {LANGUAGE_KEY} from '../../../utils/constants'; +import {SHORTCUTS_HOTKEY} from '../constants'; +import i18n from '../i18n'; + +import './InformationPopup.scss'; + +const b = cn('information-popup'); + +export interface InformationPopupProps { + onKeyboardShortcutsClick?: () => void; +} + +export function InformationPopup({onKeyboardShortcutsClick}: InformationPopupProps) { + const getDocumentationLink = () => { + const lang = settingsManager.readUserSettingsValue(LANGUAGE_KEY, navigator.language); + return lang === 'ru' ? 'https://ydb.tech/docs/ru/' : 'https://ydb.tech/docs/en/'; + }; + + return ( +
+
+ + Documentation + +
+ ( + + {text} + + )} + itemClassName={b('item')} + /> +
+
+ +
+ + +
+ +
+ {i18n('help-center.footer.shortcuts')} +
+ +
+
+
+ ); +} diff --git a/src/containers/AsideNavigation/InformationPopup/index.ts b/src/containers/AsideNavigation/InformationPopup/index.ts new file mode 100644 index 0000000000..3304979d68 --- /dev/null +++ b/src/containers/AsideNavigation/InformationPopup/index.ts @@ -0,0 +1 @@ +export {InformationPopup} from './InformationPopup'; diff --git a/src/containers/AsideNavigation/InformationPopup/types.ts b/src/containers/AsideNavigation/InformationPopup/types.ts new file mode 100644 index 0000000000..835e6c550e --- /dev/null +++ b/src/containers/AsideNavigation/InformationPopup/types.ts @@ -0,0 +1,13 @@ +import type React from 'react'; + +export interface FooterItem { + id: string; + text: string; + url?: string; + rightContent?: React.ReactNode; + onClick?: () => void; + icon?: React.ReactNode; + disableClickHandler?: boolean; +} + +export type FooterItemsArray = FooterItem[]; diff --git a/src/containers/AsideNavigation/constants.tsx b/src/containers/AsideNavigation/constants.tsx new file mode 100644 index 0000000000..89b70451f8 --- /dev/null +++ b/src/containers/AsideNavigation/constants.tsx @@ -0,0 +1,35 @@ +import {isMac} from './utils'; + +export const SHORTCUTS_HOTKEY = isMac() ? 'cmd+K' : 'ctrl+K'; + +export const HOTKEYS = [ + { + title: 'Query Editor', + items: [ + { + title: 'Execute query', + value: isMac() ? 'cmd+enter' : 'ctrl+enter', + }, + { + title: 'Execute selected query', + value: isMac() ? 'cmd+shift+enter' : 'ctrl+shift+enter', + }, + { + title: 'Previous query', + value: isMac() ? 'cmd+arrowUp' : 'ctrl+arrowUp', + }, + { + title: 'Next query', + value: isMac() ? 'cmd+arrowDown' : 'ctrl+arrowDown', + }, + { + title: 'Save query', + value: isMac() ? 'cmd+s' : 'ctrl+s', + }, + { + title: 'Save selected query', + value: isMac() ? 'cmd+shift+s' : 'ctrl+shift+s', + }, + ], + }, +]; diff --git a/src/containers/AsideNavigation/i18n/en.json b/src/containers/AsideNavigation/i18n/en.json index 069c9981c7..22dd6b7c8c 100644 --- a/src/containers/AsideNavigation/i18n/en.json +++ b/src/containers/AsideNavigation/i18n/en.json @@ -1,8 +1,12 @@ { - "navigation-item.documentation": "Documentation", + "navigation-item.information": "information", "navigation-item.settings": "Settings", "navigation-item.account": "Account", + "help-center.header.title": "Documentation", + "help-center.item.documentation": "View documentation", + "help-center.footer.shortcuts": "Keyboard shortcuts", + "account.user": "YDB User", "account.login": "Login", diff --git a/src/containers/AsideNavigation/utils.ts b/src/containers/AsideNavigation/utils.ts new file mode 100644 index 0000000000..6125308da8 --- /dev/null +++ b/src/containers/AsideNavigation/utils.ts @@ -0,0 +1 @@ +export const isMac = () => navigator.platform.toUpperCase().includes('MAC'); diff --git a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx index 229b809cea..1c4771f300 100644 --- a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx @@ -88,7 +88,6 @@ export function YqlEditor({ monacoGhostInstance?.unregister(); }; }, [isCodeAssistEnabled, monacoGhostConfig, monacoGhostInstance, prepareUserQueriesCache]); - const editorDidMount = (editor: Monaco.editor.IStandaloneCodeEditor, monaco: typeof Monaco) => { window.ydbEditor = editor; const keybindings = getKeyBindings(monaco); @@ -183,6 +182,18 @@ export function YqlEditor({ NiceModal.show(SAVE_QUERY_DIALOG); }, }); + editor.addAction({ + id: 'openKeyboardShortcutsPanel', + label: i18n('action.open-shortcuts'), + keybindings: [keybindings.shortcutsHotkey], + contextMenuGroupId: CONTEXT_MENU_GROUP_ID, + contextMenuOrder: 4, + run: () => { + // Dispatch an event that can be caught by the AsideNavigation component + const event = new CustomEvent('openKeyboardShortcutsPanel'); + window.dispatchEvent(event); + }, + }); }; const onChange = (newValue: string) => { diff --git a/src/containers/Tenant/Query/QueryEditor/keybindings.ts b/src/containers/Tenant/Query/QueryEditor/keybindings.ts index 2580073b4f..c2088d7381 100644 --- a/src/containers/Tenant/Query/QueryEditor/keybindings.ts +++ b/src/containers/Tenant/Query/QueryEditor/keybindings.ts @@ -13,5 +13,6 @@ export function getKeyBindings(monaco: typeof Monaco) { selectNextQuery: ctrlKey | KeyCode.DownArrow, saveQuery: ctrlKey | KeyCode.KeyS, saveSelectedQuery: ctrlKey | KeyMod.Shift | KeyCode.KeyS, + shortcutsHotkey: ctrlKey | KeyCode.KeyK, }; } diff --git a/src/containers/Tenant/Query/i18n/en.json b/src/containers/Tenant/Query/i18n/en.json index d036e3db6f..6db9fd0213 100644 --- a/src/containers/Tenant/Query/i18n/en.json +++ b/src/containers/Tenant/Query/i18n/en.json @@ -51,6 +51,7 @@ "action.stop": "Stop", "action.run": "Run", "action.explain": "Explain", + "action.open-shortcuts": "Open Keyboard Shortcuts Panel", "filter.text.placeholder": "Search by query text...", diff --git a/tests/suites/sidebar/Sidebar.ts b/tests/suites/sidebar/Sidebar.ts index 16dc958593..dedc3eb8f7 100644 --- a/tests/suites/sidebar/Sidebar.ts +++ b/tests/suites/sidebar/Sidebar.ts @@ -5,18 +5,24 @@ export class Sidebar { private logoButton: Locator; private footer: Locator; private settingsButton: Locator; - private documentationButton: Locator; + private informationButton: Locator; private accountButton: Locator; private collapseButton: Locator; private drawer: Locator; private drawerMenu: Locator; private experimentsSection: Locator; + private popupContent: Locator; + private hotkeysButton: Locator; + private hotkeysPanel: Locator; constructor(page: Page) { this.sidebarContainer = page.locator('.gn-aside-header__aside-content'); this.logoButton = this.sidebarContainer.locator('.gn-logo__btn-logo'); this.footer = this.sidebarContainer.locator('.gn-aside-header__footer'); this.drawer = page.locator('.gn-drawer'); + this.popupContent = page.locator('.g-popup__content'); + this.hotkeysButton = this.popupContent.locator('text=Keyboard shortcuts'); + this.hotkeysPanel = page.locator('.gn-hotkeys-panel__drawer-item'); this.drawerMenu = page.locator('.gn-settings-menu'); this.experimentsSection = this.drawerMenu .locator('.gn-settings-menu__item') @@ -24,7 +30,7 @@ export class Sidebar { // Footer buttons with specific icons const footerItems = this.sidebarContainer.locator('.gn-footer-item'); - this.documentationButton = footerItems.filter({hasText: 'Documentation'}); + this.informationButton = footerItems.filter({hasText: 'Information'}); this.settingsButton = footerItems .filter({hasText: 'Settings'}) .locator('.gn-composite-bar-item__btn-icon'); @@ -49,8 +55,8 @@ export class Sidebar { return this.settingsButton.isVisible(); } - async isDocumentationButtonVisible() { - return this.documentationButton.isVisible(); + async isInformationButtonVisible() { + return this.informationButton.isVisible(); } async isAccountButtonVisible() { @@ -65,8 +71,34 @@ export class Sidebar { await this.settingsButton.click(); } - async clickDocumentation() { - await this.documentationButton.click(); + async clickInformation() { + await this.informationButton.click(); + } + + async isPopupVisible() { + return this.popupContent.isVisible(); + } + + async hasHotkeysButtonInPopup() { + return this.hotkeysButton.isVisible(); + } + + async clickHotkeysButton() { + await this.hotkeysButton.click(); + } + + async isHotkeysPanelVisible() { + return this.hotkeysPanel.isVisible(); + } + + async hasHotkeysPanelTitle() { + const panelTitle = this.hotkeysPanel.locator('.kv-navigation__hotkeys-panel-title'); + return panelTitle.isVisible(); + } + + async hasDocumentationInPopup() { + const documentationElement = this.popupContent.locator('text=View documentation'); + return documentationElement.isVisible(); } async clickAccount() { diff --git a/tests/suites/sidebar/sidebar.test.ts b/tests/suites/sidebar/sidebar.test.ts index 0103d95116..b17a4a64b3 100644 --- a/tests/suites/sidebar/sidebar.test.ts +++ b/tests/suites/sidebar/sidebar.test.ts @@ -1,7 +1,9 @@ import {expect, test} from '@playwright/test'; import {PageModel} from '../../models/PageModel'; +import {tenantName} from '../../utils/constants'; import {toggleExperiment} from '../../utils/toggleExperiment'; +import {TenantPage} from '../tenant/TenantPage'; import {Sidebar} from './Sidebar'; @@ -51,11 +53,52 @@ test.describe('Test Sidebar', async () => { expect(menuItems).toEqual(['General', 'Editor', 'Experiments', 'About']); }); - test('Documentation button is visible and clickable', async ({page}) => { + test('Information button is visible and clickable', async ({page}) => { const sidebar = new Sidebar(page); await sidebar.waitForSidebarToLoad(); - await expect(sidebar.isDocumentationButtonVisible()).resolves.toBe(true); - await sidebar.clickDocumentation(); + await expect(sidebar.isInformationButtonVisible()).resolves.toBe(true); + await sidebar.clickInformation(); + }); + + test('Information popup contains documentation and keyboard shortcuts', async ({page}) => { + const sidebar = new Sidebar(page); + await sidebar.waitForSidebarToLoad(); + + // Click the Information button to open the popup + await sidebar.clickInformation(); + await page.waitForTimeout(500); // Wait for animation + + // Check if the popup is visible + await expect(sidebar.isPopupVisible()).resolves.toBe(true); + + // Check if the popup contains Documentation + await expect(sidebar.hasDocumentationInPopup()).resolves.toBe(true); + + // Check if the popup contains Keyboard shortcuts button + await expect(sidebar.hasHotkeysButtonInPopup()).resolves.toBe(true); + }); + + test('Clicking hotkeys button in information popup opens hotkeys panel with title', async ({ + page, + }) => { + const sidebar = new Sidebar(page); + await sidebar.waitForSidebarToLoad(); + + // Click the Information button to open the popup + await sidebar.clickInformation(); + await page.waitForTimeout(500); // Wait for animation + + // Check if the popup is visible + await expect(sidebar.isPopupVisible()).resolves.toBe(true); + + // Check if hotkeys button is visible and click it + await expect(sidebar.hasHotkeysButtonInPopup()).resolves.toBe(true); + await sidebar.clickHotkeysButton(); + await page.waitForTimeout(500); // Wait for animation + + // Check if hotkeys panel is visible and has the title + await expect(sidebar.isHotkeysPanelVisible()).resolves.toBe(true); + await expect(sidebar.hasHotkeysPanelTitle()).resolves.toBe(true); }); test('Account button is visible and clickable', async ({page}) => { @@ -65,6 +108,33 @@ test.describe('Test Sidebar', async () => { await sidebar.clickAccount(); }); + test('Pressing Ctrl+K in editor page opens hotkeys panel', async ({page}) => { + // Open editor page + const pageQueryParams = { + schema: tenantName, + database: tenantName, + general: 'query', + }; + + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + await page.waitForTimeout(1000); // Wait for page to load fully + + // Create sidebar instance to check for hotkeys panel + const sidebar = new Sidebar(page); + + // Initially hotkeys panel should not be visible + await expect(sidebar.isHotkeysPanelVisible()).resolves.toBe(false); + + // Press Ctrl+K to open hotkeys panel + await page.keyboard.press('Control+k'); + await page.waitForTimeout(500); // Wait for animation + + // Check if hotkeys panel is visible and has the title + await expect(sidebar.isHotkeysPanelVisible()).resolves.toBe(true); + await expect(sidebar.hasHotkeysPanelTitle()).resolves.toBe(true); + }); + test('Sidebar can be collapsed and expanded', async ({page}) => { const sidebar = new Sidebar(page); await sidebar.waitForSidebarToLoad();