Files
kawa 53bbd00533 docs(05-03): complete act()-warning elimination plan
- SUMMARY.md: userEvent v14 migration + vi.useFakeTimers() for ReviewStep
- STATE.md: advanced to completed 05-03, added patterns as decisions
- ROADMAP.md: phase 5 now 4/4 plans complete (Complete status)
- REQUIREMENTS.md: TECH-05 marked complete
2026-03-30 11:44:59 +02:00

427 lines
17 KiB
Markdown

---
phase: 03-wizard-ui
plan: "02"
type: execute
wave: 2
depends_on:
- "03-01"
files_modified:
- src/components/ui/BackendCard.tsx
- src/components/ui/PasswordField.tsx
- src/components/ui/FieldRenderer.tsx
- src/components/wizard/AzureAuthToggle.tsx
autonomous: true
requirements:
- BACK-01
- BACK-02
- BACK-03
must_haves:
truths:
- "BackendCard renders a clickable card with backend name and calls onClick when clicked"
- "PasswordField renders a password input with a show/hide eye toggle button"
- "FieldRenderer renders the correct input type for text, password, select, and toggle FieldDef entries"
- "AzureAuthToggle renders a segmented SAS/Key toggle, shows the active field, and defaults to SAS"
artifacts:
- path: "src/components/ui/BackendCard.tsx"
provides: "Clickable backend selection card"
exports: ["BackendCard"]
- path: "src/components/ui/PasswordField.tsx"
provides: "Password input with show/hide toggle"
exports: ["PasswordField"]
- path: "src/components/ui/FieldRenderer.tsx"
provides: "Single FieldDef renderer for registry-driven forms"
exports: ["FieldRenderer"]
- path: "src/components/wizard/AzureAuthToggle.tsx"
provides: "Azure SAS vs Access Key segmented toggle"
exports: ["AzureAuthToggle"]
key_links:
- from: "src/components/wizard/AzureAuthToggle.tsx"
to: "src/schemas/registry.ts"
via: "renders key and sas_url FieldDef entries via PasswordField"
pattern: "PasswordField"
- from: "src/components/ui/FieldRenderer.tsx"
to: "src/schemas/registry.ts"
via: "accepts FieldDef and renders the correct input element"
pattern: "FieldDef"
---
<objective>
Create the four reusable UI atom components that the wizard step components depend on: BackendCard, PasswordField, FieldRenderer, and AzureAuthToggle.
Purpose: Establish shared building blocks so Plans 03 and 04 can implement their step components without duplicating input logic.
Output: Four new component files in src/components/ui/ and src/components/wizard/.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/03-wizard-ui/03-CONTEXT.md
@.planning/phases/03-wizard-ui/03-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From src/schemas/registry.ts:
```typescript
export type BackendType = 'azureblob' | 's3' | 's3-compatible';
export interface FieldDef {
key: string; // MUST match rclone config key exactly (snake_case)
label: string;
inputType: 'text' | 'password' | 'select' | 'toggle';
required: boolean;
placeholder?: string;
helpText?: string;
options?: { value: string; label: string }[]; // for inputType: 'select'
}
export const BACKEND_REGISTRY: Record<BackendType, FieldDef[]> = {
azureblob: [
{ key: 'account', label: 'Storage Account Name', inputType: 'text', required: true },
{ key: 'key', label: 'Access Key', inputType: 'password', required: false },
{ key: 'sas_url', label: 'SAS URL', inputType: 'password', required: false },
],
s3: [
{ key: 'provider', label: 'Provider', inputType: 'select', required: true },
{ key: 'access_key_id', label: 'Access Key ID', inputType: 'text', required: true },
{ key: 'secret_access_key', label: 'Secret Access Key',inputType: 'password', required: true },
{ key: 'region', label: 'Region', inputType: 'text', required: true },
],
's3-compatible': [
{ key: 'provider', label: 'Provider', inputType: 'select', required: true },
{ key: 'access_key_id', label: 'Access Key ID', inputType: 'text', required: true },
{ key: 'secret_access_key', label: 'Secret Access Key',inputType: 'password', required: true },
{ key: 'endpoint', label: 'Endpoint URL', inputType: 'text', required: true },
{ key: 'region', label: 'Region', inputType: 'text', required: false },
],
};
```
From react-hook-form (installed as react-hook-form ^7.72.0):
```typescript
// UseFormRegister and FieldError types used in FieldRenderer and PasswordField
import type { UseFormRegister, FieldError } from 'react-hook-form';
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create BackendCard and PasswordField UI atoms</name>
<files>src/components/ui/BackendCard.tsx, src/components/ui/PasswordField.tsx</files>
<action>
Create `src/components/ui/` directory and the two simplest atom components.
**src/components/ui/BackendCard.tsx**
A clickable card for the backend selection grid. Props: `name` (display string), `description` (short subtitle), `onClick` (void callback), `selected` (boolean for visual highlight).
```tsx
import type { ButtonHTMLAttributes } from 'react';
interface BackendCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
name: string;
description: string;
selected?: boolean;
onClick: () => void;
}
export function BackendCard({ name, description, selected = false, onClick, ...rest }: BackendCardProps) {
return (
<button
type="button"
onClick={onClick}
data-selected={selected}
className={[
'flex flex-col items-start gap-1 rounded-lg border-2 p-4 text-left transition-colors',
selected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 bg-white hover:border-blue-400 hover:bg-gray-50',
].join(' ')}
{...rest}
>
<span className="font-semibold text-gray-900">{name}</span>
<span className="text-sm text-gray-500">{description}</span>
</button>
);
}
```
**src/components/ui/PasswordField.tsx**
Password input with a show/hide eye toggle. Per locked decision: all password-type fields (access key, SAS URL, S3 secret) have a show/hide toggle. Per project instructions: each field has its own independent visibility state.
Props: `id` (string), `label` (string), `error` (FieldError | undefined), `registration` (return value of react-hook-form `register()`), `placeholder` (optional).
```tsx
import { useState } from 'react';
import type { FieldError, UseFormRegisterReturn } from 'react-hook-form';
interface PasswordFieldProps {
id: string;
label: string;
error?: FieldError;
registration: UseFormRegisterReturn;
placeholder?: string;
helpText?: string;
}
export function PasswordField({ id, label, error, registration, placeholder, helpText }: PasswordFieldProps) {
const [show, setShow] = useState(false);
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
</label>
<div className="relative">
<input
id={id}
type={show ? 'text' : 'password'}
placeholder={placeholder}
className={[
'w-full rounded-md border px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2',
error ? 'border-red-500 focus:ring-red-300' : 'border-gray-300 focus:ring-blue-300',
].join(' ')}
{...registration}
/>
<button
type="button"
onClick={() => setShow(v => !v)}
aria-label={show ? 'Hide' : 'Show'}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700"
>
{show ? '🙈' : '👁'}
</button>
</div>
{helpText && !error && <p className="text-xs text-gray-500">{helpText}</p>}
{error && <p className="text-xs text-red-600">{error.message}</p>}
</div>
);
}
```
</action>
<verify>
<automated>npx vitest run --reporter=verbose 2>&1 | tail -20</automated>
</verify>
<done>BackendCard.tsx and PasswordField.tsx exist with correct exports. Vitest still reports RED (test stubs still fail — components exist but tests have expect.fail).</done>
</task>
<task type="auto">
<name>Task 2: Create FieldRenderer and AzureAuthToggle</name>
<files>src/components/ui/FieldRenderer.tsx, src/components/wizard/AzureAuthToggle.tsx</files>
<action>
**src/components/ui/FieldRenderer.tsx**
Renders a single `FieldDef` from the BACKEND_REGISTRY as the appropriate input element. This is the registry-driven form engine — no hardcoded per-backend JSX is allowed anywhere else.
The `provider` select field (s3 and s3-compatible backends) has only one option — hide it from the user and auto-register it with its default value. This follows the recommendation from 03-RESEARCH.md open question #2.
Props: `field` (FieldDef), `register` (UseFormRegister<any>), `error` (FieldError | undefined), `defaultValue` (string | undefined — used to auto-register hidden fields).
```tsx
import type { UseFormRegister, FieldError } from 'react-hook-form';
import type { FieldDef } from '../../schemas/registry';
import { PasswordField } from './PasswordField';
interface FieldRendererProps {
field: FieldDef;
register: UseFormRegister<any>;
error?: FieldError;
}
export function FieldRenderer({ field, register, error }: FieldRendererProps) {
// provider field: single-option select — hide from UI, auto-register with default value
if (field.key === 'provider' && field.options?.length === 1) {
return (
<input
type="hidden"
value={field.options[0].value}
{...register(field.key)}
/>
);
}
if (field.inputType === 'password') {
return (
<PasswordField
id={field.key}
label={field.label}
error={error}
registration={register(field.key)}
placeholder={field.placeholder}
helpText={field.helpText}
/>
);
}
if (field.inputType === 'select' && field.options) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={field.key} className="text-sm font-medium text-gray-700">
{field.label}
{field.required && <span className="ml-1 text-red-500">*</span>}
</label>
<select
id={field.key}
className={[
'w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2',
error ? 'border-red-500 focus:ring-red-300' : 'border-gray-300 focus:ring-blue-300',
].join(' ')}
{...register(field.key)}
>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{field.helpText && !error && <p className="text-xs text-gray-500">{field.helpText}</p>}
{error && <p className="text-xs text-red-600">{error.message}</p>}
</div>
);
}
// text (default)
return (
<div className="flex flex-col gap-1">
<label htmlFor={field.key} className="text-sm font-medium text-gray-700">
{field.label}
{field.required && <span className="ml-1 text-red-500">*</span>}
</label>
<input
id={field.key}
type="text"
placeholder={field.placeholder}
className={[
'w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2',
error ? 'border-red-500 focus:ring-red-300' : 'border-gray-300 focus:ring-blue-300',
].join(' ')}
{...register(field.key)}
/>
{field.helpText && !error && <p className="text-xs text-gray-500">{field.helpText}</p>}
{error && <p className="text-xs text-red-600">{error.message}</p>}
</div>
);
}
```
**src/components/wizard/AzureAuthToggle.tsx**
Azure-specific segmented control that toggles between "SAS URL" and "Access Key". Per locked decision:
- Default method: SAS URL
- Switching PRESERVES both field values (both are always registered in react-hook-form)
- Only the active field is displayed
- Both values flow into `remote.params` on form submit (generator strips inactive one)
Props: `register` (UseFormRegister<any>), `errors` (object with key and sas_url FieldError entries).
```tsx
import { useState } from 'react';
import type { UseFormRegister, FieldError } from 'react-hook-form';
import { PasswordField } from '../ui/PasswordField';
type AuthMethod = 'sas' | 'key';
interface AzureAuthToggleProps {
register: UseFormRegister<any>;
errors: {
key?: FieldError;
sas_url?: FieldError;
};
}
export function AzureAuthToggle({ register, errors }: AzureAuthToggleProps) {
const [authMethod, setAuthMethod] = useState<AuthMethod>('sas');
return (
<div className="flex flex-col gap-3">
{/* Segmented control */}
<div className="flex rounded-md border border-gray-300 overflow-hidden">
<button
type="button"
onClick={() => setAuthMethod('sas')}
className={[
'flex-1 py-1.5 text-sm font-medium transition-colors',
authMethod === 'sas'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50',
].join(' ')}
>
SAS URL
</button>
<button
type="button"
onClick={() => setAuthMethod('key')}
className={[
'flex-1 py-1.5 text-sm font-medium transition-colors',
authMethod === 'key'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50',
].join(' ')}
>
Access Key
</button>
</div>
{/* Both fields always registered — only active one visible */}
<div className={authMethod === 'sas' ? 'block' : 'hidden'}>
<PasswordField
id="sas_url"
label="SAS URL"
error={errors.sas_url}
registration={register('sas_url')}
placeholder="https://mystorageaccount.blob.core.windows.net/?sv=..."
helpText="Full SAS URL including account and container"
/>
</div>
<div className={authMethod === 'key' ? 'block' : 'hidden'}>
<PasswordField
id="key"
label="Access Key"
error={errors.key}
registration={register('key')}
helpText="Base64-encoded storage account key"
/>
</div>
</div>
);
}
```
CRITICAL: Using CSS `hidden` class (not conditional rendering) ensures both fields are registered with react-hook-form and their values are preserved when toggling. Do not use `{authMethod === 'sas' && <PasswordField ... />}` — that unmounts the hidden field and react-hook-form loses its value.
</action>
<verify>
<automated>npx vitest run --reporter=verbose 2>&1 | tail -20</automated>
</verify>
<done>
FieldRenderer.tsx and AzureAuthToggle.tsx exist with correct exports. All four atoms exist. Vitest still RED (stubs still have expect.fail — implementation tasks come in Plans 03 and 04).
</done>
</task>
</tasks>
<verification>
Run after both tasks complete:
```bash
npx vitest run --reporter=verbose 2>&1
```
Vitest runs cleanly (no crashes). Tests remain RED from stubs. All four atom files exist with named exports.
Check exports:
```bash
grep -n "^export" src/components/ui/BackendCard.tsx src/components/ui/PasswordField.tsx src/components/ui/FieldRenderer.tsx src/components/wizard/AzureAuthToggle.tsx
```
</verification>
<success_criteria>
- BackendCard, PasswordField, FieldRenderer, AzureAuthToggle all export their named components
- FieldRenderer hides the `provider` field for single-option selects and auto-registers the default value
- AzureAuthToggle registers BOTH `key` and `sas_url` with react-hook-form regardless of active method (uses CSS show/hide, not conditional rendering)
- PasswordField has per-instance show/hide state (not a global toggle)
- No new Tailwind config file created (Tailwind v4 CSS-first — all styles use utility classes directly)
</success_criteria>
<output>
After completion, create `.planning/phases/03-wizard-ui/03-02-SUMMARY.md`
</output>