Skip to content

Commit f198395

Browse files
fix: isolate pdisk preview rendering and preserve keyboard focus
1 parent e7d9b3d commit f198395

42 files changed

Lines changed: 294 additions & 140 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/DiskStateProgressBar/DiskStateProgressBar.scss‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@use './diskStateColors';
1+
@use '../../styles/mixins';
22

33
.storage-disk-progress-bar {
44
$block: &;
@@ -31,7 +31,22 @@
3131
border-radius: $outer-border-radius;
3232
background-color: var(--entity-state-background-color);
3333

34-
@include diskStateColors.disk-state-colors($block);
34+
@include mixins.entity-state-colors($block);
35+
36+
// SolidRed severity (6): solid red background for critical errors in State mode
37+
&_solidred {
38+
--entity-state-font-color: var(--g-color-text-light-primary);
39+
--entity-state-border-color: var(--g-color-base-danger-heavy);
40+
--entity-state-background-color: var(--g-color-base-danger-heavy);
41+
--entity-state-fill-color: var(--g-color-base-danger-heavy);
42+
--entity-state-icon-color: var(--g-color-text-light-primary);
43+
--entity-state-stripe-color: var(--g-color-text-dark-hint);
44+
45+
&#{$block}_highlighted {
46+
--entity-state-background-color: var(--g-color-base-danger-heavy-hover);
47+
--entity-state-border-color: var(--g-color-base-danger-heavy-hover);
48+
}
49+
}
3550

3651
&_expert-mode {
3752
--entity-state-compact-background-color: var(--entity-state-background-color);

‎src/components/DiskStateProgressBar/DiskStateProgressBar.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,8 @@ interface DiskStateProgressBarProps {
3333
overflowVisible?: boolean;
3434
}
3535

36-
export function getDiskStateColorMods(tone: DiskBarTone, highlighted?: boolean) {
37-
return {
38-
[tone === 'LightGrey' ? 'light-grey' : tone.toLowerCase()]: true,
39-
highlighted,
40-
};
36+
function getToneModifier(tone: DiskBarTone) {
37+
return tone === 'LightGrey' ? 'light-grey' : tone.toLowerCase();
4138
}
4239

4340
export function DiskStateProgressBar({
@@ -69,10 +66,11 @@ export function DiskStateProgressBar({
6966
inactive,
7067
striped,
7168
filled,
69+
highlighted,
7270
'all-mode-has-issues': mode === 'all' && strongFill,
7371
'legend-inactive': borderless,
7472
'overlap-icon-at-top-left': overflowVisible,
75-
...getDiskStateColorMods(tone, highlighted),
73+
[getToneModifier(tone)]: true,
7674
};
7775

7876
if (mode) {

‎src/components/DiskStateProgressBar/diskStateColors.scss‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎src/containers/Storage/PDisk/PDisk.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ interface PDiskProps {
172172
onHidePopup?: VoidFunction;
173173
className?: string;
174174
progressBarClassName?: string;
175+
linkRef?: React.Ref<HTMLAnchorElement>;
175176
viewContext?: StorageViewContext;
176177
width?: number;
177178
delayOpen?: number;
@@ -192,6 +193,7 @@ export const PDisk = ({
192193
onHidePopup,
193194
className,
194195
progressBarClassName,
196+
linkRef,
195197
viewContext,
196198
width,
197199
delayOpen = DISKS_POPUP_DEBOUNCE_TIMEOUT,
@@ -278,7 +280,12 @@ export const PDisk = ({
278280
delayOpen={delayOpen}
279281
delayClose={delayClose}
280282
>
281-
<InternalLink to={pDiskPath} aria-label={accessibleName} className={b('content')}>
283+
<InternalLink
284+
to={pDiskPath}
285+
innerRef={linkRef}
286+
aria-label={accessibleName}
287+
className={b('content')}
288+
>
282289
<DiskStateProgressBar
283290
allocation={allocatedPercent}
284291
tone={tone}

‎src/containers/Storage/PDisks/PDisksPreview.scss‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
@use '../../../components/DiskStateProgressBar/diskStateColors';
1+
@use '../../../styles/mixins';
22

33
.ydb-storage-pdisks-preview {
44
&__color {
5-
@include diskStateColors.disk-state-colors(&);
5+
@include mixins.entity-state-colors(&);
66
}
77

88
&__control {

‎src/containers/Storage/PDisks/PDisksPreview.test.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import type {PDisk} from '../PDisk';
88
import {PDisksPreview} from './PDisksPreview';
99

1010
let mockInverted = false;
11+
const mockDetailsById = new Map<string, React.ComponentProps<typeof PDisk>>();
1112
let mockDetailsProps: React.ComponentProps<typeof PDisk>;
1213

1314
jest.mock('../../../utils/hooks/useSetting', () => ({useSetting: () => [mockInverted]}));
1415
jest.mock('../../../store', () => ({singleClusterMode: true}));
1516
jest.mock('../PDisk', () => ({
1617
PDisk: (props: React.ComponentProps<typeof PDisk>) => {
1718
mockDetailsProps = props;
19+
mockDetailsById.set(props.data?.StringifiedId || '', props);
1820
return <button>Expanded PDisk</button>;
1921
},
2022
}));
@@ -93,3 +95,16 @@ test('closing details clears highlight and ignores callbacks from the previous e
9395
act(() => mockDetailsProps.onShowPopup?.());
9496
expect(mockDetailsProps.highlighted).toBe(true);
9597
});
98+
99+
test('collapsing another group and its delayed hide callback preserve the highlighted owner', () => {
100+
const secondDisk = {...pDisk, PDiskId: 2, StringifiedId: '1-2'};
101+
render(<PDisksPreview pDisks={[pDisk, secondDisk]} vDisks={vDisks} />);
102+
fireEvent.click(screen.getByRole('button', {name: 'Show PDisk 1-1 details'}));
103+
fireEvent.click(screen.getByRole('button', {name: 'Show PDisk 1-2 details'}));
104+
const firstHide = mockDetailsById.get('1-1')?.onHidePopup;
105+
act(() => mockDetailsById.get('1-2')?.onShowPopup?.());
106+
act(() => firstHide?.());
107+
expect(mockDetailsById.get('1-2')?.highlighted).toBe(true);
108+
fireEvent.click(screen.getAllByRole('button', {name: 'Expanded PDisk'})[0]);
109+
expect(mockDetailsById.get('1-2')?.highlighted).toBe(true);
110+
});

‎src/containers/Storage/PDisks/PDisksPreview.tsx‎

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ import React from 'react';
33
import {Button, Flex, PortalProvider} from '@gravity-ui/uikit';
44
import {createPortal} from 'react-dom';
55

6-
import {getDiskStateColorMods} from '../../../components/DiskStateProgressBar/DiskStateProgressBar';
76
import {SETTING_KEYS} from '../../../store/reducers/settings/constants';
87
import {cn} from '../../../utils/cn';
9-
import {
10-
getDefaultDiskDisplayState,
11-
getDefaultPDiskDisplayState,
12-
} from '../../../utils/disks/displayState';
13-
import {getDiskBarTone} from '../../../utils/disks/getDiskBarTone';
14-
import type {DiskBarTone, PreparedPDisk, PreparedVDisk} from '../../../utils/disks/types';
8+
import {DONOR_COLOR} from '../../../utils/disks/constants';
9+
import {getDataSeverityColor} from '../../../utils/disks/helpers';
10+
import type {PreparedPDisk, PreparedVDisk} from '../../../utils/disks/types';
1511
import {useSetting} from '../../../utils/hooks/useSetting';
1612
import {isNumeric} from '../../../utils/utils';
1713
import {PDisk} from '../PDisk';
@@ -62,13 +58,16 @@ interface PDiskPreviewItemProps extends PDiskPreviewSvgProps {
6258
id: string;
6359
expandedWidth: number;
6460
highlightedDisk?: string;
65-
setHighlightedDisk: (id?: string) => void;
61+
setHighlightedDisk: React.Dispatch<React.SetStateAction<string | undefined>>;
6662
}
6763

64+
// The preview uses ordinary prepared-data severity, independently of Expert mode display states.
65+
type PreviewTone = ReturnType<typeof getDataSeverityColor> | typeof DONOR_COLOR;
66+
6867
interface VDiskPath {
6968
key: string;
7069
path: string;
71-
tone: DiskBarTone;
70+
tone: PreviewTone;
7271
inactive: boolean;
7372
}
7473

@@ -86,8 +85,8 @@ function getFilledSize(percent: unknown, size: number, inverted = false) {
8685
return (filledPercent / 100) * size;
8786
}
8887

89-
function getBarClassName(element: string, tone: DiskBarTone, highlighted?: boolean) {
90-
return b('color', getDiskStateColorMods(tone, highlighted), b(element));
88+
function getBarClassName(element: string, tone: PreviewTone, highlighted?: boolean) {
89+
return b('color', {[tone.toLowerCase()]: true, highlighted}, b(element));
9190
}
9291

9392
function getPDiskKey(pDisk: PreparedPDisk, index: number) {
@@ -102,10 +101,7 @@ function getVDiskPaths(vDisks: PreparedVDisk[], viewContext?: StorageViewContext
102101
const row = index % VDISKS_WITHOUT_PDISK_COLUMN;
103102
const x = VDISK_X + column * (VDISK_SIZE + VDISK_COLUMN_GAP);
104103
const y = row * (VDISK_SIZE + VDISK_GAP);
105-
const tone = getDiskBarTone({
106-
severity: getDefaultDiskDisplayState(vDisk, vDisk.DonorMode).severity,
107-
isDonor: vDisk.DonorMode,
108-
});
104+
const tone = vDisk.DonorMode ? DONOR_COLOR : getDataSeverityColor(vDisk.Severity);
109105
const inactive = !isVdiskActive(vDisk, viewContext);
110106
const key = `${tone}-${inactive}`;
111107
const rectPath = `M${x} ${y}h${VDISK_SIZE}v${VDISK_SIZE}h-${VDISK_SIZE}z`;
@@ -143,9 +139,8 @@ function PDiskPreviewSvg({
143139
highlighted,
144140
inverted,
145141
}: PDiskPreviewSvgProps) {
146-
const displayState = getDefaultPDiskDisplayState(pDisk);
147-
const pDiskTone = getDiskBarTone({severity: displayState.severity});
148-
const pDiskFilledHeight = getFilledSize(displayState.allocatedPercent, PDISK_HEIGHT, inverted);
142+
const pDiskTone = getDataSeverityColor(pDisk.Severity);
143+
const pDiskFilledHeight = getFilledSize(pDisk.AllocatedPercent, PDISK_HEIGHT, inverted);
149144
const pDiskFillY = inverted ? PDISK_Y : PDISK_Y + PDISK_HEIGHT - pDiskFilledHeight;
150145
const vDiskPaths = React.useMemo(
151146
() => getVDiskPaths(vDisks, viewContext),
@@ -232,9 +227,22 @@ function PDiskPreviewItem({
232227
const [previewFocused, setPreviewFocused] = React.useState(false);
233228
const popupContainerRef = React.useRef<HTMLElement | null>(null);
234229
const detailsRef = React.useRef<HTMLDivElement>(null);
230+
const diskLinkRef = React.useRef<HTMLAnchorElement>(null);
231+
const previewRef = React.useRef<HTMLButtonElement>(null);
232+
const transferFocusRef = React.useRef(false);
235233
const popupHostRef = React.useRef<HTMLDivElement>(null);
236234
const [popupAvailableHeight, setPopupAvailableHeight] = React.useState(0);
237235

236+
React.useLayoutEffect(() => {
237+
if (!transferFocusRef.current) {
238+
return;
239+
}
240+
transferFocusRef.current = false;
241+
// Transfer keyboard focus after React replaces the activated control.
242+
const target = detailsOpened ? diskLinkRef.current : previewRef.current;
243+
target?.focus({preventScroll: true});
244+
}, [detailsOpened]);
245+
238246
React.useLayoutEffect(() => {
239247
const container = popupContainerRef.current;
240248
const details = detailsRef.current;
@@ -264,15 +272,26 @@ function PDiskPreviewItem({
264272
window.removeEventListener('resize', updatePopupHeight);
265273
};
266274
}, [detailsOpened, highlightedDisk]);
275+
const clearOwnHighlight = React.useCallback(() => {
276+
setHighlightedDisk((current) =>
277+
current === id || vDisks.some((disk) => disk.StringifiedId === current)
278+
? undefined
279+
: current,
280+
);
281+
}, [id, vDisks, setHighlightedDisk]);
267282
const detailsVersionRef = React.useRef(0);
268283
const detailsVersion = detailsVersionRef.current;
269284
const setDetailsHighlightedDisk = React.useCallback(
270285
(diskId?: string) => {
271286
if (detailsVersionRef.current === detailsVersion) {
272-
setHighlightedDisk(diskId);
287+
if (diskId === undefined) {
288+
clearOwnHighlight();
289+
} else {
290+
setHighlightedDisk(diskId);
291+
}
273292
}
274293
},
275-
[detailsVersion, setHighlightedDisk],
294+
[detailsVersion, setHighlightedDisk, clearOwnHighlight],
276295
);
277296

278297
const closeDetails = React.useCallback(() => {
@@ -281,8 +300,8 @@ function PDiskPreviewItem({
281300
setPreviewHovered(false);
282301
setPreviewFocused(false);
283302
setDetailsOpened(false);
284-
setHighlightedDisk(undefined);
285-
}, [setHighlightedDisk]);
303+
clearOwnHighlight();
304+
}, [clearOwnHighlight]);
286305

287306
const handleOpenedDetailsClick = React.useCallback(
288307
(event: React.MouseEvent<unknown>) => {
@@ -295,6 +314,7 @@ function PDiskPreviewItem({
295314

296315
event.preventDefault();
297316
event.stopPropagation();
317+
transferFocusRef.current = event.detail === 0;
298318
closeDetails();
299319
},
300320
[closeDetails],
@@ -305,12 +325,14 @@ function PDiskPreviewItem({
305325
<Button
306326
view="flat"
307327
className={b('control')}
328+
ref={previewRef}
308329
onMouseEnter={() => setPreviewHovered(true)}
309330
onMouseLeave={() => setPreviewHovered(false)}
310331
onFocus={() => setPreviewFocused(true)}
311332
onBlur={() => setPreviewFocused(false)}
312333
onClick={(event) => {
313334
event.stopPropagation();
335+
transferFocusRef.current = event.detail === 0;
314336
// Keep popup wheel events in the table's native scroll container.
315337
let container = event.currentTarget.parentElement;
316338
while (
@@ -361,6 +383,7 @@ function PDiskPreviewItem({
361383
onClickCapture={handleOpenedDetailsClick}
362384
>
363385
<PDisk
386+
linkRef={diskLinkRef}
364387
data={pDisk}
365388
inactive={!isPdiskActive(pDisk, viewContext)}
366389
vDisks={vDisks}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {act, renderHook} from '@testing-library/react';
2+
3+
import {useStorageColumnsSettings} from './useStorageColumnsSettings';
4+
5+
let mockPreviewEnabled = true;
6+
jest.mock('../../../utils/hooks/useSetting', () => ({
7+
useSetting: () => [mockPreviewEnabled],
8+
}));
9+
jest.mock('../../../store', () => ({singleClusterMode: true}));
10+
11+
test('accumulates disk and slot maxima across pages without shrinking on smaller chunks', () => {
12+
mockPreviewEnabled = true;
13+
const {result, rerender} = renderHook(() => useStorageColumnsSettings());
14+
const receive = (maxSlotsPerDisk: number, maxDisksPerNode: number) => {
15+
act(() => {
16+
result.current.handleDataFetched({
17+
data: [],
18+
total: 0,
19+
found: 0,
20+
columnsSettings: {maxSlotsPerDisk, maxDisksPerNode},
21+
});
22+
});
23+
};
24+
25+
receive(6, 1);
26+
expect(result.current.columnsSettings.pDiskContainerWidth).toBe(33);
27+
receive(6, 4);
28+
expect(result.current.columnsSettings.pDiskContainerWidth).toBe(78);
29+
receive(24, 1);
30+
expect(result.current.columnsSettings.pDiskContainerWidth).toBe(162);
31+
receive(1, 1);
32+
expect(result.current.columnsSettings.pDiskContainerWidth).toBe(162);
33+
34+
mockPreviewEnabled = false;
35+
rerender();
36+
expect(result.current.columnsSettings.pDiskWidth).toBe(238);
37+
expect(result.current.columnsSettings.pDiskContainerWidth).toBe(1002);
38+
});

0 commit comments

Comments
 (0)