From 6144f997276d6c72b15efc8e84a82f67ec10f233 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Mon, 10 Aug 2026 08:01:50 -0500 Subject: [PATCH 1/4] Wizard: scope the pull button state to the selected image The pull mutation's busy and error flags are hook-level, so switching to another image while a pull was in flight kept showing the pulling state on the newly selected image. Gate both flags on the mutation's originalArgs matching the current selection. Cache refreshes are unaffected: pull invalidates ImageExists per reference already. --- .../OnPrem/OfficialImageSource.tsx | 9 ++- .../tests/ImageSourceSelect.test.tsx | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx index 260495aee3..90c42facbf 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx @@ -58,8 +58,13 @@ const OfficialImageSource = () => { { skip: !selectedRef }, ); - const [pullImage, { isLoading: isPulling, isError: isPullError }] = - usePullImageMutation(); + // The mutation state is scoped to the reference it was started with, + // so switching to another image doesn't show its busy/error state. + const [pullImage, pullState] = usePullImageMutation(); + const isPulling = + pullState.isLoading && pullState.originalArgs?.reference === selectedRef; + const isPullError = + pullState.isError && pullState.originalArgs?.reference === selectedRef; const showSelectionError = forceShowErrors && !hasOfficialSelection; const showPullValidation = hasOfficialSelection && imageExists === false; diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx index 0faa147c22..936e997e4f 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx @@ -16,6 +16,9 @@ import { import ImageSourceSelect from '../components/ImageSourceSelect'; +const KVM_REF = 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest'; +const AWS_REF = 'registry.redhat.io/rhel10/rhel-bootc-aws:latest'; + const mockRefetch = vi.fn(); const mockUseGetDistributionsQuery = vi.fn(); const mockUseGetImageExistsQuery = vi.fn(); @@ -47,6 +50,19 @@ const renderHostedImageSourceSelect = () => { }); }; +// Tests preset the selected image in state rather than driving the +// dropdown: the dropdown is on its way out as a selection mechanism. +const renderWithGuestImage = () => { + return renderImageSourceSelect({ + output: { + ...initialState.output, + imageSourceType: 'official', + imageTypes: ['guest-image'], + imageSource: KVM_REF, + }, + }); +}; + describe('ImageSourceSelect', () => { beforeEach(() => { vi.clearAllMocks(); @@ -103,6 +119,65 @@ describe('ImageSourceSelect', () => { }); }); + describe('Official images', () => { + test('pulls the selected container', async () => { + renderWithGuestImage(); + const user = createUser(); + + const pullButton = await screen.findByRole('button', { + name: /pull latest image/i, + }); + await clickWithWait(user, pullButton); + + expect(mockPullImage).toHaveBeenCalledWith({ reference: KVM_REF }); + }); + + test('shows the pulling state for the selected image', async () => { + mockUsePullImageMutation.mockReturnValue([ + mockPullImage, + { + isLoading: true, + isError: false, + originalArgs: { reference: KVM_REF }, + }, + ]); + + renderWithGuestImage(); + + expect( + await screen.findByRole('button', { name: /pulling image/i }), + ).toBeInTheDocument(); + }); + + test("does not show another image's pull as busy", async () => { + // A pull of the guest image is in flight while AWS is selected + mockUsePullImageMutation.mockReturnValue([ + mockPullImage, + { + isLoading: true, + isError: false, + originalArgs: { reference: KVM_REF }, + }, + ]); + + renderImageSourceSelect({ + output: { + ...initialState.output, + imageSourceType: 'official', + imageTypes: ['aws'], + imageSource: AWS_REF, + }, + }); + + expect( + await screen.findByRole('button', { name: /pull latest image/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pulling image/i }), + ).not.toBeInTheDocument(); + }); + }); + describe('Local images', () => { const renderLocalImageSource = () => { return renderImageSourceSelect({ From 1d03da251632750b7b06efef20955bec247644e4 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Mon, 10 Aug 2026 05:15:23 -0500 Subject: [PATCH 2/4] Wizard: re-check image existence when the image source mounts The image-exists query result was served from the RTK Query cache, so removing a container outside the wizard (podman rmi) went unnoticed when the wizard was reopened. Pass refetchOnMountOrArgChange so the existence check re-runs podman whenever the image source section is shown; the wizard-level pull validation shares the same cache entries and picks up the fresh result. --- .../ImageSourceSelect/OnPrem/OfficialImageSource.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx index 90c42facbf..6dbfe0bab2 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx @@ -53,9 +53,11 @@ const OfficialImageSource = () => { return KNOWN_IMAGES.map((known) => ({ ...known, arch })); }, [isAuthenticated, arch]); + // Local images can be removed outside the wizard (e.g. podman rmi), + // so bypass the cache and re-check whenever this section mounts. const { data: imageExists } = useGetImageExistsQuery( { reference: selectedRef! }, - { skip: !selectedRef }, + { skip: !selectedRef, refetchOnMountOrArgChange: true }, ); // The mutation state is scoped to the reference it was started with, From f0bb9949e94c0979e0a962b4c025bc88012ea625 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Mon, 10 Aug 2026 05:16:27 -0500 Subject: [PATCH 3/4] Wizard: re-check the registry login when the image source mounts Same staleness as the image existence check: logging out via the CLI went unnoticed because the auth status was served from the cache. Re-run the check whenever the image source section is shown. --- .../ImageSourceSelect/OnPrem/OfficialImageSource.tsx | 4 +++- .../components/ImageSourceSelect/OnPrem/RegistryAuth.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx index 6dbfe0bab2..14fdf5238f 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx @@ -42,7 +42,9 @@ const OfficialImageSource = () => { const hasOfficialSelection = useAppSelector(selectIsOfficialImage); const { data: authStatus, isLoading: isAuthLoading } = - useGetRegistryAuthStatusQuery(); + useGetRegistryAuthStatusQuery(undefined, { + refetchOnMountOrArgChange: true, + }); const isAuthenticated = authStatus?.status === 'authenticated'; const images = useMemo(() => { diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx index 597f51fc4a..fd313a0a12 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx @@ -210,7 +210,11 @@ const RegistryStatus = ({ username }: { username: string }) => { const RegistryAuth = () => { const [isFormVisible, setIsFormVisible] = useState(false); - const { data, isLoading } = useGetRegistryAuthStatusQuery(); + // The login can change outside the wizard (e.g. podman logout), so + // bypass the cache and re-check whenever this section mounts. + const { data, isLoading } = useGetRegistryAuthStatusQuery(undefined, { + refetchOnMountOrArgChange: true, + }); if (isLoading) { return ( From fdc321aa97bf54490fa53d377a0ee20c3f221ce5 Mon Sep 17 00:00:00 2001 From: Lucas Garfield Date: Mon, 10 Aug 2026 08:38:47 -0500 Subject: [PATCH 4/4] Wizard: let logged-out users browse and build official images Registry login is only needed to pull images, so drop the login-gated empty state and the "Login required" label. The image dropdown is now always shown; a logged-out user instead sees an inline info alert offering the registry login, and the pull buttons are disabled with an explanatory tooltip. When a selected image is missing from local storage, the pull validation now says how to resolve it: logged in, it asks for a pull; logged out, it points at the registry login. The auth status hook gets the same hosted-safe conditional export as the image existence check, since useImagePullValidation calls it unconditionally. --- .../OnPrem/OfficialImageSource.tsx | 129 +++++++++++------- .../ImageSourceSelect/OnPrem/RegistryAuth.tsx | 38 +++--- .../ImageSourceSelect/OnPrem/index.tsx | 3 +- .../tests/ImageSourceSelect.test.tsx | 82 +++++++++++ .../utilities/useValidation.tsx | 12 +- src/store/api/backend/index.ts | 13 +- 6 files changed, 204 insertions(+), 73 deletions(-) diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx index 14fdf5238f..257ae50ef1 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx @@ -8,6 +8,7 @@ import { HelperText, HelperTextItem, Spinner, + Tooltip, } from '@patternfly/react-core'; import { @@ -16,7 +17,10 @@ import { usePullImageMutation, } from '@/store/api/backend'; import { Distributions } from '@/store/api/backend/hosted'; -import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants'; +import { + IMAGE_REGISTRY_HOST, + KNOWN_IMAGES, +} from '@/store/api/backend/onprem/constants'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { changeDistribution, @@ -33,6 +37,42 @@ import { import ImageSelect from './ImageSelect'; import RegistryAuth from './RegistryAuth'; +type PullButtonProps = { + onPull: () => void; + isPulling: boolean; + isAuthenticated: boolean; + isDisabled?: boolean; +}; + +const PullButton = ({ + onPull, + isPulling, + isAuthenticated, + isDisabled, +}: PullButtonProps) => { + const button = ( + + ); + + if (isAuthenticated) { + return button; + } + + return ( + + {button} + + ); +}; + const OfficialImageSource = () => { const dispatch = useAppDispatch(); const arch = useAppSelector(selectArchitecture); @@ -47,13 +87,10 @@ const OfficialImageSource = () => { }); const isAuthenticated = authStatus?.status === 'authenticated'; - const images = useMemo(() => { - if (!isAuthenticated) { - return []; - } - - return KNOWN_IMAGES.map((known) => ({ ...known, arch })); - }, [isAuthenticated, arch]); + const images = useMemo( + () => KNOWN_IMAGES.map((known) => ({ ...known, arch })), + [arch], + ); // Local images can be removed outside the wizard (e.g. podman rmi), // so bypass the cache and re-check whenever this section mounts. @@ -86,48 +123,42 @@ const OfficialImageSource = () => { return ( <> - {isAuthenticated && ( - - - { - const selected = images.find( - (img) => img.reference === selection, + + + { + const selected = images.find( + (img) => img.reference === selection, + ); + if (selected) { + dispatch(changeImageSource(selected.reference)); + dispatch(changeDistribution(selected.distro as Distributions)); + dispatch( + changeImageTypes([selected.type as SupportedImageTypes]), ); - if (selected) { - dispatch(changeImageSource(selected.reference)); - dispatch( - changeDistribution(selected.distro as Distributions), - ); - dispatch( - changeImageTypes([selected.type as SupportedImageTypes]), - ); - } - }} - getLabel={(item) => item.name} - placeholder={'Select an official image'} + } + }} + getLabel={(item) => item.name} + placeholder={'Select an official image'} + /> + + {hasOfficialSelection && ( + + pullImage({ reference: selectedRef! })} + isPulling={isPulling} + isAuthenticated={isAuthenticated} + isDisabled={isAuthLoading} /> - {hasOfficialSelection && ( - - - - )} - - )} + )} + {showSelectionError && ( @@ -143,7 +174,9 @@ const OfficialImageSource = () => { {isPullError ? 'Failed to pull image. Please try again.' - : 'Image must be pulled before proceeding.'} + : isAuthenticated + ? 'Bootc container must be pulled before proceeding.' + : `Bootc container is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`} diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx index fd313a0a12..4fb5c87b42 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { + Alert, + AlertActionLink, Button, Card, CardBody, @@ -9,10 +11,6 @@ import { CardTitle, Content, ContentVariants, - EmptyState, - EmptyStateActions, - EmptyStateBody, - EmptyStateFooter, Flex, FlexItem, FormGroup, @@ -31,23 +29,21 @@ import { import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants'; import { OnPremError } from '@/store/api/shared'; -const EmptyCard = ({ openForm }: { openForm: (arg0: boolean) => void }) => { +const LoginPrompt = ({ openForm }: { openForm: (arg0: boolean) => void }) => { return ( - - - - Registry images come from {IMAGE_REGISTRY_HOST}. - - Sign in to browse available images for this release. - - - - - - - - - + openForm(true)}>Log in + } + > + You can build from images already on this system without logging in. + Pulling the latest images from {IMAGE_REGISTRY_HOST} requires a Red Hat + login. + ); }; @@ -230,7 +226,7 @@ const RegistryAuth = () => { } if (!isFormVisible) { - return ; + return ; } return ; diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx index 81c51da2c7..403c860fb1 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx @@ -7,7 +7,6 @@ import { CardTitle, FormGroup, Gallery, - Label, } from '@patternfly/react-core'; import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants'; @@ -47,7 +46,7 @@ const OnPremImageSourceSelect = () => { }} > - Official Red Hat images + Official Red Hat images Remote images from {IMAGE_REGISTRY_HOST} diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx index 936e997e4f..888151e099 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx @@ -108,6 +108,7 @@ describe('ImageSourceSelect', () => { expect(screen.getByText('Local images')).toBeInTheDocument(); expect(screen.queryByText('Custom images')).not.toBeInTheDocument(); expect(screen.queryByText('No login')).not.toBeInTheDocument(); + expect(screen.queryByText('Login required')).not.toBeInTheDocument(); }); test('does not auto-select an image on-prem', async () => { @@ -176,6 +177,87 @@ describe('ImageSourceSelect', () => { screen.queryByRole('button', { name: /pulling image/i }), ).not.toBeInTheDocument(); }); + + test('requires the bootc container to be pulled', async () => { + mockUseGetImageExistsQuery.mockReturnValue({ + data: false, + isLoading: false, + isError: false, + }); + + renderWithGuestImage(); + + expect( + await screen.findByText( + /bootc container must be pulled before proceeding/i, + ), + ).toBeInTheDocument(); + }); + }); + + describe('Not logged in', () => { + beforeEach(() => { + mockUseGetRegistryAuthStatusQuery.mockReturnValue({ + data: { status: 'unauthenticated' }, + isLoading: false, + isError: false, + error: undefined, + }); + }); + + test('displays the login prompt instead of an empty state', async () => { + renderImageSourceSelect(); + + expect( + await screen.findByText(/log in to pull the latest images/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/login to select an image/i), + ).not.toBeInTheDocument(); + }); + + test('disables the pull button', async () => { + renderWithGuestImage(); + const user = createUser(); + + const pullButton = await screen.findByRole('button', { + name: /pull latest image/i, + }); + expect(pullButton).toHaveAttribute('aria-disabled', 'true'); + + await clickWithWait(user, pullButton); + expect(mockPullImage).not.toHaveBeenCalled(); + }); + + test('points at the login when a missing image cannot be pulled', async () => { + mockUseGetImageExistsQuery.mockReturnValue({ + data: false, + isLoading: false, + isError: false, + }); + + renderWithGuestImage(); + + expect( + await screen.findByText(/log in to registry\.redhat\.io to pull it/i), + ).toBeInTheDocument(); + }); + + test('login action opens the login form', async () => { + renderImageSourceSelect(); + const user = createUser(); + + const loginButton = await screen.findByRole('button', { + name: /log in/i, + }); + await clickWithWait(user, loginButton); + + expect( + await screen.findByText(/log in to registry\.redhat\.io/i), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); }); describe('Local images', () => { diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx index 4618d065e7..fd6aa396c2 100644 --- a/src/Components/CreateImageWizard/utilities/useValidation.tsx +++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx @@ -12,8 +12,10 @@ import { BlueprintsResponse, useGetImageExistsQuery, useGetOscapCustomizationsQuery, + useGetRegistryAuthStatusQuery, useLazyGetBlueprintsQuery, } from '@/store/api/backend'; +import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants'; import { useShowActivationKeyQuery } from '@/store/api/rhsm'; import { useAppSelector } from '@/store/hooks'; import { selectIsOnPremise } from '@/store/slices/env'; @@ -1281,6 +1283,10 @@ export const useImagePullValidation = (): StepValidation => { { reference: imageSource! }, { skip: !isOnPremise || !isOfficialImage }, ); + const { data: authStatus } = useGetRegistryAuthStatusQuery(undefined, { + skip: !isOnPremise || !isOfficialImage, + }); + const isAuthenticated = authStatus?.status === 'authenticated'; if (!isOnPremise || !isOfficialImage) { return { errors: {}, disabledNext: false }; @@ -1292,7 +1298,11 @@ export const useImagePullValidation = (): StepValidation => { if (imageExists !== true) { return { - errors: { imagePull: 'Image must be pulled before proceeding' }, + errors: { + imagePull: isAuthenticated + ? 'Bootc container must be pulled before proceeding' + : `Bootc container is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`, + }, disabledNext: true, }; } diff --git a/src/store/api/backend/index.ts b/src/store/api/backend/index.ts index 83d319d3da..0fe6e8e8d7 100644 --- a/src/store/api/backend/index.ts +++ b/src/store/api/backend/index.ts @@ -109,7 +109,6 @@ export { getHostArch, getHostDistro, useExportBlueprintCockpitQuery, - useGetRegistryAuthStatusQuery, useLazyGetImageExistsQuery, useGetUploadConfigQuery, useLazyExportBlueprintCockpitQuery, @@ -133,6 +132,18 @@ export const useGetImageExistsQuery = ( }) ) as typeof composerQueries.useGetImageExistsQuery; +// Same conditional export, for the same caller. +export const useGetRegistryAuthStatusQuery = ( + process.env.IS_ON_PREMISE + ? composerQueries.useGetRegistryAuthStatusQuery + : () => ({ + data: undefined, + isLoading: false, + isError: false, + isFetching: false, + }) +) as typeof composerQueries.useGetRegistryAuthStatusQuery; + export { composerApi, errorMessage, imageBuilderApi }; // Re-export all types from hosted API (primary/canonical types)