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:
2026-03-27 09:33:45 +01:00
parent cf06d72945
commit b47f607224
2 changed files with 111 additions and 12 deletions
@@ -2,7 +2,7 @@
// Covers WIZD-01: card grid renders Azure Blob, Amazon S3, S3-Compatible (Azure first)
// Covers WIZD-04: remote name field validates alphanumeric/dash/underscore
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 { BackendSelectionStep } from './BackendSelectionStep';
@@ -76,35 +76,41 @@ describe('BackendSelectionStep', () => {
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();
// Click a card without filling in name — triggers validation
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
fireEvent.click(azureButton);
// Error should appear
expect(screen.getByRole('alert')).toBeDefined();
expect(screen.getByText(/required/i)).toBeDefined();
// Error should appear after async validation
await waitFor(() => {
expect(screen.getByRole('alert')).toBeDefined();
expect(screen.getByText(/required/i)).toBeDefined();
});
});
it('accepts alphanumeric, dashes, and underscores', () => {
it('accepts alphanumeric, dashes, and underscores', async () => {
renderStep();
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'my-remote_01' } });
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
fireEvent.click(azureButton);
// Valid name — no alert should appear
expect(screen.queryByRole('alert')).toBeNull();
// Valid name — no alert should appear (wait briefly to confirm no error appears)
await waitFor(() => {
expect(screen.queryByRole('alert')).toBeNull();
});
});
it('rejects names with spaces or special characters', () => {
it('rejects names with spaces or special characters', async () => {
renderStep();
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'my remote!' } });
const azureButton = screen.getAllByRole('button').find(b => b.textContent?.includes('Azure Blob Storage'))!;
fireEvent.click(azureButton);
// Invalid name — alert should appear
expect(screen.getByRole('alert')).toBeDefined();
expect(screen.getByText(/letters, numbers, dashes/i)).toBeDefined();
// Invalid name — alert should appear after async validation
await waitFor(() => {
expect(screen.getByRole('alert')).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>
);
}