diff --git a/src/Components/CreateImageWizard/LabelInput.tsx b/src/Components/CreateImageWizard/LabelInput.tsx index f73cb1c536..ff16ef4799 100644 --- a/src/Components/CreateImageWizard/LabelInput.tsx +++ b/src/Components/CreateImageWizard/LabelInput.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Button, @@ -18,7 +18,13 @@ import { UnknownAction } from '@reduxjs/toolkit'; import { StepValidation } from './utilities/useValidation'; import { UNDEFINED_GROUPS_WARNING_KEY } from '../../constants'; -import { useAppDispatch } from '../../store/hooks'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + clearPendingInput, + resetForceShowErrors, + selectForceShowErrors, + setPendingInput, +} from '../../store/slices/wizard'; const DEFAULT_TRUNCATE_LENGTH = 20; const DEFAULT_CHIP_COLLAPSE_THRESHOLD = 4; @@ -59,11 +65,18 @@ const LabelInput = ({ helperText, }: LabelInputProps) => { const dispatch = useAppDispatch(); + const forceShowErrors = useAppSelector(selectForceShowErrors); const [inputValue, setInputValue] = useState(''); const [onStepInputErrorText, setOnStepInputErrorText] = useState(''); let [invalidImports, duplicateImports] = ['', '']; + useEffect(() => { + return () => { + dispatch(clearPendingInput(fieldName)); + }; + }, [dispatch, fieldName]); + if (stepValidation.errors[fieldName]) { [invalidImports, duplicateImports] = stepValidation.errors[fieldName].split('|'); @@ -75,6 +88,14 @@ const LabelInput = ({ ) => { setInputValue(value); setOnStepInputErrorText(''); + if (forceShowErrors) { + dispatch(resetForceShowErrors()); + } + if (value.trim()) { + dispatch(setPendingInput(fieldName)); + } else { + dispatch(clearPendingInput(fieldName)); + } }; const addItem = (value: string) => { @@ -135,6 +156,7 @@ const LabelInput = ({ } dispatch(addAction(trimmed)); + dispatch(clearPendingInput(fieldName)); setInputValue(''); setOnStepInputErrorText(''); }; @@ -156,6 +178,10 @@ const LabelInput = ({ if (duplicateImports) errors.push(duplicateImports); const warning = stepValidation.errors[UNDEFINED_GROUPS_WARNING_KEY]; + const unaddedInputWarning = + forceShowErrors && inputValue.trim() + ? `"${inputValue.trim()}" has not been added. Press Enter or click ${hideAddLabel ? 'the add icon' : 'Add'} to add it.` + : ''; const totalItems = (requiredList?.length ?? 0) + (list?.length ?? 0); return ( @@ -218,6 +244,11 @@ const LabelInput = ({ {warning && ( {warning} )} + {unaddedInputWarning && errors.length === 0 && ( + + {unaddedInputWarning} + + )} diff --git a/src/Components/CreateImageWizard/tests/LabelInput.test.tsx b/src/Components/CreateImageWizard/tests/LabelInput.test.tsx index 857bce0d84..e110a5d7f9 100644 --- a/src/Components/CreateImageWizard/tests/LabelInput.test.tsx +++ b/src/Components/CreateImageWizard/tests/LabelInput.test.tsx @@ -12,12 +12,14 @@ import { import LabelInput from '../LabelInput'; import type { StepValidation } from '../utilities/useValidation'; -const { dispatchMock } = vi.hoisted(() => ({ +const { dispatchMock, forceShowErrorsMock } = vi.hoisted(() => ({ dispatchMock: vi.fn(), + forceShowErrorsMock: { value: false }, })); vi.mock('@/store/hooks', () => ({ useAppDispatch: () => dispatchMock, + useAppSelector: () => forceShowErrorsMock.value, })); const stepValidation: StepValidation = { @@ -46,6 +48,7 @@ const renderLabelInput = ( describe('LabelInput', () => { beforeEach(() => { dispatchMock.mockClear(); + forceShowErrorsMock.value = false; }); test('does not dispatch for empty input via Add button or Enter', async () => { @@ -109,7 +112,10 @@ describe('LabelInput', () => { ); expect(screen.getByText('Item already exists.')).toBeInTheDocument(); - expect(dispatchMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: 'test/add', + payload: 'admins', + }); }); test('shows field-specific validation error for invalid input', async () => { @@ -132,7 +138,10 @@ describe('LabelInput', () => { expect( screen.getByText('Expected format: . Example: admin'), ).toBeInTheDocument(); - expect(dispatchMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: 'test/add', + payload: 'invalid value', + }); }); test('dispatches remove action when removing a chip', async () => { @@ -149,4 +158,35 @@ describe('LabelInput', () => { payload: 'admins', }); }); + + test('shows warning for unadded input when forceShowErrors is true', async () => { + forceShowErrorsMock.value = true; + renderLabelInput(); + const user = createUser(); + + await typeWithWait( + user, + screen.getByPlaceholderText('Add label'), + 'pending-value', + ); + + expect( + screen.getByText( + '"pending-value" has not been added. Press Enter or click Add to add it.', + ), + ).toBeInTheDocument(); + }); + + test('does not show unadded warning when forceShowErrors is false', async () => { + renderLabelInput(); + const user = createUser(); + + await typeWithWait( + user, + screen.getByPlaceholderText('Add label'), + 'pending-value', + ); + + expect(screen.queryByText(/has not been added/)).not.toBeInTheDocument(); + }); }); diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx index b22f25db73..8fc6df9137 100644 --- a/src/Components/CreateImageWizard/utilities/useValidation.tsx +++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx @@ -59,6 +59,7 @@ import { selectLanguages, selectNtpServers, selectOrgId, + selectPendingInputFields, selectRegistrationType, selectSatelliteCaCertificate, selectSatelliteRegistrationCommand, @@ -128,6 +129,11 @@ export type UsersStepValidation = { disabledNext: boolean; }; +const hasPendingInputForFields = ( + pendingInputFields: string[], + fieldNames: string[], +): boolean => fieldNames.some((f) => pendingInputFields.includes(f)); + export function useIsBlueprintValid(): boolean { const aap = useAAPValidation(); const registration = useRegistrationValidation(); @@ -552,6 +558,7 @@ export function useSnapshotValidation(): StepValidation { export function useTimezoneValidation(): StepValidation { const timezone = useAppSelector(selectTimezone); const ntpServers = useAppSelector(selectNtpServers); + const pendingInputFields = useAppSelector(selectPendingInputFields); const invalidServers = []; if (ntpServers) { @@ -581,7 +588,8 @@ export function useTimezoneValidation(): StepValidation { disabledNext: timezoneError !== '' || invalidServers.length > 0 || - duplicateNtpServers.length > 0, + duplicateNtpServers.length > 0 || + hasPendingInputForFields(pendingInputFields, ['ntpServers']), }; } @@ -661,6 +669,7 @@ export function useHostnameValidation(): StepValidation { export function useKernelValidation(): StepValidation { const kernel = useAppSelector(selectKernel); + const pendingInputFields = useAppSelector(selectPendingInputFields); const invalidArgs = []; if (kernel.append.length > 0) { @@ -685,12 +694,16 @@ export function useKernelValidation(): StepValidation { errors: { kernelAppend: kernelAppendError + '|' + duplicateKernelArgsError, }, - disabledNext: kernelAppendError !== '' || duplicateKernelArgs.length > 0, + disabledNext: + kernelAppendError !== '' || + duplicateKernelArgs.length > 0 || + hasPendingInputForFields(pendingInputFields, ['kernelAppend']), }; } export function useFirewallValidation(): StepValidation { const firewall = useAppSelector(selectFirewall); + const pendingInputFields = useAppSelector(selectPendingInputFields); const invalidPorts = []; const invalidDisabled = []; const invalidEnabled = []; @@ -768,12 +781,18 @@ export function useFirewallValidation(): StepValidation { invalidEnabled.length > 0 || duplicatePorts.length > 0 || duplicateDisabledServices.length > 0 || - duplicateEnabledServices.length > 0, + duplicateEnabledServices.length > 0 || + hasPendingInputForFields(pendingInputFields, [ + 'ports', + 'enabledServices', + 'disabledServices', + ]), }; } export function useServicesValidation(): StepValidation { const services = useAppSelector(selectServices); + const pendingInputFields = useAppSelector(selectPendingInputFields); const invalidDisabled = []; const invalidMasked = []; @@ -851,7 +870,12 @@ export function useServicesValidation(): StepValidation { invalidEnabled.length > 0 || duplicateDisabledServices.length > 0 || duplicateMaskedServices.length > 0 || - duplicateEnabledServices.length > 0, + duplicateEnabledServices.length > 0 || + hasPendingInputForFields(pendingInputFields, [ + 'enabledSystemdServices', + 'disabledSystemdServices', + 'maskedSystemdServices', + ]), }; } @@ -888,6 +912,7 @@ export function useUsersValidation(): UsersStepValidation { const environments = useAppSelector(selectImageTypes); const users = useAppSelector(selectUsers); const userGroups = useAppSelector(selectUserGroups); + const pendingInputFields = useAppSelector(selectPendingInputFields); const errors: { [key: string]: { [key: string]: string } } = {}; if ( @@ -1005,7 +1030,8 @@ export function useUsersValidation(): UsersStepValidation { return { errors, warnings: {}, - disabledNext: !canProceed, + disabledNext: + !canProceed || hasPendingInputForFields(pendingInputFields, ['groups']), }; } diff --git a/src/store/slices/wizard/validation/selectors.ts b/src/store/slices/wizard/validation/selectors.ts index ad0d3a01ab..baeaab140d 100644 --- a/src/store/slices/wizard/validation/selectors.ts +++ b/src/store/slices/wizard/validation/selectors.ts @@ -3,3 +3,7 @@ import { RootState } from '@/store'; export const selectForceShowErrors = (state: RootState) => { return state.wizard.validation.forceShowErrors; }; + +export const selectPendingInputFields = (state: RootState) => { + return state.wizard.validation.pendingInputFields; +}; diff --git a/src/store/slices/wizard/validation/slice.ts b/src/store/slices/wizard/validation/slice.ts index d16a333c24..637e7b0e40 100644 --- a/src/store/slices/wizard/validation/slice.ts +++ b/src/store/slices/wizard/validation/slice.ts @@ -1,4 +1,4 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialState } from './state'; @@ -14,6 +14,16 @@ export const validationSlice = createSlice({ resetForceShowErrors: (state) => { state.forceShowErrors = false; }, + setPendingInput: (state, action: PayloadAction) => { + if (!state.pendingInputFields.includes(action.payload)) { + state.pendingInputFields.push(action.payload); + } + }, + clearPendingInput: (state, action: PayloadAction) => { + state.pendingInputFields = state.pendingInputFields.filter( + (f) => f !== action.payload, + ); + }, }, extraReducers: (builder) => { builder @@ -27,5 +37,9 @@ export const validationSlice = createSlice({ }, }); -export const { setForceShowErrors, resetForceShowErrors } = - validationSlice.actions; +export const { + setForceShowErrors, + resetForceShowErrors, + setPendingInput, + clearPendingInput, +} = validationSlice.actions; diff --git a/src/store/slices/wizard/validation/state.ts b/src/store/slices/wizard/validation/state.ts index 728d3cb4b9..0bde46885f 100644 --- a/src/store/slices/wizard/validation/state.ts +++ b/src/store/slices/wizard/validation/state.ts @@ -2,4 +2,5 @@ import { ValidationSlice } from './types'; export const initialState: ValidationSlice = { forceShowErrors: false, + pendingInputFields: [], }; diff --git a/src/store/slices/wizard/validation/types.ts b/src/store/slices/wizard/validation/types.ts index 5682d2c655..04c4248caa 100644 --- a/src/store/slices/wizard/validation/types.ts +++ b/src/store/slices/wizard/validation/types.ts @@ -1,3 +1,4 @@ export type ValidationSlice = { forceShowErrors: boolean; + pendingInputFields: string[]; };