feat: add ParameterDetectorStepper component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-14 18:47:06 -04:00
parent c012ca30e6
commit 504eff918f

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}