- 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
427 lines
17 KiB
Markdown
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>
|