diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.css b/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.css new file mode 100644 index 0000000000..af71e737cb --- /dev/null +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.css @@ -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; +} diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.tsx index 8f6ad2c14f..37ca1cbcab 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/BlueprintMode.tsx @@ -5,6 +5,7 @@ import { FormGroup, ToggleGroup, ToggleGroupItem, + Tooltip, } from '@patternfly/react-core'; import { BuildIcon, RepositoryIcon } from '@patternfly/react-icons'; @@ -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(RHEL_10); + // undefined until the host distro check resolves on-prem + const [hostDistro, setHostDistro] = useState(); const previousDistro = useRef(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 = ( + } + 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 ( @@ -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 @@ -70,25 +105,20 @@ const BlueprintMode = () => { }} aria-describedby='blueprint-mode-description' /> - } - 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. + + + {imageModeToggle} + + + ) : ( + imageModeToggle + )} { + const actual = await importOriginal(); + return { + ...actual, + getHostDistro: () => mockGetHostDistro(), + }; +}); + describe('BlueprintMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetHostDistro.mockResolvedValue('rhel-10'); + }); + describe('Rendering', () => { test('displays image type label', async () => { renderBlueprintMode(); @@ -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(); + }); + }); }); diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/helpers.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/helpers.tsx index bdc84d48ea..9cc9d70a6c 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/helpers.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/helpers.tsx @@ -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'; @@ -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); };