Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/components/EntityStatus/EntityStatus.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
@use '../../styles/mixins.scss';

.ydb-entity-status {
&__compact {
display: inline-flex;
flex-shrink: 0;
}

&__note {
color: inherit;
}
Expand Down
18 changes: 18 additions & 0 deletions src/components/EntityStatus/EntityStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface EntityStatusLabelProps {
className?: string;
endContent?: React.ReactNode;
withStatusName?: boolean;
view?: 'default' | 'compact';
Comment thread
astandrik marked this conversation as resolved.
Outdated
size?: LabelProps['size'];
iconSize?: number;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
Expand All @@ -63,12 +64,29 @@ function EntityStatusLabel({
endContent,
status,
withStatusName = true,
view = 'default',
note,
size = 'm',
iconSize = 14,
onClick,
qa,
}: EntityStatusLabelProps) {
if (view === 'compact') {
const statusName = EFlagToStatusName[status];
return (
<ActionTooltip title={statusName}>
<span className={b('compact')} role="img" aria-label={statusName}>
<Label
theme={status === EFlag.Blue ? 'info' : EFlagToLabelTheme[status]}
icon={<StatusIcon size={iconSize} status={status} />}
size={size}
className={b(null, className)}
qa={qa}
/>
</span>
</ActionTooltip>
);
}
const theme = EFlagToLabelTheme[status];
const isClickable = Boolean(onClick);
return (
Expand Down
64 changes: 64 additions & 0 deletions src/components/EntityStatus/__test__/EntityStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {ThemeProvider} from '@gravity-ui/uikit';
import {fireEvent, render, screen} from '@testing-library/react';

import {EFlag} from '../../../types/api/enums';
import {EntityStatus} from '../EntityStatus';

describe('EntityStatus.Label', () => {
test.each([
[EFlag.Red, 'Critical', 'danger'],
[EFlag.Orange, 'Caution', 'danger'],
[EFlag.Yellow, 'Warning', 'warning'],
[EFlag.Blue, 'Normal', 'info'],
[EFlag.Green, 'Good', 'success'],
[EFlag.Grey, 'Unknown', 'unknown'],
] as const)('renders %s as an accessible compact status', async (status, name, theme) => {
const {container} = render(<EntityStatus.Label status={status} view="compact" />, {
wrapper: ThemeProvider,
});
const icon = screen.getByRole('img', {name});

expect(icon).not.toHaveAttribute('tabindex');
expect(screen.queryByText(name)).not.toBeInTheDocument();
expect(container.querySelector('.g-label__text')).not.toBeInTheDocument();
expect(container.querySelector(`.g-label_theme_${theme}`)).toBeInTheDocument();
expect(container.querySelector('.ydb-entity-status_critical')).not.toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();

fireEvent.mouseEnter(icon);
expect(await screen.findByText(name)).toBeVisible();
});

test('preserves the default critical label, content and description', async () => {
const {container} = render(
<EntityStatus.Label status={EFlag.Red} endContent="extra">
Cluster
</EntityStatus.Label>,
{wrapper: ThemeProvider},
);

expect(container).toHaveTextContent('ClusterCritical');
expect(screen.getByText('extra')).toBeVisible();
const label = container.getElementsByClassName('ydb-entity-status_critical')[0];
expect(label).toBeInTheDocument();
fireEvent.mouseEnter(label);
expect(
await screen.findByText('Critical state, requires immediate attention'),
).toBeVisible();
});

test('preserves existing icon-and-content labels and click handlers', () => {
const onClick = jest.fn();
const {container} = render(
<EntityStatus.Label status={EFlag.Blue} withStatusName={false} onClick={onClick}>
Replication
</EntityStatus.Label>,
{wrapper: ThemeProvider},
);

expect(container.querySelector('.g-label_theme_success')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Replication'}));
expect(onClick).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Normal')).not.toBeInTheDocument();
});
});
7 changes: 6 additions & 1 deletion src/containers/Tenant/Healthcheck/Healthcheck.scss
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@
}
}
&__issue-status {
text-align: start;
display: flex;
flex-shrink: 0;
}
&__issue-title {
min-width: 0;
overflow-wrap: anywhere;
}
&__issue-content {
--g-definition-list-item-gap: var(--g-spacing-3);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,26 @@ export function HealthcheckIssue({
justifyContent="space-between"
className={b('issue-summary')}
>
<Flex direction="column" gap={1} alignSelf="center">
<Text variant="subheader-2">{issue.message}</Text>

<Flex
gap={2}
alignItems="center"
className={b('issue-message')}
>
{issue.status && (
<div className={b('issue-status')}>
<EntityStatus.Label
view="compact"
size="s"
status={hcStatusToColorFlag[issue.status]}
/>
</div>
)}
<Text
variant="subheader-2"
className={b('issue-title')}
>
{issue.message}
</Text>
</Flex>
<Flex
wrap="nowrap"
Expand Down Expand Up @@ -124,21 +133,22 @@ export function HealthcheckIssue({
>
<Flex
id={id}
direction="column"
gap={1}
alignSelf="center"
gap={2}
alignItems="center"
className={b('issue-message')}
>
<Text variant="subheader-2">{issue.message}</Text>

{issue.status && (
<div className={b('issue-status')}>
<EntityStatus.Label
view="compact"
size="s"
status={hcStatusToColorFlag[issue.status]}
/>
</div>
)}
<Text variant="subheader-2" className={b('issue-title')}>
{issue.message}
</Text>
</Flex>
<div
className={b('issue-action')}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {QueryParamProvider} from 'use-query-params';
import {ReactRouter5Adapter} from 'use-query-params/adapters/react-router-5';

import type {IssuesTree} from '../../../../../store/reducers/healthcheckInfo/types';
import {SelfCheckResult, StatusFlag} from '../../../../../types/api/healthcheck';
import {HealthcheckContext} from '../../HealthcheckContext';
import type {HealthcheckContextValue} from '../../HealthcheckContext';
import {HealthcheckIssue} from '../HealthcheckIssue';

const issue = {
id: 'vdisk-1',
message: 'VDisk is not available',
status: StatusFlag.RED,
categoryForUI: 'storage',
} satisfies IssuesTree;

function renderIssue(assistant?: HealthcheckContextValue['assistant'], value: IssuesTree = issue) {
return render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<HealthcheckContext.Provider value={{database: '/Root/db', assistant}}>
<HealthcheckIssue issue={value} />
</HealthcheckContext.Provider>
</QueryParamProvider>
</MemoryRouter>,
);
}

test('keeps the compact status before the title and the full row as the disclosure trigger', () => {
const {container} = renderIssue();
const status = screen.getByRole('img', {name: 'Critical'});
const title = screen.getAllByText(issue.message)[0];
expect(status.compareDocumentPosition(title)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
const trigger = screen.getByRole('button', {name: /VDisk is not available/});
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(container.querySelector('button button')).not.toBeInTheDocument();

fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Details')).toBeInTheDocument();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});

test('keeps Fix independent from disclosure and passes the original issue and target', () => {
const fix = jest.fn();
const target = {
scope: 'database' as const,
request: {database: '/Root/db', clusterName: 'test'},
};
const snapshot = {selfCheckResult: SelfCheckResult.EMERGENCY, issues: [issue]};
const renderAction = jest.fn((props) => (
<button type="button" onClick={() => fix(props)}>
Fix
</button>
));
const {container} = renderIssue({target, snapshot, renderAction});

expect(screen.getByRole('img', {name: 'Critical'})).toBeInTheDocument();
const disclosure = screen.getByRole('button', {name: /Expand/});
fireEvent.click(screen.getByRole('button', {name: 'Fix'}));
expect(fix).toHaveBeenCalledTimes(1);
expect(fix).toHaveBeenCalledWith({action: 'fix', issue, target, snapshot});
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
expect(container.querySelector('button button')).not.toBeInTheDocument();

fireEvent.click(screen.getAllByText(issue.message)[0]);
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', {name: 'Fix'}));
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Details')).toBeInTheDocument();
});

test('renders a title without a status placeholder when status is absent', () => {
const {container} = renderIssue(undefined, {...issue, status: undefined});
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(container.querySelector('.ydb-healthcheck__issue-status')).not.toBeInTheDocument();
expect(screen.getByRole('button', {name: issue.message})).toBeInTheDocument();
});
Loading