feat(03-03): implement BackendSelectionStep (WIZD-01, WIZD-04)
- Remote name field with zod validation (mode: onSubmit, reValidateMode: onChange)
- Three backend cards in popularity order: Azure Blob, Amazon S3, S3-Compatible
- Card click validates name first; dispatches SET_REMOTE_NAME + SET_BACKEND_TYPE + SET_REMOTE_PARAMS({}) + SET_STEP(1)
- Does NOT dispatch RESET — preserves deployment options
- All 10 BackendSelectionStep tests GREEN
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
// Covers WIZD-01: card grid renders Azure Blob, Amazon S3, S3-Compatible (Azure first)
|
// Covers WIZD-01: card grid renders Azure Blob, Amazon S3, S3-Compatible (Azure first)
|
||||||
// Covers WIZD-04: remote name field validates alphanumeric/dash/underscore
|
// Covers WIZD-04: remote name field validates alphanumeric/dash/underscore
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { WizardProvider } from '../../store/context';
|
import { WizardProvider } from '../../store/context';
|
||||||
import { BackendSelectionStep } from './BackendSelectionStep';
|
import { BackendSelectionStep } from './BackendSelectionStep';
|
||||||
|
|
||||||
@@ -76,35 +76,41 @@ describe('BackendSelectionStep', () => {
|
|||||||
expect(screen.queryByRole('alert')).toBeNull();
|
expect(screen.queryByRole('alert')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows inline error after first Next attempt with invalid name', () => {
|
it('shows inline error after first Next attempt with invalid name', async () => {
|
||||||
renderStep();
|
renderStep();
|
||||||
// Click a card without filling in name — triggers validation
|
// Click a card without filling in name — triggers validation
|
||||||
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
||||||
fireEvent.click(azureButton);
|
fireEvent.click(azureButton);
|
||||||
// Error should appear
|
// Error should appear after async validation
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeDefined();
|
expect(screen.getByRole('alert')).toBeDefined();
|
||||||
expect(screen.getByText(/required/i)).toBeDefined();
|
expect(screen.getByText(/required/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts alphanumeric, dashes, and underscores', () => {
|
it('accepts alphanumeric, dashes, and underscores', async () => {
|
||||||
renderStep();
|
renderStep();
|
||||||
const nameInput = screen.getByRole('textbox');
|
const nameInput = screen.getByRole('textbox');
|
||||||
fireEvent.change(nameInput, { target: { value: 'my-remote_01' } });
|
fireEvent.change(nameInput, { target: { value: 'my-remote_01' } });
|
||||||
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
||||||
fireEvent.click(azureButton);
|
fireEvent.click(azureButton);
|
||||||
// Valid name — no alert should appear
|
// Valid name — no alert should appear (wait briefly to confirm no error appears)
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('alert')).toBeNull();
|
expect(screen.queryByRole('alert')).toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects names with spaces or special characters', () => {
|
it('rejects names with spaces or special characters', async () => {
|
||||||
renderStep();
|
renderStep();
|
||||||
const nameInput = screen.getByRole('textbox');
|
const nameInput = screen.getByRole('textbox');
|
||||||
fireEvent.change(nameInput, { target: { value: 'my remote!' } });
|
fireEvent.change(nameInput, { target: { value: 'my remote!' } });
|
||||||
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
|
||||||
fireEvent.click(azureButton);
|
fireEvent.click(azureButton);
|
||||||
// Invalid name — alert should appear
|
// Invalid name — alert should appear after async validation
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toBeDefined();
|
expect(screen.getByRole('alert')).toBeDefined();
|
||||||
expect(screen.getByText(/letters, numbers, dashes/i)).toBeDefined();
|
expect(screen.getByText(/letters, numbers, dashes/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useWizard } from '../../store/context';
|
||||||
|
import type { BackendType } from '../../store/types';
|
||||||
|
import { BackendCard } from '../ui/BackendCard';
|
||||||
|
|
||||||
|
const remoteNameSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Remote name is required')
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9_-]+$/,
|
||||||
|
'Only letters, numbers, dashes, and underscores allowed'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RemoteNameFormValues = z.infer<typeof remoteNameSchema>;
|
||||||
|
|
||||||
|
const BACKENDS: { type: BackendType; name: string; description: string }[] = [
|
||||||
|
{
|
||||||
|
type: 'azureblob',
|
||||||
|
name: 'Azure Blob Storage',
|
||||||
|
description: 'Microsoft Azure cloud storage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 's3',
|
||||||
|
name: 'Amazon S3',
|
||||||
|
description: 'AWS Simple Storage Service',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 's3-compatible',
|
||||||
|
name: 'S3-Compatible',
|
||||||
|
description: 'Wasabi, MinIO, Cloudflare R2, and others',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BackendSelectionStep() {
|
||||||
|
const { state, dispatch } = useWizard();
|
||||||
|
const pendingBackend = useRef<BackendType | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RemoteNameFormValues>({
|
||||||
|
resolver: zodResolver(remoteNameSchema),
|
||||||
|
mode: 'onSubmit',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
defaultValues: { name: state.remote.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
function onValidSubmit(values: RemoteNameFormValues) {
|
||||||
|
const backend = pendingBackend.current;
|
||||||
|
if (!backend) return;
|
||||||
|
dispatch({ type: 'SET_REMOTE_NAME', payload: values.name });
|
||||||
|
dispatch({ type: 'SET_BACKEND_TYPE', payload: backend });
|
||||||
|
dispatch({ type: 'SET_REMOTE_PARAMS', payload: {} });
|
||||||
|
dispatch({ type: 'SET_STEP', payload: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(backendType: BackendType) {
|
||||||
|
pendingBackend.current = backendType;
|
||||||
|
void handleSubmit(onValidSubmit)();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Step 1: Select Backend</h2>
|
||||||
|
<form onSubmit={handleSubmit(onValidSubmit)}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="remote-name">Remote name</label>
|
||||||
|
<input id="remote-name" type="text" {...register('name')} />
|
||||||
|
{errors.name && (
|
||||||
|
<p role="alert">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div data-testid="backend-cards">
|
||||||
|
{BACKENDS.map((backend) => (
|
||||||
|
<BackendCard
|
||||||
|
key={backend.type}
|
||||||
|
name={backend.name}
|
||||||
|
description={backend.description}
|
||||||
|
selected={state.remote.backendType === backend.type}
|
||||||
|
onClick={() => handleCardClick(backend.type)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user