Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/Components/CreateImageWizard/LabelInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';

import {
Button,
Expand All @@ -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;
Expand Down Expand Up @@ -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('|');
Expand All @@ -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) => {
Expand Down Expand Up @@ -135,6 +156,7 @@ const LabelInput = ({
}

dispatch(addAction(trimmed));
dispatch(clearPendingInput(fieldName));
setInputValue('');
setOnStepInputErrorText('');
};
Expand All @@ -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 (
Expand Down Expand Up @@ -218,6 +244,11 @@ const LabelInput = ({
{warning && (
<HelperTextItem variant={'warning'}>{warning}</HelperTextItem>
)}
{unaddedInputWarning && errors.length === 0 && (
<HelperTextItem variant={'warning'}>
{unaddedInputWarning}
</HelperTextItem>
)}
</HelperText>
</FlexItem>
<FlexItem alignSelf={{ default: 'alignSelfFlexStart' }}>
Expand Down
46 changes: 43 additions & 3 deletions src/Components/CreateImageWizard/tests/LabelInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -132,7 +138,10 @@ describe('LabelInput', () => {
expect(
screen.getByText('Expected format: <group-name>. 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 () => {
Expand All @@ -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();
});
});
36 changes: 31 additions & 5 deletions src/Components/CreateImageWizard/utilities/useValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
selectLanguages,
selectNtpServers,
selectOrgId,
selectPendingInputFields,
selectRegistrationType,
selectSatelliteCaCertificate,
selectSatelliteRegistrationCommand,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -581,7 +588,8 @@ export function useTimezoneValidation(): StepValidation {
disabledNext:
timezoneError !== '' ||
invalidServers.length > 0 ||
duplicateNtpServers.length > 0,
duplicateNtpServers.length > 0 ||
hasPendingInputForFields(pendingInputFields, ['ntpServers']),
};
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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',
]),
};
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -1005,7 +1030,8 @@ export function useUsersValidation(): UsersStepValidation {
return {
errors,
warnings: {},
disabledNext: !canProceed,
disabledNext:
!canProceed || hasPendingInputForFields(pendingInputFields, ['groups']),
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/store/slices/wizard/validation/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
20 changes: 17 additions & 3 deletions src/store/slices/wizard/validation/slice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSlice } from '@reduxjs/toolkit';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

import { initialState } from './state';

Expand All @@ -14,6 +14,16 @@ export const validationSlice = createSlice({
resetForceShowErrors: (state) => {
state.forceShowErrors = false;
},
setPendingInput: (state, action: PayloadAction<string>) => {
if (!state.pendingInputFields.includes(action.payload)) {
state.pendingInputFields.push(action.payload);
}
},
clearPendingInput: (state, action: PayloadAction<string>) => {
state.pendingInputFields = state.pendingInputFields.filter(
(f) => f !== action.payload,
);
},
},
extraReducers: (builder) => {
builder
Expand All @@ -27,5 +37,9 @@ export const validationSlice = createSlice({
},
});

export const { setForceShowErrors, resetForceShowErrors } =
validationSlice.actions;
export const {
setForceShowErrors,
resetForceShowErrors,
setPendingInput,
clearPendingInput,
} = validationSlice.actions;
1 change: 1 addition & 0 deletions src/store/slices/wizard/validation/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { ValidationSlice } from './types';

export const initialState: ValidationSlice = {
forceShowErrors: false,
pendingInputFields: [],
};
1 change: 1 addition & 0 deletions src/store/slices/wizard/validation/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type ValidationSlice = {
forceShowErrors: boolean;
pendingInputFields: string[];
};
Loading