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[];
};