- 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
17 KiB
17 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-wizard-ui | 02 | execute | 2 |
|
|
true |
|
|
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/.
<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>
@.planning/PROJECT.md @.planning/phases/03-wizard-ui/03-CONTEXT.md @.planning/phases/03-wizard-ui/03-RESEARCH.mdFrom src/schemas/registry.ts:
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):
// UseFormRegister and FieldError types used in FieldRenderer and PasswordField
import type { UseFormRegister, FieldError } from 'react-hook-form';
**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>
);
}
```
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.
Check exports:
grep -n "^export" src/components/ui/BackendCard.tsx src/components/ui/PasswordField.tsx src/components/ui/FieldRenderer.tsx src/components/wizard/AzureAuthToggle.tsx
<success_criteria>
- BackendCard, PasswordField, FieldRenderer, AzureAuthToggle all export their named components
- FieldRenderer hides the
providerfield for single-option selects and auto-registers the default value - AzureAuthToggle registers BOTH
keyandsas_urlwith 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>