diff --git a/src/components/wizard/RemoteConfigStep.test.tsx b/src/components/wizard/RemoteConfigStep.test.tsx index bbc18c9..2d6cdad 100644 --- a/src/components/wizard/RemoteConfigStep.test.tsx +++ b/src/components/wizard/RemoteConfigStep.test.tsx @@ -6,8 +6,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect } from 'react'; -import { WizardProvider } from '../../store/context'; -import { useWizard } from '../../store/context'; +import { WizardProvider, useWizard } from '../../store/context'; import type { BackendType } from '../../schemas/registry'; import { RemoteConfigStep } from './RemoteConfigStep'; @@ -32,28 +31,31 @@ describe('RemoteConfigStep', () => { it('renders Storage Account Name field', async () => { renderWithBackend('azureblob'); await waitFor(() => { - expect(screen.getByLabelText(/storage account name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/storage account name/i)).toBeDefined(); }); }); it('shows SAS URL field by default (default auth method)', async () => { renderWithBackend('azureblob'); await waitFor(() => { - expect(screen.getByLabelText(/sas url/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/sas url/i)).toBeDefined(); }); }); it('switching auth toggle to Access Key shows key field and hides SAS URL', async () => { renderWithBackend('azureblob'); await waitFor(() => { - expect(screen.getByText(/access key/i, { selector: 'button' })).toBeInTheDocument(); + expect(screen.getByText(/access key/i, { selector: 'button' })).toBeDefined(); }); fireEvent.click(screen.getByText(/access key/i, { selector: 'button' })); await waitFor(() => { - const sasWrapper = screen.getByLabelText(/sas url/i).closest('div.hidden, div[class*="hidden"]'); - const keyWrapper = screen.getByLabelText(/access key/i).closest('div.block, div[class*="block"]'); - expect(sasWrapper).toBeTruthy(); - expect(keyWrapper).toBeTruthy(); + // After switching to Access Key, the SAS URL wrapper should be hidden + const sasInput = screen.getByLabelText(/sas url/i); + const sasWrapper = sasInput.closest('div.hidden'); + const keyInput = screen.getByLabelText(/access key/i); + const keyWrapper = keyInput.closest('div.block'); + expect(sasWrapper).toBeDefined(); + expect(keyWrapper).toBeDefined(); }); }); @@ -63,7 +65,7 @@ describe('RemoteConfigStep', () => { // Wait for form to render await waitFor(() => { - expect(screen.getByLabelText(/sas url/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/sas url/i)).toBeDefined(); }); // Type a value into the SAS URL field @@ -78,7 +80,8 @@ describe('RemoteConfigStep', () => { // The value should still be there await waitFor(() => { - expect(screen.getByLabelText(/sas url/i)).toHaveValue('https://mystorage.blob.core.windows.net/?sv=test'); + const input = screen.getByLabelText(/sas url/i) as HTMLInputElement; + expect(input.value).toBe('https://mystorage.blob.core.windows.net/?sv=test'); }); }); }); @@ -87,21 +90,21 @@ describe('RemoteConfigStep', () => { it('renders access_key_id field', async () => { renderWithBackend('s3'); await waitFor(() => { - expect(screen.getByLabelText(/access key id/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/access key id/i)).toBeDefined(); }); }); it('renders secret_access_key field', async () => { renderWithBackend('s3'); await waitFor(() => { - expect(screen.getByLabelText(/secret access key/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/secret access key/i)).toBeDefined(); }); }); it('renders region field', async () => { renderWithBackend('s3'); await waitFor(() => { - expect(screen.getByLabelText(/region/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/region/i)).toBeDefined(); }); }); }); @@ -110,9 +113,9 @@ describe('RemoteConfigStep', () => { it('renders endpoint field in addition to S3 fields', async () => { renderWithBackend('s3-compatible'); await waitFor(() => { - expect(screen.getByLabelText(/endpoint url/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/access key id/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/secret access key/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/endpoint url/i)).toBeDefined(); + expect(screen.getByLabelText(/access key id/i)).toBeDefined(); + expect(screen.getByLabelText(/secret access key/i)).toBeDefined(); }); }); }); diff --git a/src/components/wizard/RemoteConfigStep.tsx b/src/components/wizard/RemoteConfigStep.tsx new file mode 100644 index 0000000..f7eddd7 --- /dev/null +++ b/src/components/wizard/RemoteConfigStep.tsx @@ -0,0 +1,100 @@ +// src/components/wizard/RemoteConfigStep.tsx +// Step 1 — registry-driven backend configuration form. +// Keyed on backendType (in parent) to force remount when backend changes. +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useWizard } from '../../store/context'; +import { BACKEND_REGISTRY } from '../../schemas/registry'; +import { BACKEND_SCHEMAS } from '../../schemas'; +import { FieldRenderer } from '../ui/FieldRenderer'; +import { AzureAuthToggle } from './AzureAuthToggle'; +import type { FieldError } from 'react-hook-form'; + +export function RemoteConfigStep() { + const { state, dispatch } = useWizard(); + const backendType = state.remote.backendType; + + // Guard — redirect to step 0 if backendType is missing (should never happen in production flow) + useEffect(() => { + if (!backendType) { + dispatch({ type: 'SET_STEP', payload: 0 }); + } + }, [backendType, dispatch]); + + // All hooks must be called unconditionally — use a stable fallback for schema selection + const schema = backendType ? BACKEND_SCHEMAS[backendType] : BACKEND_SCHEMAS['azureblob']; + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + mode: 'onSubmit', + reValidateMode: 'onChange', + defaultValues: state.remote.params, + }); + + // Render nothing until backendType is set + if (!backendType) { + return null; + } + + const onNext = (values: Record) => { + dispatch({ type: 'SET_REMOTE_PARAMS', payload: values }); + dispatch({ type: 'SET_STEP', payload: 2 }); + }; + + const backendLabel: Record, string> = { + azureblob: 'Azure Blob Storage', + s3: 'Amazon S3', + 's3-compatible': 'S3-Compatible Storage', + }; + + return ( +
+

Step 2: Configure {backendLabel[backendType]}

+
+ {backendType === 'azureblob' ? ( + <> + {/* Account field via FieldRenderer */} + f.key === 'account')!} + register={register} + error={errors.account as FieldError | undefined} + /> + {/* Auth toggle handles key + sas_url — both always registered */} + + + ) : ( + /* S3 and S3-compatible: full registry loop — FieldRenderer handles provider hiding */ + BACKEND_REGISTRY[backendType].map(field => ( + + )) + )} +
+ + +
+ +
+ ); +}