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
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user