Skip to content

Commit 16342ac

Browse files
committed
feat: implement hotkeys in new ui
1 parent 513cfe5 commit 16342ac

File tree

21 files changed

+641
-94
lines changed

21 files changed

+641
-94
lines changed

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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Gear, CircleInfo} 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';
@@ -53,6 +53,7 @@ export function Footer(props: FooterProps): ReactNode {
5353
<FooterItem compact={false} item={{
5454
id: PanelId.Info,
5555
title: 'Info',
56+
tooltipText: <>Info <Hotkey value="i" view="dark" /></>,
5657
onItemClick: props.onFooterItemClick,
5758
current: isInfoCurrent,
5859
qa: 'footer-item-info',
@@ -68,6 +69,7 @@ export function Footer(props: FooterProps): ReactNode {
6869
<FooterItem compact={false} item={{
6970
id: PanelId.Settings,
7071
title: 'Settings',
72+
tooltipText: <>Settings <Hotkey value="," view="dark" /></>,
7173
onItemClick: props.onFooterItemClick,
7274
current: isSettingsCurrent,
7375
itemWrapper: (params, makeItem) => makeItem({

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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

@@ -12,8 +12,10 @@ import {Footer} from './Footer';
1212
import {EmptyReportCard} from '@/static/new-ui/components/Card/EmptyReportCard';
1313
import {InfoPanel} from '@/static/new-ui/components/InfoPanel';
1414
import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics';
15+
import {useHotkey} from '@/static/new-ui/hooks/useHotkey';
1516
import {setSectionSizes} from '../../../modules/actions/suites-page';
1617
import {ArrowLeftToLine, ArrowRightFromLine} from '@gravity-ui/icons';
18+
import {Hotkey} from '@gravity-ui/uikit';
1719
import {isSectionHidden} from '../../features/suites/utils';
1820
import {Page, PathNames} from '@/constants';
1921

@@ -39,9 +41,15 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
3941
const location = useLocation();
4042
const analytics = useAnalytics();
4143

44+
const pageHotkeys: Record<string, string> = {
45+
[PathNames.suites]: 's',
46+
[PathNames.visualChecks]: 'v'
47+
};
48+
4249
const menuItems: GravityMenuItem[] = props.pages.map(item => ({
4350
id: item.url,
4451
title: item.title,
52+
tooltipText: <>{item.title} <Hotkey value={pageHotkeys[item.url]} view="dark" /></>,
4553
icon: item.icon,
4654
current: Boolean(matchPath(`${item.url.replace(/\/$/, '')}/*`, location.pathname)),
4755
onItemClick: (): void => {
@@ -55,11 +63,13 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
5563
const backupSuitesPageSectionSizes = useSelector(state => state.ui[Page.suitesPage].backupSectionSizes);
5664
if (/\/suites/.test(location.pathname)) {
5765
const shouldExpandTree = isSectionHidden(currentSuitesPageSectionSizes[0]);
66+
const treeTitle = shouldExpandTree ? 'Expand tree' : 'Collapse tree';
5867
menuItems.push(
5968
{id: 'divider', type: 'divider', title: '-'},
6069
{
6170
id: 'expand-collapse-tree',
62-
title: shouldExpandTree ? 'Expand tree' : 'Collapse tree',
71+
title: treeTitle,
72+
tooltipText: <>{treeTitle} <Hotkey value="t" view="dark" /></>,
6373
icon: shouldExpandTree ? ArrowRightFromLine : ArrowLeftToLine,
6474
onItemClick: (): void => {
6575
dispatch(setSectionSizes({sizes: shouldExpandTree ? backupSuitesPageSectionSizes : [0, 100], page: Page.suitesPage}));
@@ -73,11 +83,13 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
7383
const backupVisualChecksPageSectionSizes = useSelector(state => state.ui[Page.visualChecksPage].backupSectionSizes);
7484
if (/\/visual-checks/.test(location.pathname)) {
7585
const shouldExpandTree = isSectionHidden(currentVisualChecksPageSectionSizes[0]);
86+
const treeTitle = shouldExpandTree ? 'Expand tree' : 'Collapse tree';
7687
menuItems.push(
7788
{id: 'divider', type: 'divider', title: '-'},
7889
{
7990
id: 'expand-collapse-tree',
80-
title: shouldExpandTree ? 'Expand tree' : 'Collapse tree',
91+
title: treeTitle,
92+
tooltipText: <>{treeTitle} <Hotkey value="t" view="dark" /></>,
8193
icon: shouldExpandTree ? ArrowRightFromLine : ArrowLeftToLine,
8294
onItemClick: (): void => {
8395
dispatch(setSectionSizes({sizes: shouldExpandTree ? backupVisualChecksPageSectionSizes : [0, 100], page: Page.visualChecksPage}));
@@ -102,6 +114,34 @@ export function MainLayout(props: MainLayoutProps): ReactNode {
102114
}
103115
};
104116

117+
const togglePanel = useCallback((panelId: PanelId): void => {
118+
setVisiblePanel(prev => prev === panelId ? null : panelId);
119+
}, []);
120+
121+
const toggleTreeSidebar = useCallback((): void => {
122+
const isOnSuitesPage = /\/suites/.test(location.pathname);
123+
const isOnVisualChecksPage = /\/visual-checks/.test(location.pathname);
124+
125+
if (isOnSuitesPage) {
126+
const shouldExpand = isSectionHidden(currentSuitesPageSectionSizes[0]);
127+
dispatch(setSectionSizes({sizes: shouldExpand ? backupSuitesPageSectionSizes : [0, 100], page: Page.suitesPage}));
128+
} else if (isOnVisualChecksPage) {
129+
const shouldExpand = isSectionHidden(currentVisualChecksPageSectionSizes[0]);
130+
dispatch(setSectionSizes({sizes: shouldExpand ? backupVisualChecksPageSectionSizes : [0, 100], page: Page.visualChecksPage}));
131+
}
132+
}, [location.pathname, currentSuitesPageSectionSizes, backupSuitesPageSectionSizes, currentVisualChecksPageSectionSizes, backupVisualChecksPageSectionSizes, dispatch]);
133+
134+
const navigateToSuites = useCallback(() => navigate(PathNames.suites), [navigate]);
135+
const navigateToVisualChecks = useCallback(() => navigate(PathNames.visualChecks), [navigate]);
136+
const toggleInfoPanel = useCallback(() => togglePanel(PanelId.Info), [togglePanel]);
137+
const toggleSettingsPanel = useCallback(() => togglePanel(PanelId.Settings), [togglePanel]);
138+
139+
useHotkey('s', navigateToSuites);
140+
useHotkey('v', navigateToVisualChecks);
141+
useHotkey('t', toggleTreeSidebar);
142+
useHotkey('i', toggleInfoPanel);
143+
useHotkey(',', toggleSettingsPanel);
144+
105145
return <AsideHeader
106146
className={classNames({'aside-header--initialized': isInitialized})}
107147
logo={{text: 'Testplane UI', iconSrc: TestplaneIcon, iconSize: 32, onClick: () => navigate(PathNames.suites)}}

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+
}

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import React, {ChangeEvent, ReactNode, useCallback, useMemo, useState} from 'react';
1+
import React, {ChangeEvent, ReactNode, useCallback, useMemo, useRef, useState} from 'react';
22
import {debounce} from 'lodash';
33
import {useDispatch, useSelector} from 'react-redux';
4-
import {Icon, TextInput} from '@gravity-ui/uikit';
4+
import {Hotkey, Icon, TextInput} from '@gravity-ui/uikit';
55
import {FontCase, Xmark} from '@gravity-ui/icons';
6+
import classNames from 'classnames';
67
import * as actions from '@/static/modules/actions';
78
import {getIsInitialized} from '@/static/new-ui/store/selectors';
89
import {NameFilterButton} from './NameFilterButton';
910
import styles from './index.module.css';
1011
import {usePage} from '@/static/new-ui/hooks/usePage';
12+
import {useHotkey} from '@/static/new-ui/hooks/useHotkey';
1113
import {search} from '@/static/modules/search';
1214

1315
export const NameFilter = (): ReactNode => {
@@ -17,6 +19,23 @@ export const NameFilter = (): ReactNode => {
1719
const useRegexFilter = useSelector((state) => state.app[page].useRegexFilter);
1820
const useMatchCaseFilter = useSelector((state) => state.app[page].useMatchCaseFilter);
1921
const [testNameFilter, setNameFilter] = useState(nameFilter);
22+
const [isFocused, setIsFocused] = useState(false);
23+
const inputRef = useRef<HTMLInputElement>(null);
24+
25+
const focusSearch = useCallback(() => inputRef.current?.focus(), []);
26+
useHotkey('mod+k', focusSearch, {allowInInput: true});
27+
28+
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>): void => {
29+
if (event.key === 'Escape') {
30+
event.preventDefault();
31+
if (testNameFilter) {
32+
setNameFilter('');
33+
search('', useMatchCaseFilter, useRegexFilter, page, false, dispatch);
34+
} else {
35+
inputRef.current?.blur();
36+
}
37+
}
38+
}, [testNameFilter, useMatchCaseFilter, useRegexFilter, page, dispatch]);
2039

2140
const updateNameFilter = useCallback(debounce(
2241
(text) => {
@@ -67,18 +86,38 @@ export const NameFilter = (): ReactNode => {
6786
return false;
6887
}, [useRegexFilter, testNameFilter]);
6988

89+
const onFocus = useCallback((): void => {
90+
setIsFocused(true);
91+
}, []);
92+
93+
const onBlur = useCallback((): void => {
94+
setIsFocused(false);
95+
}, []);
96+
97+
const showHotkeyHint = !isFocused && !testNameFilter;
98+
7099
return (
71100
<div className={styles.container}>
72101
<TextInput
102+
controlRef={inputRef}
73103
disabled={!isInitialized}
74104
placeholder="Search or filter"
75105
value={testNameFilter}
76106
onChange={onChange}
77-
className={styles['search-input']}
107+
onFocus={onFocus}
108+
onBlur={onBlur}
109+
onKeyDown={onKeyDown}
110+
className={classNames(
111+
styles['search-input'],
112+
showHotkeyHint ? styles['search-input--with-hotkey'] : styles['search-input--without-hotkey']
113+
)}
78114
error={isRegexInvalid}
79115
qa="name-filter"
80116
/>
81117
<div className={styles['buttons-wrapper']}>
118+
{showHotkeyHint && (
119+
<Hotkey className={styles.hotkey} view="dark" value="mod+k" />
120+
)}
82121
{testNameFilter && (
83122
<NameFilterButton
84123
selected={false}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
.retry-button {
88
composes: regular-button from global, action-button from global;
9+
padding-right: 4px;
910
}
1011

1112
.run-options-button::before {
@@ -24,3 +25,7 @@
2425
width: 450px;
2526
padding: 16px;
2627
}
28+
29+
.hotkey {
30+
margin-left: 4px;
31+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {forwardRef, useCallback, useState} from 'react';
1+
import React, {forwardRef, ReactNode, useCallback, useState} from 'react';
22

33
import styles from './index.module.css';
44
import {Button, ButtonProps, Icon, Popover, Spin} from '@gravity-ui/uikit';
@@ -18,10 +18,11 @@ interface RunTestProps {
1818
browser: BrowserEntity | null;
1919
buttonText?: string | null;
2020
buttonProps?: ButtonProps;
21+
hotkey?: ReactNode;
2122
}
2223

2324
export const RunTestButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, RunTestProps>(
24-
({browser, buttonProps, buttonText}, ref) => {
25+
({browser, buttonProps, buttonText, hotkey}, ref) => {
2526
const isRunning = useSelector(state => state.running);
2627

2728
const analytics = useAnalytics();
@@ -58,7 +59,7 @@ export const RunTestButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, R
5859
pin={hasRunTestOptions ? 'round-brick' : undefined}
5960
{...buttonProps}
6061
>
61-
{isRunning ? <Spin size={'xs'} /> : <Icon data={ArrowRotateRight}/>}{buttonText === undefined ? 'Retry' : buttonText}
62+
{isRunning ? <Spin size={'xs'} /> : <Icon data={ArrowRotateRight}/>}{buttonText === undefined ? 'Retry' : buttonText}{hotkey}
6263
</Button>
6364
{hasRunTestOptions && <Popover
6465
onOpenChange={onRunOptionsOpenChange}

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Icon, Popover, Spin} from '@gravity-ui/uikit';
1+
import {Hotkey, Icon, Popover, Spin} from '@gravity-ui/uikit';
22
import classNames from 'classnames';
33
import {
44
ArrowUturnCcwLeft,
@@ -15,7 +15,7 @@ import {
1515
Hierarchy,
1616
GearPlay
1717
} from '@gravity-ui/icons';
18-
import React, {ReactNode, useMemo} from 'react';
18+
import React, {ReactNode, useCallback, useMemo} from 'react';
1919
import {useDispatch, useSelector} from 'react-redux';
2020

2121
import styles from './index.module.css';
@@ -49,6 +49,7 @@ import {GroupBySelect} from '@/static/new-ui/features/suites/components/GroupByS
4949
import {SortBySelect} from '@/static/new-ui/features/suites/components/SortBySelect';
5050
import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots';
5151
import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics';
52+
import {useHotkey} from '@/static/new-ui/hooks/useHotkey';
5253
import ExtensionPoint, {getExtensionPointComponents} from '../../../components/extension-point';
5354
import {ExtensionPointName} from '../../constants/plugins';
5455
import * as plugins from '../../../modules/plugins';
@@ -135,7 +136,10 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
135136
dispatch(setAllTreeNodesState({isExpanded: false}));
136137
};
137138

138-
const handleRun = (): void => {
139+
const selectedOrVisible = isSelectedAtLeastOne ? 'selected' : 'visible';
140+
const areActionsDisabled = isRunning || !isInitialized;
141+
142+
const handleRun = useCallback((): void => {
139143
analytics?.trackFeatureUsage({featureName: `${ANALYTICS_PREFIX} run tests`});
140144
if (isSelectedAtLeastOne) {
141145
dispatch(thunkRunTests({tests: selectedTests}));
@@ -146,7 +150,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
146150
}));
147151
dispatch(thunkRunTests({tests: visibleTests}));
148152
}
149-
};
153+
}, [analytics, isSelectedAtLeastOne, selectedTests, visibleBrowserIds, browsersById, dispatch]);
150154

151155
const handleUndo = (): void => {
152156
const acceptableImageIds = activeImages
@@ -161,7 +165,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
161165
}
162166
};
163167

164-
const handleAccept = (): void => {
168+
const handleAccept = useCallback((): void => {
165169
const acceptableImageIds = activeImages
166170
.filter(image => isAcceptable(image))
167171
.map(image => image.id);
@@ -173,16 +177,16 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
173177
} else {
174178
dispatch(thunkAcceptImages({imageIds: acceptableImageIds}));
175179
}
176-
};
180+
}, [activeImages, analytics, isStaticImageAccepterEnabled, dispatch]);
177181

178182
const handleToggleTreeView = (): void => {
179183
const newTreeViewMode = treeViewMode === TreeViewMode.Tree ? TreeViewMode.List : TreeViewMode.Tree;
180184
analytics?.trackFeatureUsage({featureName: `${ANALYTICS_PREFIX} change tree view mode`, treeViewMode: newTreeViewMode});
181185
dispatch(setTreeViewMode({treeViewMode: newTreeViewMode}));
182186
};
183187

184-
const selectedOrVisible = isSelectedAtLeastOne ? 'selected' : 'visible';
185-
const areActionsDisabled = isRunning || !isInitialized;
188+
useHotkey('shift+r', handleRun, {enabled: Boolean(isRunTestsAvailable) && !isRunning && isInitialized});
189+
useHotkey('shift+a', handleAccept, {enabled: Boolean(isEditScreensAvailable) && !areActionsDisabled && isAtLeastOneAcceptable && !isUndoButtonVisible});
186190

187191
const loadedPluginConfigs = plugins.getLoadedConfigs();
188192
const pluginComponents = getExtensionPointComponents(loadedPluginConfigs, ExtensionPointName.RunTestOptions);
@@ -198,7 +202,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
198202
<IconButton
199203
className={styles.iconButton}
200204
icon={<Icon data={Play} height={14}/>}
201-
tooltip={`Run ${selectedOrVisible}`}
205+
tooltip={<>Run {selectedOrVisible}<Hotkey value="shift+r" view="light" /></>}
202206
view={'flat'}
203207
onClick={handleRun}
204208
disabled={isRunning || !isInitialized}
@@ -220,7 +224,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi
220224
{isEditScreensAvailable && (
221225
isUndoButtonVisible ?
222226
<IconButton className={styles.iconButton} icon={<Icon data={ArrowUturnCcwLeft} />} tooltip={`Undo accepting ${selectedOrVisible} screenshots`} view={'flat'} onClick={handleUndo} disabled={areActionsDisabled}></IconButton> :
223-
<IconButton className={styles.iconButton} icon={<Icon data={Check} />} tooltip={`Accept ${selectedOrVisible} screenshots`} view={'flat'} onClick={handleAccept} disabled={areActionsDisabled || !isAtLeastOneAcceptable}></IconButton>
227+
<IconButton className={styles.iconButton} icon={<Icon data={Check} />} tooltip={<>Accept {selectedOrVisible} screenshots<Hotkey value="shift+a" view="light" /></>} view={'flat'} onClick={handleAccept} disabled={areActionsDisabled || !isAtLeastOneAcceptable}></IconButton>
224228
)}
225229
{(isRunTestsAvailable || isEditScreensAvailable) && <div className={styles.buttonsDivider}></div>}
226230
<IconButton

0 commit comments

Comments
 (0)