Skip to content

Commit beebfb4

Browse files
authored
feat: implement hotkeys in new ui (#736)
* feat: implement hotkeys in new ui * feat: add hotkeys panel to the menu
1 parent f6ed861 commit beebfb4

File tree

33 files changed

+1195
-105
lines changed

33 files changed

+1195
-105
lines changed

lib/static/new-ui/components/AsidePanel/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface AsidePanelProps {
1212

1313
export function AsidePanel(props: AsidePanelProps): ReactNode {
1414
return <div className={classNames(styles.container, props.className)}>
15-
<h2 className={classNames('text-display-1')}>{props.title}</h2>
15+
<h2 className={classNames('text-display-1')} data-qa="aside-panel-title">{props.title}</h2>
1616
<Divider className={styles.divider} orientation={'horizontal'} />
1717
{props.children}
1818
</div>;

lib/static/new-ui/components/AttemptPickerItem/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,5 @@ export const AttemptPickerItem = (props: AttemptPickerItemProps): ReactNode => {
9090
{[styles['attempt-picker-item--non-matched']]: isGroupingEnabled && !matchedSelectedGroup}
9191
);
9292

93-
return <Button {...buttonStyle} title={title} className={className} onClick={onClick} qa={'retry-switcher'}>{result.attempt + 1}</Button>;
93+
return <Button {...buttonStyle} title={title} className={className} onClick={onClick} qa={'retry-switcher'} data-qa-active={isActive}>{result.attempt + 1}</Button>;
9494
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.hotkeys-panel {
2+
height: 100%;
3+
}
4+
5+
.divider {
6+
margin: 16px 0;
7+
}
8+
9+
.hotkeys-list {
10+
display: flex;
11+
flex-direction: column;
12+
gap: 12px;
13+
margin-top: 12px;
14+
}
15+
16+
.hotkey-item {
17+
display: flex;
18+
justify-content: space-between;
19+
align-items: center;
20+
gap: 16px;
21+
}
22+
23+
.hotkey-title {
24+
color: var(--color-neutral-500);
25+
font-size: 15px;
26+
}
27+
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {Divider, Hotkey} from '@gravity-ui/uikit';
2+
import React, {ReactNode} from 'react';
3+
4+
import {AsidePanel} from '@/static/new-ui/components/AsidePanel';
5+
import {PanelSection} from '@/static/new-ui/components/PanelSection';
6+
import {HOTKEYS_GROUPS} from '@/static/new-ui/components/MainLayout/hotkeys';
7+
import styles from './index.module.css';
8+
9+
export function HotkeysPanel(): ReactNode {
10+
const sections = HOTKEYS_GROUPS.map((group, groupIndex) => (
11+
<PanelSection key={groupIndex} title={group.title}>
12+
<div className={styles.hotkeysList}>
13+
{group.items.map((item, itemIndex) => (
14+
<div key={itemIndex} className={styles.hotkeyItem}>
15+
<span className={styles.hotkeyTitle}>{item.title}</span>
16+
<Hotkey value={item.value} view="light" />
17+
</div>
18+
))}
19+
</div>
20+
</PanelSection>
21+
));
22+
23+
const lastSection = sections.pop();
24+
25+
return (
26+
<AsidePanel title="Keyboard Shortcuts" className={styles.hotkeysPanel}>
27+
{sections.map((section, index) => (
28+
<React.Fragment key={index}>
29+
{section}
30+
<Divider orientation="horizontal" className={styles.divider} />
31+
</React.Fragment>
32+
))}
33+
{lastSection}
34+
</AsidePanel>
35+
);
36+
}
37+

lib/static/new-ui/components/IconButton/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, {KeyboardEventHandler, MouseEventHandler, ReactNode} from 'react';
33

44
interface IconButtonProps {
55
icon: ReactNode;
6-
tooltip: string;
6+
tooltip: ReactNode;
77
onClick?: MouseEventHandler;
88
onKeyDown?: KeyboardEventHandler;
99
view?: ButtonView;

lib/static/new-ui/components/MainLayout/Footer.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {Gear, CircleInfo} from '@gravity-ui/icons';
1+
import {Gear, CircleInfo, Keyboard} from '@gravity-ui/icons';
22
import {FooterItem, MenuItem as GravityMenuItem} from '@gravity-ui/navigation';
3-
import {Icon} from '@gravity-ui/uikit';
3+
import {Hotkey, Icon} from '@gravity-ui/uikit';
44
import classNames from 'classnames';
55
import React, {ReactNode, useEffect, useState} from 'react';
66
import {useSelector} from 'react-redux';
@@ -45,14 +45,32 @@ export function Footer(props: FooterProps): ReactNode {
4545
}
4646
}, [props.visiblePanel]);
4747

48+
const isHotkeysCurrent = props.visiblePanel === PanelId.Hotkeys;
4849
const isInfoCurrent = props.visiblePanel === PanelId.Info;
4950
const isSettingsCurrent = props.visiblePanel === PanelId.Settings;
5051

5152
return <>
5253
<UiModeHintNotification isVisible={isHintVisible} onClose={(): void => setIsHintVisible(false)} />
54+
<FooterItem compact={false} item={{
55+
id: PanelId.Hotkeys,
56+
title: 'Keyboard Shortcuts',
57+
tooltipText: <>Keyboard Shortcuts <Hotkey value="mod+/" view="dark" /></>,
58+
onItemClick: props.onFooterItemClick,
59+
current: isHotkeysCurrent,
60+
qa: 'footer-item-hotkeys',
61+
itemWrapper: (params, makeItem) => makeItem({
62+
...params,
63+
icon: <Icon className={classNames({
64+
[styles.footerItem]: !isHotkeysCurrent,
65+
[styles['footer-item--active']]: isHotkeysCurrent,
66+
disabled: !isInitialized
67+
})} data={Keyboard} />
68+
})
69+
}} />
5370
<FooterItem compact={false} item={{
5471
id: PanelId.Info,
5572
title: 'Info',
73+
tooltipText: <>Info <Hotkey value="i" view="dark" /></>,
5674
onItemClick: props.onFooterItemClick,
5775
current: isInfoCurrent,
5876
qa: 'footer-item-info',
@@ -68,6 +86,7 @@ export function Footer(props: FooterProps): ReactNode {
6886
<FooterItem compact={false} item={{
6987
id: PanelId.Settings,
7088
title: 'Settings',
89+
tooltipText: <>Settings <Hotkey value="," view="dark" /></>,
7190
onItemClick: props.onFooterItemClick,
7291
current: isSettingsCurrent,
7392
itemWrapper: (params, makeItem) => makeItem({
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type {HotkeysGroup} from '@gravity-ui/navigation';
2+
3+
export const HOTKEYS_GROUPS: HotkeysGroup[] = [
4+
{
5+
title: 'Navigation',
6+
items: [
7+
{title: 'Suites page', value: 's'},
8+
{title: 'Visual Checks page', value: 'v'},
9+
{title: 'Keyboard shortcuts', value: 'mod+/'},
10+
{title: 'Info panel', value: 'i'},
11+
{title: 'Settings panel', value: ','}
12+
]
13+
},
14+
{
15+
title: 'Tests Tree',
16+
items: [
17+
{title: 'Toggle tree sidebar', value: 't'},
18+
{title: 'Focus search', value: 'mod+k'},
19+
{title: 'Clear search', value: 'escape'}
20+
]
21+
},
22+
{
23+
title: 'Working with Tests',
24+
items: [
25+
{title: 'Previous test', value: '↑'},
26+
{title: 'Next test', value: '↓'},
27+
{title: 'Previous attempt', value: '←'},
28+
{title: 'Next attempt', value: '→'},
29+
{title: 'Run current test', value: 'r'},
30+
{title: 'Run all/selected tests', value: 'shift+r'},
31+
{title: 'Accept screenshot', value: 'a'},
32+
{title: 'Undo accept', value: 'u'},
33+
{title: 'Accept all/selected', value: 'shift+a'},
34+
{title: 'Go to Suites / Visual Checks', value: 'g'}
35+
]
36+
},
37+
{
38+
title: 'Time Travel Player',
39+
items: [
40+
{title: 'Show/hide player', value: 'p'},
41+
{title: 'Play/pause', value: 'k'}
42+
]
43+
}
44+
];
45+

lib/static/new-ui/components/MainLayout/index.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import {AsideHeader, MenuItem as GravityMenuItem} from '@gravity-ui/navigation';
22
import classNames from 'classnames';
3-
import React, {ReactNode, useState} from 'react';
3+
import React, {ReactNode, useCallback, useState} from 'react';
44
import {useDispatch, useSelector} from 'react-redux';
55
import {matchPath, useLocation, useNavigate} from 'react-router-dom';
66

77
import {getIsInitialized} from '@/static/new-ui/store/selectors';
88
import {SettingsPanel} from '@/static/new-ui/components/SettingsPanel';
9+
import {HotkeysPanel} from '@/static/new-ui/components/HotkeysPanel';
910
import TestplaneIcon from '../../../icons/testplane-mono.svg';
1011
import styles from './index.module.css';
1112
import {Footer} from './Footer';
1213
import {EmptyReportCard} from '@/static/new-ui/components/Card/EmptyReportCard';
1314
import {InfoPanel} from '@/static/new-ui/components/InfoPanel';
1415
import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics';
16+
import {useHotkey} from '@/static/new-ui/hooks/useHotkey';
1517
import {setSectionSizes} from '../../../modules/actions/suites-page';
1618
import {ArrowLeftToLine, ArrowRightFromLine} from '@gravity-ui/icons';
19+
import {Hotkey} from '@gravity-ui/uikit';
1720
import {isSectionHidden} from '../../features/suites/utils';
1821
import {Page, PathNames} from '@/constants';
1922

2023
export enum PanelId {
24+
Hotkeys = 'hotkeys',
2125
Settings = 'settings',
2226
Info = 'info',
2327
}
@@ -39,9 +43,15 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
3943
const location = useLocation();
4044
const analytics = useAnalytics();
4145

46+
const pageHotkeys: Record<string, string> = {
47+
[PathNames.suites]: 's',
48+
[PathNames.visualChecks]: 'v'
49+
};
50+
4251
const menuItems: GravityMenuItem[] = props.pages.map(item => ({
4352
id: item.url,
4453
title: item.title,
54+
tooltipText: <>{item.title} <Hotkey value={pageHotkeys[item.url]} view="dark" /></>,
4555
icon: item.icon,
4656
current: Boolean(matchPath(`${item.url.replace(/\/$/, '')}/*`, location.pathname)),
4757
onItemClick: (): void => {
@@ -55,11 +65,13 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
5565
const backupSuitesPageSectionSizes = useSelector(state => state.ui[Page.suitesPage].backupSectionSizes);
5666
if (/\/suites/.test(location.pathname)) {
5767
const shouldExpandTree = isSectionHidden(currentSuitesPageSectionSizes[0]);
68+
const treeTitle = shouldExpandTree ? 'Expand tree' : 'Collapse tree';
5869
menuItems.push(
5970
{id: 'divider', type: 'divider', title: '-'},
6071
{
6172
id: 'expand-collapse-tree',
62-
title: shouldExpandTree ? 'Expand tree' : 'Collapse tree',
73+
title: treeTitle,
74+
tooltipText: <>{treeTitle} <Hotkey value="t" view="dark" /></>,
6375
icon: shouldExpandTree ? ArrowRightFromLine : ArrowLeftToLine,
6476
onItemClick: (): void => {
6577
dispatch(setSectionSizes({sizes: shouldExpandTree ? backupSuitesPageSectionSizes : [0, 100], page: Page.suitesPage}));
@@ -73,11 +85,13 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
7385
const backupVisualChecksPageSectionSizes = useSelector(state => state.ui[Page.visualChecksPage].backupSectionSizes);
7486
if (/\/visual-checks/.test(location.pathname)) {
7587
const shouldExpandTree = isSectionHidden(currentVisualChecksPageSectionSizes[0]);
88+
const treeTitle = shouldExpandTree ? 'Expand tree' : 'Collapse tree';
7689
menuItems.push(
7790
{id: 'divider', type: 'divider', title: '-'},
7891
{
7992
id: 'expand-collapse-tree',
80-
title: shouldExpandTree ? 'Expand tree' : 'Collapse tree',
93+
title: treeTitle,
94+
tooltipText: <>{treeTitle} <Hotkey value="t" view="dark" /></>,
8195
icon: shouldExpandTree ? ArrowRightFromLine : ArrowLeftToLine,
8296
onItemClick: (): void => {
8397
dispatch(setSectionSizes({sizes: shouldExpandTree ? backupVisualChecksPageSectionSizes : [0, 100], page: Page.visualChecksPage}));
@@ -102,6 +116,36 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
102116
}
103117
};
104118

119+
const togglePanel = useCallback((panelId: PanelId): void => {
120+
setVisiblePanel(prev => prev === panelId ? null : panelId);
121+
}, []);
122+
123+
const toggleTreeSidebar = useCallback((): void => {
124+
const isOnSuitesPage = /\/suites/.test(location.pathname);
125+
const isOnVisualChecksPage = /\/visual-checks/.test(location.pathname);
126+
127+
if (isOnSuitesPage) {
128+
const shouldExpand = isSectionHidden(currentSuitesPageSectionSizes[0]);
129+
dispatch(setSectionSizes({sizes: shouldExpand ? backupSuitesPageSectionSizes : [0, 100], page: Page.suitesPage}));
130+
} else if (isOnVisualChecksPage) {
131+
const shouldExpand = isSectionHidden(currentVisualChecksPageSectionSizes[0]);
132+
dispatch(setSectionSizes({sizes: shouldExpand ? backupVisualChecksPageSectionSizes : [0, 100], page: Page.visualChecksPage}));
133+
}
134+
}, [location.pathname, currentSuitesPageSectionSizes, backupSuitesPageSectionSizes, currentVisualChecksPageSectionSizes, backupVisualChecksPageSectionSizes, dispatch]);
135+
136+
const navigateToSuites = useCallback(() => navigate(PathNames.suites), [navigate]);
137+
const navigateToVisualChecks = useCallback(() => navigate(PathNames.visualChecks), [navigate]);
138+
const toggleHotkeysPanel = useCallback(() => togglePanel(PanelId.Hotkeys), [togglePanel]);
139+
const toggleInfoPanel = useCallback(() => togglePanel(PanelId.Info), [togglePanel]);
140+
const toggleSettingsPanel = useCallback(() => togglePanel(PanelId.Settings), [togglePanel]);
141+
142+
useHotkey('s', navigateToSuites);
143+
useHotkey('v', navigateToVisualChecks);
144+
useHotkey('t', toggleTreeSidebar);
145+
useHotkey('mod+/', toggleHotkeysPanel);
146+
useHotkey('i', toggleInfoPanel);
147+
useHotkey(',', toggleSettingsPanel);
148+
105149
return <AsideHeader
106150
className={classNames({'aside-header--initialized': isInitialized})}
107151
logo={{text: 'Testplane UI', iconSrc: TestplaneIcon, iconSize: 32, onClick: () => navigate(PathNames.suites)}}
@@ -120,6 +164,10 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
120164
hideCollapseButton={true}
121165
renderFooter={(): ReactNode => <Footer visiblePanel={visiblePanel} onFooterItemClick={onFooterItemClick}/>}
122166
panelItems={[{
167+
id: PanelId.Hotkeys,
168+
children: <HotkeysPanel />,
169+
visible: visiblePanel === PanelId.Hotkeys
170+
}, {
123171
id: PanelId.Info,
124172
children: <InfoPanel />,
125173
visible: visiblePanel === PanelId.Info

lib/static/new-ui/components/NameFilter/index.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
}
55

66
.search-input :global(.g-text-input__control) {
7+
padding-right: 100px;
8+
}
9+
10+
.search-input--with-hotkey :global(.g-text-input__control) {
11+
padding-right: 100px;
12+
}
13+
14+
.search-input--without-hotkey :global(.g-text-input__control) {
715
padding-right: 54px;
816
}
917

@@ -17,6 +25,7 @@
1725
width: fit-content;
1826
flex-direction: row;
1927
padding: 2px 2px 2px 0;
28+
align-items: center;
2029
}
2130

2231
.buttons-wrapper :global(.g-button) {
@@ -39,3 +48,14 @@
3948
width: var(--_--height);
4049
font-weight: 600;
4150
}
51+
52+
.hotkey {
53+
--g-color-base-light-simple-hover: var(--color-neutral-100);
54+
--g-color-text-light-complementary: var(--color-neutral-600);
55+
--g-color-text-light-hint: var(--color-neutral-400);
56+
pointer-events: none;
57+
display: flex;
58+
align-items: center;
59+
margin-right: 4px;
60+
height: 20px;
61+
}

0 commit comments

Comments
 (0)