diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ContainerSection.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ContainerSection.tsx new file mode 100644 index 0000000000..b3fd24b050 --- /dev/null +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ContainerSection.tsx @@ -0,0 +1,159 @@ +import React from 'react'; + +import { + Button, + ClipboardCopy, + Content, + Flex, + FlexItem, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Spinner, + Tooltip, +} from '@patternfly/react-core'; + +import { + useGetImageExistsQuery, + useGetRegistryAuthStatusQuery, + usePullImageMutation, +} from '@/store/api/backend'; +import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants'; + +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} + + ); +}; + +type ContainerSectionProps = { + label: string; + // Unset until a target environment implies a container + reference?: string | undefined; + // Human-readable image name, shown as a subtitle under the reference + name?: string | undefined; + helperText?: string; +}; + +// A container determined by the selected target environment. The +// reference is shown in a read-only, copyable field - PatternFly's +// "boxed" read-only form control - because the containers behind the +// official images are never chosen directly. When local images become +// selectable this slot swaps to a real select, so the layout +// deliberately reads as a form field. +const ContainerSection = ({ + label, + reference, + name, + helperText, +}: ContainerSectionProps) => { + const { data: authStatus, isLoading: isAuthLoading } = + useGetRegistryAuthStatusQuery(undefined, { + refetchOnMountOrArgChange: true, + }); + const isAuthenticated = authStatus?.status === 'authenticated'; + + // 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: reference! }, + { skip: !reference, refetchOnMountOrArgChange: true }, + ); + + // 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 === reference; + const isPullError = + pullState.isError && pullState.originalArgs?.reference === reference; + + const showPullValidation = !!reference && imageExists === false; + + if (!reference) { + return ( + + + Select a target environment to see the container image it uses. + + + ); + } + + return ( + + + + + {reference} + + + + pullImage({ reference })} + isPulling={isPulling} + isAuthenticated={isAuthenticated} + isDisabled={isAuthLoading} + /> + + + {(name || helperText) && ( + + + {name && {name}} + {helperText && {helperText}} + + + )} + {showPullValidation && ( + + + + {isPullError + ? 'Failed to pull image. Please try again.' + : isAuthenticated + ? `${label} must be pulled before proceeding.` + : `${label} is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`} + + + + )} + + ); +}; + +export default ContainerSection; diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ImageSelect.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ImageSelect.tsx deleted file mode 100644 index 41d3eab5ed..0000000000 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/ImageSelect.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useState } from 'react'; - -import { - Label, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, -} from '@patternfly/react-core'; - -import { simpleTargetNames } from '@/constants'; -import type { BootcDistributionItem } from '@/store/api/backend'; -import { isImageType } from '@/store/slices/wizard'; - -import './OnPrem.css'; - -type ImageSelectProps = { - items: BootcDistributionItem[]; - selectedRef: string | undefined; - onSelect: (event?: React.MouseEvent, selection?: string | number) => void; - getLabel: (item: BootcDistributionItem) => string; - placeholder?: string; - isDisabled?: boolean; - ariaDescribedBy?: string | undefined; -}; - -const toggleStyle = { - minWidth: '20rem', - maxWidth: '100%', -} as React.CSSProperties; - -const ImageSelect = ({ - items, - selectedRef, - onSelect, - getLabel, - placeholder = 'Select an image', - isDisabled = false, - ariaDescribedBy, -}: ImageSelectProps) => { - const [isOpen, setIsOpen] = useState(false); - - const selectedItem = items.find((item) => item.reference === selectedRef); - - const handleSelect = ( - event?: React.MouseEvent, - selection?: string | number, - ) => { - onSelect(event, selection); - setIsOpen(false); - }; - - const toggle = (toggleRef: React.Ref) => ( - setIsOpen((prev) => !prev)} - isExpanded={isOpen} - isDisabled={isDisabled} - style={toggleStyle} - aria-describedby={ariaDescribedBy} - > - {selectedItem ? getLabel(selectedItem) : placeholder} - - ); - - return ( -
- -
- ); -}; - -export default ImageSelect; 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 257ae50ef1..f9c99e4a57 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx @@ -1,120 +1,24 @@ -import React, { useMemo } from 'react'; +import React from 'react'; +import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants'; +import { useAppSelector } from '@/store/hooks'; import { - Button, - Flex, - FlexItem, - FormHelperText, - HelperText, - HelperTextItem, - Spinner, - Tooltip, -} from '@patternfly/react-core'; - -import { - useGetImageExistsQuery, - useGetRegistryAuthStatusQuery, - usePullImageMutation, -} from '@/store/api/backend'; -import { Distributions } from '@/store/api/backend/hosted'; -import { - IMAGE_REGISTRY_HOST, - KNOWN_IMAGES, -} from '@/store/api/backend/onprem/constants'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { - changeDistribution, - changeImageSource, - changeImageTypes, - selectArchitecture, - selectForceShowErrors, selectImageSource, selectImageSourceType, - selectIsOfficialImage, - type SupportedImageTypes, } from '@/store/slices/wizard'; -import ImageSelect from './ImageSelect'; +import ContainerSection from './ContainerSection'; 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} - - ); -}; - +// The target environment radios are the only way to choose an official +// image (the output slice resolves the image from the selected type); +// this section just shows what that choice maps to and lets the user +// pull it. const OfficialImageSource = () => { - const dispatch = useAppDispatch(); - const arch = useAppSelector(selectArchitecture); const selectedRef = useAppSelector(selectImageSource); const imageSourceType = useAppSelector(selectImageSourceType); - const forceShowErrors = useAppSelector(selectForceShowErrors); - const hasOfficialSelection = useAppSelector(selectIsOfficialImage); - - const { data: authStatus, isLoading: isAuthLoading } = - useGetRegistryAuthStatusQuery(undefined, { - refetchOnMountOrArgChange: true, - }); - const isAuthenticated = authStatus?.status === 'authenticated'; - - 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. - const { data: imageExists } = useGetImageExistsQuery( - { reference: selectedRef! }, - { skip: !selectedRef, refetchOnMountOrArgChange: true }, - ); - - // 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; - const errorId = showSelectionError - ? 'official-image-selection-error' - : showPullValidation - ? 'official-image-pull-error' - : undefined; + const selected = KNOWN_IMAGES.find((img) => img.reference === selectedRef); if (imageSourceType !== 'official') { return null; @@ -123,64 +27,11 @@ const OfficialImageSource = () => { return ( <> - - - { - 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]), - ); - } - }} - getLabel={(item) => item.name} - placeholder={'Select an official image'} - /> - - {hasOfficialSelection && ( - - pullImage({ reference: selectedRef! })} - isPulling={isPulling} - isAuthenticated={isAuthenticated} - isDisabled={isAuthLoading} - /> - - )} - - {showSelectionError && ( - - - - Select an official image to proceed. - - - - )} - {showPullValidation && ( - - - - {isPullError - ? 'Failed to pull image. Please try again.' - : 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/TargetEnvironment.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx index cef5535840..91bdfa79c4 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/TargetEnvironment.tsx @@ -14,12 +14,15 @@ import { import { rhsmApi } from '@/store/api'; import { + categorizeEnvironments, type Distributions, useGetArchitectureEnvironmentsQuery, useGetDistributionEnvironmentsQuery, } from '@/store/api/backend'; +import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants'; import { useCustomizationRestrictions } from '@/store/api/distributions'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { selectIsOnPremise } from '@/store/slices/env'; import { changeImageTypes, changeIsoPayloadReference, @@ -27,7 +30,6 @@ import { selectArchitecture, selectDistribution, selectForceShowErrors, - selectImageSourceFilter, selectImageTypes, selectIsImageMode, selectIsOnlyNetworkInstallerSelected, @@ -42,6 +44,13 @@ import TargetEnvironmentOption from './TargetEnvironmentOption'; const TEXT_WRAP_WIDTH = '54rem'; +// On-prem image mode always offers the same environments: the ones the +// official images support. Keeping the list independent of the current +// selection means it doesn't shrink while no image is selected. +const KNOWN_IMAGE_ENVIRONMENTS = categorizeEnvironments([ + ...new Set(KNOWN_IMAGES.map((image) => image.type)), +]); + const createLabelWithTooltip = ( prefix: string, tooltipText: string, @@ -81,19 +90,24 @@ const TargetEnvironment = () => { selectedImageTypes: environments, }); + const isOnPremise = useAppSelector(selectIsOnPremise); + const isOnPremImageMode = isImageMode && isOnPremise; + const archResult = useGetArchitectureEnvironmentsQuery( { distribution: distribution as Distributions, arch }, { skip: isImageMode }, ); - const imageSourceFilter = useAppSelector(selectImageSourceFilter); - const distroResult = useGetDistributionEnvironmentsQuery( - { arch, distro: distribution, ...imageSourceFilter }, - { skip: !isImageMode }, + { arch, distro: distribution }, + { skip: !isImageMode || isOnPremise }, ); - const { data, isFetching, isError } = isImageMode ? distroResult : archResult; + const { data, isFetching, isError } = isOnPremImageMode + ? { data: KNOWN_IMAGE_ENVIRONMENTS, isFetching: false, isError: false } + : isImageMode + ? distroResult + : archResult; const { publicClouds = [], diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx index 888151e099..24e52e88ff 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx @@ -50,8 +50,9 @@ 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. +// The image is implied by the selected target environment; these tests +// preset the resulting state (the radio interaction itself is covered +// by the TargetEnvironment and output slice tests). const renderWithGuestImage = () => { return renderImageSourceSelect({ output: { @@ -121,6 +122,29 @@ describe('ImageSourceSelect', () => { }); describe('Official images', () => { + test('shows a hint until a target environment is selected', async () => { + renderImageSourceSelect(); + + expect(await screen.findByText('Bootc container')).toBeInTheDocument(); + expect( + screen.getByText( + /select a target environment to see the container image it uses/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pull latest image/i }), + ).not.toBeInTheDocument(); + }); + + test('shows the container reference for the selected target environment', async () => { + renderWithGuestImage(); + + expect(await screen.findByDisplayValue(KVM_REF)).toBeInTheDocument(); + expect( + screen.getByText('Red Hat Enterprise Linux (RHEL) 10.3'), + ).toBeInTheDocument(); + }); + test('pulls the selected container', async () => { renderWithGuestImage(); const user = createUser(); @@ -216,6 +240,12 @@ describe('ImageSourceSelect', () => { ).not.toBeInTheDocument(); }); + test('still shows the selected container reference', async () => { + renderWithGuestImage(); + + expect(await screen.findByDisplayValue(KVM_REF)).toBeInTheDocument(); + }); + test('disables the pull button', async () => { renderWithGuestImage(); const user = createUser(); @@ -279,6 +309,7 @@ describe('ImageSourceSelect', () => { expect( screen.getByText(/cockpit image builder 10\.4/i), ).toBeInTheDocument(); + expect(screen.queryByText('Bootc container')).not.toBeInTheDocument(); }); test('displays the image-builder CLI example', async () => { diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx index d1818a8462..8f922680cb 100644 --- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx +++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/TargetEnvironment.test.tsx @@ -1,14 +1,22 @@ +import React from 'react'; + import { screen } from '@testing-library/react'; +import { vi } from 'vitest'; import { RHEL_10 } from '@/constants'; import { Distributions } from '@/store/api/backend'; -import { initialState, selectImageTypes } from '@/store/slices/wizard'; +import { + initialState, + selectImageSource, + selectImageTypes, +} from '@/store/slices/wizard'; import { clickWithWait, composeHandlers, createArchitecturesHandler, createUser, fetchMock, + renderWithRedux, type WizardStateOverrides, } from '@/test/testUtils'; @@ -24,6 +32,8 @@ import { setupErrorHandler, } from './mocks'; +import TargetEnvironment from '../components/TargetEnvironment'; + fetchMock.enableMocks(); beforeEach(() => { @@ -333,6 +343,103 @@ describe('TargetEnvironment', () => { expect(selectImageTypes(store.getState())).toEqual(['aws']); }); + // On-prem image mode always offers the official image environments, + // independent of whether an image is selected yet. + const renderOnPremTargetEnvironment = ( + outputOverrides: Partial = {}, + ) => { + return renderWithRedux( + , + { + ...imageModeOverrides, + output: { + ...initialState.output, + distribution: RHEL_10 as Distributions, + imageSource: 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest', + imageTypes: ['guest-image'], + ...outputOverrides, + }, + }, + { + preloadedState: { + env: { isOnPremise: true }, + }, + }, + ); + }; + + test('offers every official image type on-prem', async () => { + renderOnPremTargetEnvironment(); + + expect( + await screen.findByRole('radio', { + name: /Virtualization.*Guest image/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: /Amazon Web Services/i }), + ).toBeInTheDocument(); + }); + + test('offers the same environments when no image is selected', async () => { + renderOnPremTargetEnvironment({ + imageSource: undefined, + imageTypes: [], + }); + + expect( + await screen.findByRole('radio', { + name: /Virtualization.*Guest image/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: /Amazon Web Services/i }), + ).toBeInTheDocument(); + }); + + test('selecting a radio selects the matching official image', async () => { + // The image selection in the output slice only ships on-prem + vi.stubEnv('IS_ON_PREMISE', 'true'); + + const user = createUser(); + const { store } = renderOnPremTargetEnvironment({ + imageSource: undefined, + imageTypes: [], + }); + + const guestRadio = await screen.findByRole('radio', { + name: /Virtualization.*Guest image/i, + }); + await clickWithWait(user, guestRadio); + + expect(selectImageTypes(store.getState())).toEqual(['guest-image']); + expect(selectImageSource(store.getState())).toBe( + 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest', + ); + + vi.unstubAllEnvs(); + }); + + test('selecting a radio switches the official image to the sibling type', async () => { + // The image selection in the output slice only ships on-prem + vi.stubEnv('IS_ON_PREMISE', 'true'); + + const user = createUser(); + const { store } = renderOnPremTargetEnvironment(); + + const awsRadio = await screen.findByRole('radio', { + name: /Amazon Web Services/i, + }); + await clickWithWait(user, awsRadio); + + expect(selectImageTypes(store.getState())).toEqual(['aws']); + expect(selectImageSource(store.getState())).toBe( + 'registry.redhat.io/rhel10/rhel-bootc-aws:latest', + ); + + vi.unstubAllEnvs(); + }); + test('shows loading state while fetching distributions', async () => { fetchMock.mockResponse(() => new Promise(() => {})); diff --git a/src/store/api/backend/derived.ts b/src/store/api/backend/derived.ts index eb7175dac9..5d24024ee1 100644 --- a/src/store/api/backend/derived.ts +++ b/src/store/api/backend/derived.ts @@ -14,7 +14,6 @@ import type { Distributions, } from './hosted'; import { imageBuilderApi } from './hosted/enhancedImageBuilderApi'; -import { KNOWN_IMAGES } from './onprem/constants'; import { composerApi } from './onprem/enhancedComposerApi'; export type CategorizedEnvironments = { @@ -92,9 +91,9 @@ const derivedApi = backendApi.injectEndpoints({ getDistributionEnvironments: builder.query< DistributionEnvironmentsResult, - { arch: string; distro?: string; imageSource?: string } + { arch: string; distro?: string } >({ - queryFn: async ({ imageSource, ...queryArgs }, api) => { + queryFn: async (queryArgs, api) => { const result = await api.dispatch( backendApi.endpoints.getDistributions.initiate( { kind: 'bootc', ...queryArgs }, @@ -116,18 +115,7 @@ const derivedApi = backendApi.injectEndpoints({ const distributions = result.data.filter(isBootcDistribution); - // When an exact image reference is provided, narrow the - // available target types to only those matching that image. - // This is used on-prem where each container image supports - // a single output type via its image-builder.image.type label. - const selectedType = imageSource - ? (distributions.find((d) => d.reference === imageSource)?.type ?? - KNOWN_IMAGES.find((k) => k.reference === imageSource)?.type) - : undefined; - - const imageTypes = selectedType - ? [selectedType] - : [...new Set(distributions.map((d) => d.type))]; + const imageTypes = [...new Set(distributions.map((d) => d.type))]; return { data: { diff --git a/src/store/middleware/listeners.ts b/src/store/middleware/listeners.ts index 6ab9be0450..02fbd5926b 100644 --- a/src/store/middleware/listeners.ts +++ b/src/store/middleware/listeners.ts @@ -6,6 +6,7 @@ import { changeArchitecture, changeBlueprintMode, changeDistribution, + changeImageTypes, } from '../slices/wizard'; // export from slices/wizard/listeners rather than slices/wizard // this is needed to avoid circular dependencies @@ -13,6 +14,7 @@ import { clearUnsupportedRegistration, filterImageTypes, registerLater, + resolveOfficialImage, } from '../slices/wizard/listeners'; export const listenerMiddleware = createListenerMiddleware(); @@ -39,3 +41,8 @@ startListening({ actionCreator: changeBlueprintMode, effect: clearUnsupportedRegistration, }); + +startListening({ + actionCreator: changeImageTypes, + effect: resolveOfficialImage, +}); diff --git a/src/store/slices/wizard/listeners.ts b/src/store/slices/wizard/listeners.ts index 2f0190752a..4b3631209b 100644 --- a/src/store/slices/wizard/listeners.ts +++ b/src/store/slices/wizard/listeners.ts @@ -1,12 +1,17 @@ -import { backendApi } from '@/store/api/backend'; +import { backendApi, type Distributions } from '@/store/api/backend'; +import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants'; import type { WizardListenerEffect } from '@/store/middleware/types'; import { selectIsImageMode } from './details'; import { + changeDistribution, + changeImageSource, changeImageTypes, isRhel, selectArchitecture, selectDistribution, + selectImageSource, + selectImageSourceType, selectImageTypes, } from './output'; import { @@ -90,3 +95,59 @@ export const clearUnsupportedRegistration: WizardListenerEffect = ( listenerApi.dispatch(changeAapEnabled(false)); } }; + +// On-prem, the target environment radios are the only way to choose an +// official image: each type maps to exactly one official image, so a +// type change resolves the image source and distribution. Guarded by +// IS_ON_PREMISE, a build-time constant, so the hosted bundle +// dead-code-eliminates it and can never have its image source +// rewritten. +export const resolveOfficialImage: WizardListenerEffect = ( + _action, + listenerApi, +) => { + if (!process.env.IS_ON_PREMISE) { + return; + } + + const state = listenerApi.getState(); + if ( + !selectIsImageMode(state) || + selectImageSourceType(state) !== 'official' + ) { + return; + } + + const imageTypes = selectImageTypes(state); + if (imageTypes.length === 0) { + return; + } + const targetType = imageTypes[0]; + + const currentRef = selectImageSource(state); + const current = KNOWN_IMAGES.find((k) => k.reference === currentRef); + // A set but unknown reference belongs to another flow (e.g. an + // imported blueprint); leave it alone. + if (currentRef && !current) { + return; + } + + // Prefer the sibling published under the same name, so a type change + // stays within the same release once several are offered. + const selected = + current?.type === targetType + ? current + : ((current && + KNOWN_IMAGES.find( + (k) => k.name === current.name && k.type === targetType, + )) ?? + KNOWN_IMAGES.find((k) => k.type === targetType)); + if (!selected) { + return; + } + + if (selected !== current) { + listenerApi.dispatch(changeImageSource(selected.reference)); + listenerApi.dispatch(changeDistribution(selected.distro as Distributions)); + } +}; diff --git a/src/store/slices/wizard/output/selectors.ts b/src/store/slices/wizard/output/selectors.ts index 8b4650a5d5..95854474bc 100644 --- a/src/store/slices/wizard/output/selectors.ts +++ b/src/store/slices/wizard/output/selectors.ts @@ -2,7 +2,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '@/store'; import { isKnownImageRef } from '@/store/api/backend/onprem/constants'; -import { selectIsOnPremise } from '@/store/slices/env'; export const selectImageSource = (state: RootState) => { return state.wizard.output.imageSource; @@ -48,13 +47,3 @@ export const selectIsOfficialImage = createSelector( selectImageSource, (imageSource) => !!imageSource && isKnownImageRef(imageSource), ); - -export const selectImageSourceFilter = createSelector( - selectIsOnPremise, - selectImageSource, - ( - isOnPremise, - imageSource, - ): { imageSource: string } | Record => - isOnPremise && imageSource ? { imageSource } : {}, -); diff --git a/src/store/slices/wizard/output/tests/output.test.ts b/src/store/slices/wizard/output/tests/output.test.ts index 8aa757679e..17dd5c6289 100644 --- a/src/store/slices/wizard/output/tests/output.test.ts +++ b/src/store/slices/wizard/output/tests/output.test.ts @@ -15,7 +15,6 @@ import { selectBootcDistributions, selectDistribution, selectImageSource, - selectImageSourceFilter, selectImageTypes, selectIsOnlyNetworkInstallerSelected, selectIsoPayloadReference, @@ -24,7 +23,7 @@ import { type WizardState, } from '@/store/slices/wizard'; -import { createMockState, mockRootState } from '../../tests/mockWizardState'; +import { createMockState } from '../../tests/mockWizardState'; describe('output reducers', () => { describe('changeImageSource', () => { @@ -410,44 +409,4 @@ describe('output selectors', () => { expect(selectIsOtherEnvironmentSelected(state)).toBe(false); }); }); - - describe('selectImageSourceFilter', () => { - it('should return imageSource when on-premise with imageSource set', () => { - const state = { - ...mockRootState, - env: { isOnPremise: true }, - wizard: { - ...mockRootState.wizard, - output: { - ...initialState.output, - imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0', - }, - }, - }; - - expect(selectImageSourceFilter(state)).toEqual({ - imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0', - }); - }); - - it('should return empty object when on-premise with no imageSource', () => { - const state = { - ...mockRootState, - env: { isOnPremise: true }, - }; - - expect(selectImageSourceFilter(state)).toEqual({}); - }); - - it('should return empty object when hosted, even with imageSource set', () => { - const state = createMockState({ - output: { - ...initialState.output, - imageSource: 'registry.redhat.io/rhel10/rhel-bootc:10.0', - }, - }); - - expect(selectImageSourceFilter(state)).toEqual({}); - }); - }); }); diff --git a/src/store/slices/wizard/tests/resolveOfficialImage.test.ts b/src/store/slices/wizard/tests/resolveOfficialImage.test.ts new file mode 100644 index 0000000000..c355d16b6e --- /dev/null +++ b/src/store/slices/wizard/tests/resolveOfficialImage.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { changeImageTypes, initialState } from '@/store/slices/wizard'; +import { + createListenerApi, + createMockState, +} from '@/store/slices/wizard/tests/mockWizardState'; + +import { resolveOfficialImage } from '../listeners'; + +const KVM_REF = 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest'; +const AWS_REF = 'registry.redhat.io/rhel10/rhel-bootc-aws:latest'; + +const imageModeState = (outputOverrides = {}) => + createMockState({ + details: { + ...initialState.details, + blueprint: { + ...initialState.details.blueprint, + mode: 'image', + }, + }, + output: { + ...initialState.output, + imageSourceType: 'official', + ...outputOverrides, + }, + }); + +describe('resolveOfficialImage', () => { + beforeEach(() => { + vi.stubEnv('IS_ON_PREMISE', 'true'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('selects the official image and distribution for the chosen type', () => { + const listenerApi = createListenerApi( + imageModeState({ imageTypes: ['guest-image'] }), + ); + + resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi); + + expect(listenerApi.dispatch).toHaveBeenCalledWith({ + type: 'wizard/output/changeImageSource', + payload: KVM_REF, + }); + expect(listenerApi.dispatch).toHaveBeenCalledWith({ + type: 'wizard/output/changeDistribution', + payload: 'rhel-10.3', + }); + }); + + it('switches a known image to its sibling for the selected type', () => { + const listenerApi = createListenerApi( + imageModeState({ imageSource: KVM_REF, imageTypes: ['aws'] }), + ); + + resolveOfficialImage(changeImageTypes(['aws']), listenerApi); + + expect(listenerApi.dispatch).toHaveBeenCalledWith({ + type: 'wizard/output/changeImageSource', + payload: AWS_REF, + }); + }); + + it('keeps a known image when its type is selected', () => { + const listenerApi = createListenerApi( + imageModeState({ imageSource: AWS_REF, imageTypes: ['aws'] }), + ); + + resolveOfficialImage(changeImageTypes(['aws']), listenerApi); + + expect(listenerApi.dispatch).not.toHaveBeenCalled(); + }); + + it('does not change an unknown image reference', () => { + const listenerApi = createListenerApi( + imageModeState({ + imageSource: 'localhost/my-derived-image:latest', + imageTypes: ['aws'], + }), + ); + + resolveOfficialImage(changeImageTypes(['aws']), listenerApi); + + expect(listenerApi.dispatch).not.toHaveBeenCalled(); + }); + + it('does not select an image for the local source type', () => { + const listenerApi = createListenerApi( + imageModeState({ + imageSourceType: 'local', + imageTypes: ['guest-image'], + }), + ); + + resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi); + + expect(listenerApi.dispatch).not.toHaveBeenCalled(); + }); + + it('does not run in package mode', () => { + // On-prem package mode also dispatches changeImageTypes (e.g. the + // architecture filter); the resolver must not touch the state then. + const listenerApi = createListenerApi( + createMockState({ + output: { + ...initialState.output, + imageTypes: ['aws', 'guest-image'], + }, + }), + ); + + resolveOfficialImage(changeImageTypes(['aws', 'guest-image']), listenerApi); + + expect(listenerApi.dispatch).not.toHaveBeenCalled(); + }); + + it('does not run in the hosted bundle', () => { + vi.stubEnv('IS_ON_PREMISE', ''); + const listenerApi = createListenerApi( + imageModeState({ imageTypes: ['guest-image'] }), + ); + + resolveOfficialImage(changeImageTypes(['guest-image']), listenerApi); + + expect(listenerApi.dispatch).not.toHaveBeenCalled(); + }); +});