From b47f6072242ccb2538dd506c641151d7f6ec651f Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 27 Mar 2026 09:33:45 +0100 Subject: [PATCH] feat(03-03): implement BackendSelectionStep (WIZD-01, WIZD-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../wizard/BackendSelectionStep.test.tsx | 30 +++--- .../wizard/BackendSelectionStep.tsx | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 src/components/wizard/BackendSelectionStep.tsx diff --git a/src/components/wizard/BackendSelectionStep.test.tsx b/src/components/wizard/BackendSelectionStep.test.tsx index e689f14..c352f33 100644 --- a/src/components/wizard/BackendSelectionStep.test.tsx +++ b/src/components/wizard/BackendSelectionStep.test.tsx @@ -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(); + }); }); }); }); diff --git a/src/components/wizard/BackendSelectionStep.tsx b/src/components/wizard/BackendSelectionStep.tsx new file mode 100644 index 0000000..1bceaed --- /dev/null +++ b/src/components/wizard/BackendSelectionStep.tsx @@ -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; + +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(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( +
+

Step 1: Select Backend

+
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ {BACKENDS.map((backend) => ( + handleCardClick(backend.type)} + /> + ))} +
+
+
+ ); +}