Wizard: validate uniqueness of Blueprint name

This commit is contained in:
Ondrej Ezr 2024-06-24 22:39:38 +02:00 committed by Klara Simickova
parent ff0b2509cc
commit facb71ceae
9 changed files with 80 additions and 28 deletions

View file

@ -23,7 +23,6 @@ interface HookValidatedTextInputPropTypes extends TextInputProps {
dataTestId?: string | undefined;
ouiaId?: string;
ariaLabel: string | undefined;
helperText: string | undefined;
value: string;
placeholder?: string;
stepValidation: StepValidation;
@ -34,7 +33,6 @@ export const HookValidatedInput = ({
dataTestId,
ouiaId,
ariaLabel,
helperText,
value,
placeholder,
onChange,
@ -43,7 +41,10 @@ export const HookValidatedInput = ({
}: HookValidatedTextInputPropTypes) => {
const [isPristine, setIsPristine] = useState(!value ? true : false);
// Do not surface validation on pristine state components
// Allow step validation to be set on pristine state, when needed
const validated = isPristine
? 'default'
: stepValidation.errors[fieldName] === 'default'
? 'default'
: stepValidation.errors[fieldName]
? 'error'
@ -69,7 +70,7 @@ export const HookValidatedInput = ({
{validated === 'error' && (
<HelperText>
<HelperTextItem variant="error" hasIcon>
{helperText}
{stepValidation.errors[fieldName]}
</HelperTextItem>
</HelperText>
)}

View file

@ -56,7 +56,6 @@ const DetailsStep = () => {
dataTestId="blueprint"
value={blueprintName}
onChange={handleNameChange}
helperText="Please enter a valid name"
placeholder="Add blueprint name"
stepValidation={stepValidation}
fieldName="name"
@ -79,7 +78,6 @@ const DetailsStep = () => {
dataTestId="blueprint description"
value={blueprintDescription || ''}
onChange={handleDescriptionChange}
helperText="Please enter a valid description"
placeholder="Add description"
stepValidation={stepValidation}
fieldName="description"

View file

@ -307,7 +307,6 @@ const MinimumSize = ({ partition }: MinimumSizePropTypes) => {
return (
<HookValidatedInput
ariaLabel="minimum partition size"
helperText="Must be larger than 0"
value={partition.min_size}
type="text"
ouiaId="size"

View file

@ -1,9 +1,16 @@
import { useEffect, useState } from 'react';
import { useAppSelector } from '../../../store/hooks';
import {
BlueprintsResponse,
useLazyGetBlueprintsQuery,
} from '../../../store/imageBuilderApi';
import {
selectBlueprintName,
selectBlueprintDescription,
selectFileSystemPartitionMode,
selectPartitions,
selectWizardMode,
} from '../../../store/wizardSlice';
import {
getDuplicateMountPoints,
@ -38,7 +45,7 @@ export function useFilesystemValidation(): StepValidation {
const duplicates = getDuplicateMountPoints(partitions);
for (const partition of partitions) {
if (!isMountpointMinSizeValid(partition.min_size)) {
errors[`min-size-${partition.id}`] = 'Invalid size';
errors[`min-size-${partition.id}`] = 'Must be larger than 0';
disabledNext = true;
}
if (duplicates.includes(partition.mountpoint)) {
@ -52,14 +59,47 @@ export function useFilesystemValidation(): StepValidation {
export function useDetailsValidation(): StepValidation {
const name = useAppSelector(selectBlueprintName);
const description = useAppSelector(selectBlueprintDescription);
const wizardMode = useAppSelector(selectWizardMode);
const nameValid = isBlueprintNameValid(name);
const descriptionValid = isBlueprintDescriptionValid(description);
const [uniqueName, setUniqueName] = useState<boolean | null>(null);
const [trigger] = useLazyGetBlueprintsQuery();
useEffect(() => {
if (wizardMode === 'create' && name !== '' && nameValid) {
trigger({ name })
.unwrap()
.then((response: BlueprintsResponse) => {
if (response?.meta?.count > 0) {
setUniqueName(false);
} else {
setUniqueName(true);
}
})
.catch(() => {
// If the request fails, we assume the name is unique
setUniqueName(true);
});
}
}, [wizardMode, name, setUniqueName, trigger]);
let nameError = '';
if (!nameValid) {
nameError = 'Invalid blueprint name';
} else if (uniqueName === false) {
nameError = 'Blueprint with this name already exists';
} else if (wizardMode === 'create' && uniqueName === null) {
// Hack to keep the error message from flickering
nameError = 'default';
}
return {
errors: {
name: nameValid ? '' : 'Invalid name',
name: nameError,
description: descriptionValid ? '' : 'Invalid description',
},
disabledNext: !nameValid || !descriptionValid,
disabledNext: !!nameError || !descriptionValid,
};
}

View file

@ -914,16 +914,17 @@ describe('Step Details', () => {
await setUp();
// Enter image name
const invalidName = 'a'.repeat(101);
await enterBlueprintName(invalidName);
expect(await getNextButton()).toHaveClass('pf-m-disabled');
expect(await getNextButton()).toBeDisabled();
const nameInput = await screen.findByRole('textbox', {
name: /blueprint name/i,
});
const invalidName = 'a'.repeat(101);
await user.type(nameInput, invalidName);
expect(await getNextButton()).toHaveClass('pf-m-disabled');
expect(await getNextButton()).toBeDisabled();
await user.clear(nameInput);
await user.type(nameInput, 'valid-name');
await enterBlueprintName();
expect(await getNextButton()).not.toHaveClass('pf-m-disabled');
expect(await getNextButton()).toBeEnabled();
@ -992,10 +993,7 @@ describe('Step Review', () => {
// skip firstboot
await clickNext();
// skip Details
const blueprintName = await screen.findByRole('textbox', {
name: /blueprint name/i,
});
await user.type(blueprintName, 'valid-name');
await enterBlueprintName();
await clickNext();
};
@ -1056,10 +1054,7 @@ describe('Step Review', () => {
await clickNext();
// skip First boot
await clickNext();
const blueprintName = await screen.findByRole('textbox', {
name: /blueprint name/i,
});
await user.type(blueprintName, 'valid-name');
await enterBlueprintName();
await clickNext();
};

View file

@ -82,6 +82,17 @@ describe('validates name', () => {
const nextButton = await getNextButton();
expect(nextButton).toBeEnabled();
});
test('with non-unique name', async () => {
await renderCreateMode();
await goToRegistrationStep();
await clickRegisterLater();
await goToDetailsStep();
await enterBlueprintName('Lemon Pie');
const nextButton = await getNextButton();
expect(nextButton).toBeDisabled();
});
});
describe('registration request generated correctly', () => {

View file

@ -93,7 +93,7 @@ const goToReviewStep = async () => {
await clickNext(); // Additional packages
await clickNext(); // FirstBoot
await clickNext(); // Details
await enterBlueprintName('oscap');
await enterBlueprintName('Oscap test');
await clickNext(); // Review
};
@ -130,7 +130,10 @@ describe('oscap', () => {
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = oscapCreateBlueprintRequest;
const expectedRequest: CreateBlueprintRequest = {
...oscapCreateBlueprintRequest,
name: 'Oscap test',
};
expect(receivedRequest).toEqual(expectedRequest);
});
@ -146,7 +149,7 @@ describe('oscap', () => {
const expectedRequest: CreateBlueprintRequest = {
...baseCreateBlueprintRequest,
name: 'oscap',
name: 'Oscap test',
};
expect(receivedRequest).toEqual(expectedRequest);
@ -170,7 +173,7 @@ describe('oscap', () => {
kernel: expectedKernelCisL2,
filesystem: expectedFilesystemCisL2,
},
name: 'oscap',
name: 'Oscap test',
};
await waitFor(() => {

View file

@ -157,7 +157,7 @@ export const mockGetBlueprints: GetBlueprintsApiResponse = {
},
{
id: '147032db-8697-4638-8fdd-6f428100d8fc',
name: 'Red Velvet',
name: 'Pink Velvet',
description: 'Layered cake with icing',
version: 1,
last_modified_at: '2021-09-08T21:00:00.000Z',

View file

@ -155,11 +155,16 @@ export const handlers = [
}
),
rest.get(`${IMAGE_BUILDER_API}/blueprints`, (req, res, ctx) => {
const nameParam = req.url.searchParams.get('name');
const search = req.url.searchParams.get('search');
const limit = req.url.searchParams.get('limit') || '10';
const offset = req.url.searchParams.get('offset') || '0';
const resp = Object.assign({}, mockGetBlueprints);
if (search) {
if (nameParam) {
resp.data = resp.data.filter(({ name }) => {
return nameParam === name;
});
} else if (search) {
let regexp;
try {
regexp = new RegExp(search);