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-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
|
||||
// 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
|
||||
// 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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user