From 91a3e6ab8f8b23fdede43b813c8103ce654ab368 Mon Sep 17 00:00:00 2001 From: Justin Sherrill Date: Tue, 7 Apr 2026 08:35:25 -0400 Subject: [PATCH] Wizard: clear timezone and locale for unsupported image types Network-installer already excluded timezone from its supported blueprint options, but the wizard wasn't acting on that restriction. Add clearTimezone and clearLocale reducers and wire them into both wizard versions so state is reset when restrictions indicate a customization should be hidden. Co-authored-by: Gianluca Zuccarelli --- .../CreateImageWizard/CreateImageWizard.tsx | 26 +++- .../tests/distributionDetailsApi.test.ts | 2 +- .../api/distributions/tests/hooks.test.tsx | 1 + src/store/slices/wizard/index.ts | 10 ++ .../slices/wizard/tests/wizardSlice.test.ts | 50 ++++++++ .../steps/ImageOutput/ImageOutput.test.tsx | 117 +++++++++++++++++- 6 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/Components/CreateImageWizard/CreateImageWizard.tsx b/src/Components/CreateImageWizard/CreateImageWizard.tsx index cf38d5497a..0a72971f47 100644 --- a/src/Components/CreateImageWizard/CreateImageWizard.tsx +++ b/src/Components/CreateImageWizard/CreateImageWizard.tsx @@ -34,6 +34,8 @@ import { changeProxy, changeServerUrl, changeTimezone, + clearLocale, + clearTimezone, initializeWizard, loadWizardState, selectDistribution, @@ -121,6 +123,7 @@ const CreateImageWizard = () => { const { userData } = useGetUser(auth); const hasTrackedInitialStepRef = useRef(false); const hasTrackedWizardOpenedRef = useRef(false); + const isHostDistroDetected = useRef(!isOnPremise); const { data: blueprintDetails, @@ -247,6 +250,7 @@ const CreateImageWizard = () => { const initializeHostDistro = async () => { const distro = await getHostDistro(); dispatch(changeDistribution(distro)); + isHostDistroDetected.current = true; }; const initializeHostArch = async () => { @@ -297,15 +301,33 @@ const CreateImageWizard = () => { return; } + if (restrictions.locale.shouldHide) { + dispatch(clearLocale()); + } + + if (restrictions.timezone.shouldHide) { + if (timezone) { + dispatch(clearTimezone()); + } + return; + } + const defaultTimezone = distribution === RHEL_10 || targetEnvironments.includes('azure') ? DEFAULT_TIMEZONE : 'America/New_York'; - if (!timezone) { + if (!timezone && isHostDistroDetected.current) { dispatch(changeTimezone(defaultTimezone)); } - }, [distribution, targetEnvironments, mode, dispatch]); + }, [ + distribution, + targetEnvironments, + mode, + dispatch, + restrictions, + timezone, + ]); useEffect(() => { if (!isOnPremise && showWizardModal && !hasTrackedWizardOpenedRef.current) { diff --git a/src/store/api/distributions/tests/distributionDetailsApi.test.ts b/src/store/api/distributions/tests/distributionDetailsApi.test.ts index 3e980772f9..10769885cb 100644 --- a/src/store/api/distributions/tests/distributionDetailsApi.test.ts +++ b/src/store/api/distributions/tests/distributionDetailsApi.test.ts @@ -50,7 +50,7 @@ describe('DISTRO_DETAILS configuration', () => { }); describe('network-installer restrictions', () => { - it('should only allow locale and fips for network-installer', () => { + it('should only allow fips and locale for network-installer', () => { const networkInstallerSupported = DISTRO_DETAILS['network-installer'].supported_blueprint_options; diff --git a/src/store/api/distributions/tests/hooks.test.tsx b/src/store/api/distributions/tests/hooks.test.tsx index 7d38f3f35c..52e76b51d5 100644 --- a/src/store/api/distributions/tests/hooks.test.tsx +++ b/src/store/api/distributions/tests/hooks.test.tsx @@ -247,6 +247,7 @@ describe('useCustomizationRestrictions hook logic', () => { expect(result.packages.shouldHide).toBe(true); expect(result.filesystem.shouldHide).toBe(true); expect(result.kernel.shouldHide).toBe(true); + expect(result.timezone.shouldHide).toBe(true); expect(result.users.shouldHide).toBe(true); }); diff --git a/src/store/slices/wizard/index.ts b/src/store/slices/wizard/index.ts index 7b593533c6..60e902dff6 100644 --- a/src/store/slices/wizard/index.ts +++ b/src/store/slices/wizard/index.ts @@ -1463,6 +1463,10 @@ export const wizardSlice = createSlice({ clearLanguages: (state) => { state.locale.languages = []; }, + clearLocale: (state) => { + state.locale.languages = []; + state.locale.keyboard = ''; + }, changeKeyboard: (state, action: PayloadAction) => { state.locale.keyboard = action.payload; }, @@ -1613,6 +1617,10 @@ export const wizardSlice = createSlice({ } } }, + clearTimezone: (state) => { + state.timezone.timezone = ''; + state.timezone.ntpservers = []; + }, changeHostname: (state, action: PayloadAction) => { state.hostname = action.payload; }, @@ -1841,6 +1849,7 @@ export const { removeLanguage, replaceLanguage, clearLanguages, + clearLocale, changeKeyboard, changeBlueprintName, setIsCustomName, @@ -1874,6 +1883,7 @@ export const { changeAapTlsConfirmation, addNtpServer, removeNtpServer, + clearTimezone, changeHostname, addPort, removePort, diff --git a/src/store/slices/wizard/tests/wizardSlice.test.ts b/src/store/slices/wizard/tests/wizardSlice.test.ts index 774b8f86e3..5edae41c28 100644 --- a/src/store/slices/wizard/tests/wizardSlice.test.ts +++ b/src/store/slices/wizard/tests/wizardSlice.test.ts @@ -5,6 +5,8 @@ import wizardReducer, { changeArchitecture, changeDistribution, changeImageTypes, + clearLocale, + clearTimezone, initializeWizard, initialState, loadWizardState, @@ -232,4 +234,52 @@ describe('wizardSlice core reducers', () => { }); }); }); + + describe('clearLocale', () => { + it('should reset languages and keyboard', () => { + const stateWithLocale: wizardState = { + ...initialState, + locale: { + languages: ['en_US.UTF-8', 'fr_FR.UTF-8'], + keyboard: 'us', + }, + }; + + const result = wizardReducer(stateWithLocale, clearLocale()); + + expect(result.locale.languages).toEqual([]); + expect(result.locale.keyboard).toBe(''); + }); + + it('should be a no-op on already empty locale state', () => { + const result = wizardReducer(initialState, clearLocale()); + + expect(result.locale.languages).toEqual([]); + expect(result.locale.keyboard).toBe(''); + }); + }); + + describe('clearTimezone', () => { + it('should reset timezone and ntpservers', () => { + const stateWithTimezone: wizardState = { + ...initialState, + timezone: { + timezone: 'America/New_York', + ntpservers: ['0.pool.ntp.org', '1.pool.ntp.org'], + }, + }; + + const result = wizardReducer(stateWithTimezone, clearTimezone()); + + expect(result.timezone.timezone).toBe(''); + expect(result.timezone.ntpservers).toEqual([]); + }); + + it('should be a no-op on already empty timezone state', () => { + const result = wizardReducer(initialState, clearTimezone()); + + expect(result.timezone.timezone).toBe(''); + expect(result.timezone.ntpservers).toEqual([]); + }); + }); }); diff --git a/src/test/Components/CreateImageWizard/steps/ImageOutput/ImageOutput.test.tsx b/src/test/Components/CreateImageWizard/steps/ImageOutput/ImageOutput.test.tsx index ea45153272..c8fd7ba07b 100644 --- a/src/test/Components/CreateImageWizard/steps/ImageOutput/ImageOutput.test.tsx +++ b/src/test/Components/CreateImageWizard/steps/ImageOutput/ImageOutput.test.tsx @@ -3,7 +3,16 @@ // Redux State → mapRequestFromState() → API Request. They could be replaced by unit // tests for the request mapper functions (mapRequestToState/mapRequestFromState) which // would be faster and more focused than full integration tests. -import { EDIT_BLUEPRINT } from '../../../../../constants'; +import { screen, within } from '@testing-library/react'; + +import { CreateBlueprintRequest } from '@/store/api/backend'; +import { clickWithWait, createUser } from '@/test/testUtils'; + +import { + CREATE_BLUEPRINT, + EDIT_BLUEPRINT, + RHEL_10, +} from '../../../../../constants'; import { mockBlueprintIds } from '../../../../fixtures/blueprints'; import { aarch64CreateBlueprintRequest, @@ -13,8 +22,14 @@ import { x86_64CreateBlueprintRequest, } from '../../../../fixtures/editMode'; import { + blueprintRequest, + enterBlueprintName, + goToReview, + interceptBlueprintRequest, interceptEditBlueprintRequest, + renderCreateMode, renderEditMode, + selectGuestImageTarget, } from '../../wizardTestUtils'; describe('Image output edit mode', () => { @@ -82,3 +97,103 @@ describe('Image output edit mode', () => { expect(receivedRequest).toEqual(expectedRequest); }); }); + +const selectNetworkInstaller = async () => { + const user = createUser(); + const checkbox = await screen.findByRole('checkbox', { + name: /Network installer checkbox/i, + }); + await clickWithWait(user, checkbox); + return checkbox; +}; + +describe('Network installer target', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('selecting network-installer shows alert and disables other checkboxes', async () => { + await renderCreateMode(); + const networkInstallerCheckbox = await selectNetworkInstaller(); + + await screen.findByText( + /This image type requires specific, minimal configuration for remote installation/i, + ); + const guestImageCheckbox = await screen.findByRole('checkbox', { + name: /Virtualization guest image/i, + }); + expect(guestImageCheckbox).toBeDisabled(); + + const bareMetalCheckbox = await screen.findByRole('checkbox', { + name: /Bare metal installer/i, + }); + expect(bareMetalCheckbox).toBeDisabled(); + + expect(networkInstallerCheckbox).toBeChecked(); + expect(networkInstallerCheckbox).toBeEnabled(); + }); + + test('selecting another target first disables network-installer', async () => { + await renderCreateMode(); + await selectGuestImageTarget(); + + const networkInstallerCheckbox = await screen.findByRole('checkbox', { + name: /Network installer checkbox/i, + }); + expect(networkInstallerCheckbox).toBeDisabled(); + }); + + test('selecting network-installer only shows base settings, advanced settings, and review steps', async () => { + await renderCreateMode(); + await selectNetworkInstaller(); + + const navigation = await screen.findByRole('navigation', { + name: /wizard steps/i, + }); + + const stepButtons = within(navigation).getAllByRole('button'); + expect(stepButtons).toHaveLength(3); + + expect( + within(navigation).getByRole('button', { name: /base settings/i }), + ).toBeInTheDocument(); + expect( + within(navigation).getByRole('button', { name: /advanced settings/i }), + ).toBeInTheDocument(); + expect( + within(navigation).getByRole('button', { name: /review/i }), + ).toBeInTheDocument(); + }); + + test('can create a blueprint with network-installer', async () => { + await renderCreateMode(); + await selectNetworkInstaller(); + await enterBlueprintName('Red Velvet'); + + await goToReview(); + + const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT); + + const expectedRequest: CreateBlueprintRequest = { + ...blueprintRequest, + distribution: RHEL_10, + customizations: { + locale: { + languages: ['C.UTF-8'], + }, + }, + image_requests: [ + { + architecture: 'x86_64', + image_type: 'network-installer', + upload_request: { + options: {}, + type: 'aws.s3', + }, + }, + ], + }; + + expect(receivedRequest).toEqual(expectedRequest); + }); +});