Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* The tooltip wrapper makes the image mode item the first child of the
wrapper span instead of a middle child of the toggle group, so
PatternFly rounds its start corners. Square them so the item sits
flush against the package mode item. */
.image-mode-toggle-wrapper {
--pf-v6-c-toggle-group__item--first-child__button--BorderStartStartRadius: 0;
--pf-v6-c-toggle-group__item--first-child__button--BorderEndStartRadius: 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FormGroup,
ToggleGroup,
ToggleGroupItem,
Tooltip,
} from '@patternfly/react-core';
import { BuildIcon, RepositoryIcon } from '@patternfly/react-icons';

Expand All @@ -23,30 +24,64 @@ import {
selectIsImageMode,
} from '@/store/slices/wizard';

import './BlueprintMode.css';

const BlueprintMode = () => {
const dispatch = useAppDispatch();
const isOnPremise = useAppSelector(selectIsOnPremise);
const isImageMode = useAppSelector(selectIsImageMode);
const distribution = useAppSelector(selectDistribution);
const architecture = useAppSelector(selectArchitecture);
const [defaultDistro, setDefaultDistro] = useState<Distributions>(RHEL_10);
// undefined until the host distro check resolves on-prem
const [hostDistro, setHostDistro] = useState<Distributions | undefined>();
const previousDistro = useRef<Distributions>(RHEL_10);
const previousArch = useRef(architecture);

useEffect(() => {
if (!isOnPremise) return;
const fetchDefaultDistro = async () => {
const fetchHostDistro = async () => {
try {
const distro = await getHostDistro();
setDefaultDistro(distro as Distributions);
setHostDistro(distro as Distributions);
} catch {
// defaultDistro remains RHEL_10
// Assume the default so a failed check doesn't lock image mode
setHostDistro(RHEL_10);
}
};

fetchDefaultDistro();
fetchHostDistro();
}, [isOnPremise]);

// On-prem builds run on the host itself, and image mode only ships
// official RHEL 10 images for now. While the host distro is still
// unknown the toggle stays disabled without the tooltip, so RHEL 10
// users don't see a "coming soon" flash.
const isHostDistroKnown = !isOnPremise || hostDistro !== undefined;
const isImageModeSupported = !isOnPremise || hostDistro === RHEL_10;

const imageModeToggle = (
<ToggleGroupItem
icon={<BuildIcon />}
text='Image mode'
buttonId='blueprint-mode-image'
isSelected={isImageMode}
isDisabled={!isImageModeSupported}
onChange={() => {
if (!isOnPremise) {
previousDistro.current = distribution;
previousArch.current = architecture;
}
dispatch(changeBlueprintMode('image'));
dispatch(changeImageTypes([]));
if (!isOnPremise) {
dispatch(changeArchitecture(X86_64));
dispatch(changeImageSource(RHEL_10_IMAGE_MODE_IMAGE));
}
}}
aria-describedby='blueprint-mode-description'
/>
);

return (
<FormGroup label='Image type' isRequired>
<ToggleGroup aria-label='Blueprint mode toggle group'>
Expand All @@ -59,7 +94,7 @@ const BlueprintMode = () => {
dispatch(changeBlueprintMode('package'));
dispatch(
changeDistribution(
isOnPremise ? defaultDistro : previousDistro.current,
isOnPremise ? (hostDistro ?? RHEL_10) : previousDistro.current,
),
);
// Image source is only relevant in image mode
Expand All @@ -70,25 +105,20 @@ const BlueprintMode = () => {
}}
aria-describedby='blueprint-mode-description'
/>
<ToggleGroupItem
icon={<BuildIcon />}
text='Image mode'
buttonId='blueprint-mode-image'
isSelected={isImageMode}
onChange={() => {
if (!isOnPremise) {
previousDistro.current = distribution;
previousArch.current = architecture;
}
dispatch(changeBlueprintMode('image'));
dispatch(changeImageTypes([]));
if (!isOnPremise) {
dispatch(changeArchitecture(X86_64));
dispatch(changeImageSource(RHEL_10_IMAGE_MODE_IMAGE));
}
}}
aria-describedby='blueprint-mode-description'
/>
{!isImageModeSupported && isHostDistroKnown ? (
// Disabled buttons don't emit hover events, so the tooltip
// needs a wrapper element as its trigger.
<Tooltip content='Image mode is currently available only on RHEL 10 hosts. Support for CentOS Stream and Fedora is coming soon.'>
<span
className='image-mode-toggle-wrapper'
data-testid='image-mode-toggle-wrapper'
>
{imageModeToggle}
</span>
Comment on lines +108 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Tooltip wrapper around a disabled button might benefit from explicit accessibility handling.

Because the tooltip trigger is a non-semantic <span> wrapping a disabled control, assistive tech may not expose the tooltip or the disabled state correctly. Consider either giving the wrapper an appropriate role/ARIA attributes, or keeping the inner button focusable (e.g., aria-disabled with click prevention) so the tooltip and disabled state are properly announced for keyboard and screen-reader users.

Suggested implementation:

        {!isImageModeSupported && isHostDistroKnown ? (
          // Disabled buttons don't emit hover events, so the tooltip
          // needs a wrapper element as its trigger.
          // Make the wrapper a semantic, focusable control so the tooltip
          // and disabled state are exposed to assistive technologies.
          <Tooltip content='Image mode is currently available only on RHEL 10 hosts. Support for CentOS Stream and Fedora is coming soon.'>
            <span
              className='image-mode-toggle-wrapper'
              data-testid='image-mode-toggle-wrapper'
              role='button'
              aria-disabled='true'
              aria-label='Image mode toggle (unavailable for this host)'
              tabIndex={0}
            >
              {imageModeToggle}
            </span>
          </Tooltip>
        ) : (
          imageModeToggle
        )}

Depending on how imageModeToggle is implemented elsewhere in this file, you may want to:

  1. Ensure that the inner toggle is actually disabled only via aria-disabled (and click prevention) instead of the native disabled attribute, so that the control remains reachable by keyboard while still being announced as disabled.
  2. If you switch to aria-disabled, make sure to prevent activation for unsupported hosts in the onClick handler (and optionally onKeyDown for Space/Enter) to preserve the disabled behavior while enabling consistent tooltip announcement for keyboard and screen-reader users.

</Tooltip>
) : (
imageModeToggle
)}
</ToggleGroup>
<Content
id='blueprint-mode-description'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';

import { initialState } from '@/store/slices/wizard';
import { createUser } from '@/test/testUtils';

import { renderBlueprintMode, toggleBlueprintMode } from './helpers';

const mockGetHostDistro = vi.fn();

vi.mock('@/store/api/backend', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/store/api/backend')>();
return {
...actual,
getHostDistro: () => mockGetHostDistro(),
};
});

describe('BlueprintMode', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetHostDistro.mockResolvedValue('rhel-10');
});

describe('Rendering', () => {
test('displays image type label', async () => {
renderBlueprintMode();
Expand Down Expand Up @@ -154,4 +170,81 @@ describe('BlueprintMode', () => {
expect(imageModeButton).toHaveAttribute('aria-pressed', 'true');
});
});

describe('Host distro gating', () => {
// The toggle is re-parented into the tooltip wrapper once the host
// distro fetch resolves, so queries must run inside the waits.
test('enables image mode on a RHEL 10 host', async () => {
renderBlueprintMode();

const imageModeButton = await screen.findByRole('button', {
name: /image mode/i,
});
await waitFor(() => {
expect(imageModeButton).toBeEnabled();
});
expect(
screen.queryByTestId('image-mode-toggle-wrapper'),
).not.toBeInTheDocument();
});

test('disables the toggle without a tooltip while the host distro is unknown', async () => {
// The check never resolves, so the host distro stays unknown
mockGetHostDistro.mockReturnValue(new Promise(() => {}));

renderBlueprintMode();

const imageModeButton = await screen.findByRole('button', {
name: /image mode/i,
});
expect(imageModeButton).toBeDisabled();
expect(
screen.queryByTestId('image-mode-toggle-wrapper'),
).not.toBeInTheDocument();
});

test('disables image mode on a Fedora host', async () => {
mockGetHostDistro.mockResolvedValue('fedora-43');

renderBlueprintMode();

await waitFor(() => {
expect(
screen.getByRole('button', { name: /image mode/i }),
).toBeDisabled();
});
});

test('disables image mode on a CentOS Stream host', async () => {
mockGetHostDistro.mockResolvedValue('centos-10');

renderBlueprintMode();

await waitFor(() => {
expect(
screen.getByRole('button', { name: /image mode/i }),
).toBeDisabled();
});
});

test('shows a coming soon tooltip on non-RHEL hosts', async () => {
mockGetHostDistro.mockResolvedValue('fedora-43');

renderBlueprintMode();

const wrapper = await screen.findByTestId('image-mode-toggle-wrapper');
fireEvent.mouseEnter(wrapper);

expect(
await screen.findByText(
/image mode is currently available only on rhel 10 hosts/i,
),
).toBeInTheDocument();
expect(
screen.getByText(
/support for centos stream and fedora is coming soon/i,
),
).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';

import { RHEL_10 } from '@/constants';
import { initialState } from '@/store/slices/wizard';
Expand Down Expand Up @@ -130,6 +130,9 @@ export const toggleBlueprintMode = async (
) => {
const buttonName = mode === 'package' ? /package mode/i : /image mode/i;
const button = await screen.findByRole('button', { name: buttonName });
// Image mode starts disabled on-prem until the host distro check
// resolves; clicking a disabled toggle would silently do nothing.
await waitFor(() => expect(button).toBeEnabled());
await clickWithWait(user, button);
};

Expand Down
Loading