Library: - Clear CTA hierarchy: "Build New Script" primary, "Import Script" ghost, "Manage" demoted to text link - "New from Script" → "Import Script" (clearer label) Script Builder: - Add suggestion chips for first-time users (4 common MSP tasks) - Chips auto-hide after first message Design system normalization: - ScriptPreviewModal: bg-black/80 → bg-black/40, text-blue-400 → text-accent-text, emerald save button → primary, inline rgba → CSS variables - ScriptCodeBlock: bg-[rgba(0,0,0,0.3)] → bg-code, text-blue-400 → text-accent-text, text-text-muted typo fixed, emerald button → ghost style - TemplateCard: emerald/amber/rose badges → success-dim/warning-dim/danger-dim, ShieldAlert amber → warning token - ParameterDetectorStepper: blue focus ring → orange, amber → warning token, "Candidate" → "Variable" in stepper progress Jargon clarification: - "Detect Parameters" → "Find Variables" - "Detected Parameters" → "Configurable Variables" - "Parameters (N)" → "Variables (N)" - Detection summary copy reworded for clarity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { X, Loader2, FileCode, AlertCircle } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { scriptsApi } from '@/api'
|
|
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
|
|
import { ParameterDetectorStepper } from '@/components/script-editor/ParameterDetectorStepper'
|
|
import type {
|
|
ScriptCategoryResponse,
|
|
ScriptParameter,
|
|
ScriptParametersSchema,
|
|
ParameterCandidate,
|
|
} from '@/types'
|
|
|
|
interface ParameterizeAndSavePanelProps {
|
|
/** Pre-populated script body (script mode). Undefined triggers paste mode. */
|
|
scriptBody?: string
|
|
/** Script language. Undefined shows language picker in paste mode. */
|
|
language?: string
|
|
/** Default name for the template. */
|
|
defaultName?: string
|
|
/** Default description for the template. */
|
|
defaultDescription?: string
|
|
/** Called with the final enriched payload when user saves. */
|
|
onSave: (payload: {
|
|
name: string
|
|
description: string | undefined
|
|
category_id: string | undefined
|
|
share_with_team: boolean
|
|
script_body: string
|
|
parameters_schema: ScriptParametersSchema
|
|
}) => Promise<void>
|
|
/** Called when the panel is closed without saving. */
|
|
onClose: () => void
|
|
}
|
|
|
|
const LANGUAGES = [
|
|
{ value: 'powershell', label: 'PowerShell' },
|
|
{ value: 'bash', label: 'Bash' },
|
|
{ value: 'python', label: 'Python' },
|
|
]
|
|
|
|
export function ParameterizeAndSavePanel({
|
|
scriptBody,
|
|
language: initialLanguage,
|
|
defaultName = '',
|
|
defaultDescription = '',
|
|
onSave,
|
|
onClose,
|
|
}: ParameterizeAndSavePanelProps) {
|
|
// Mode: script (body provided) vs paste (user pastes)
|
|
const isPasteMode = scriptBody === undefined
|
|
|
|
// Paste mode state
|
|
const [pastedScript, setPastedScript] = useState('')
|
|
const [selectedLanguage, setSelectedLanguage] = useState(initialLanguage || 'powershell')
|
|
const [scriptConfirmed, setScriptConfirmed] = useState(false)
|
|
|
|
// Working state — the script body being rewritten as params are accepted
|
|
const effectiveScript = isPasteMode ? pastedScript : scriptBody!
|
|
const [workingScript, setWorkingScript] = useState(effectiveScript)
|
|
const [parameters, setParameters] = useState<ScriptParameter[]>([])
|
|
|
|
// Detection state
|
|
const [candidates, setCandidates] = useState<ParameterCandidate[]>([])
|
|
const [detectionRan, setDetectionRan] = useState(false)
|
|
const [showStepper, setShowStepper] = useState(false)
|
|
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
|
|
|
|
// Metadata state
|
|
const [name, setName] = useState(defaultName)
|
|
const [description, setDescription] = useState(defaultDescription)
|
|
const [categoryId, setCategoryId] = useState('')
|
|
const [shareWithTeam, setShareWithTeam] = useState(false)
|
|
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
|
|
|
// Save state
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Load categories on mount
|
|
useEffect(() => {
|
|
scriptsApi.getCategories().then(setCategories).catch(() => {})
|
|
}, [])
|
|
|
|
// Auto-run detection when script is ready (script mode: on mount, paste mode: after confirm)
|
|
const runDetection = useCallback((script: string) => {
|
|
const detected = detectParameterCandidates(script)
|
|
setCandidates(detected)
|
|
setDetectionRan(true)
|
|
if (detected.length > 0) {
|
|
setShowStepper(true)
|
|
} else {
|
|
setDetectionSummary('No configurable values found — the script will be saved as-is. Variable detection currently supports PowerShell only.')
|
|
}
|
|
}, [])
|
|
|
|
// Script mode: run detection on mount
|
|
useEffect(() => {
|
|
if (!isPasteMode && effectiveScript) {
|
|
setWorkingScript(effectiveScript)
|
|
runDetection(effectiveScript)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Paste mode: run detection after script is confirmed
|
|
useEffect(() => {
|
|
if (isPasteMode && scriptConfirmed && pastedScript) {
|
|
setWorkingScript(pastedScript)
|
|
runDetection(pastedScript)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scriptConfirmed])
|
|
|
|
// Escape key closes panel
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose])
|
|
|
|
const handleConfirmPaste = () => {
|
|
if (!pastedScript.trim()) return
|
|
setScriptConfirmed(true)
|
|
}
|
|
|
|
const handleAcceptCandidate = (
|
|
candidate: ParameterCandidate,
|
|
overrides: {
|
|
key: string
|
|
label: string
|
|
type: ScriptParameter['type']
|
|
sensitive: boolean
|
|
required: boolean
|
|
defaultValue: string | boolean | number | null
|
|
}
|
|
) => {
|
|
// Rewrite the script body — replace the default value with {{ key }} placeholder
|
|
let updatedScript = workingScript
|
|
if (candidate.source === 'param_block') {
|
|
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
|
|
if (defaultMatch) {
|
|
updatedScript = updatedScript.replace(
|
|
candidate.matchedLine,
|
|
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
|
|
)
|
|
}
|
|
} else {
|
|
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
|
|
if (assignMatch) {
|
|
updatedScript = updatedScript.replace(
|
|
candidate.matchedLine,
|
|
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
|
|
)
|
|
}
|
|
}
|
|
setWorkingScript(updatedScript)
|
|
|
|
// Add parameter to accumulated schema
|
|
const newParam: ScriptParameter = {
|
|
key: overrides.key,
|
|
label: overrides.label,
|
|
type: overrides.type,
|
|
required: overrides.required,
|
|
placeholder: null,
|
|
group: null,
|
|
order: parameters.length + 1,
|
|
help_text: null,
|
|
options: null,
|
|
default: overrides.defaultValue,
|
|
validation: null,
|
|
sensitive: overrides.sensitive,
|
|
}
|
|
setParameters(prev => [...prev, newParam])
|
|
}
|
|
|
|
const handleSkipCandidate = () => {
|
|
// Stepper advances internally — nothing to do here
|
|
}
|
|
|
|
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
|
|
setShowStepper(false)
|
|
setCandidates([])
|
|
setDetectionSummary(
|
|
acceptedCount === 0
|
|
? 'No parameters were added. Script will be saved as-is.'
|
|
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
|
|
)
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!name.trim()) return
|
|
|
|
setIsSaving(true)
|
|
setError(null)
|
|
|
|
try {
|
|
await onSave({
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
category_id: categoryId || undefined,
|
|
share_with_team: shareWithTeam,
|
|
script_body: workingScript,
|
|
parameters_schema: { parameters },
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
// Determine if save button should be enabled
|
|
const canSave = name.trim().length > 0
|
|
&& !isSaving
|
|
&& !showStepper
|
|
&& (isPasteMode ? scriptConfirmed : true)
|
|
|
|
return (
|
|
<>
|
|
{/* Scrim */}
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/50"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
ref={panelRef}
|
|
className="fixed top-0 right-0 z-50 h-full w-[480px] max-w-full bg-page border-l border-default flex flex-col shadow-2xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-default shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<FileCode size={18} className="text-primary" />
|
|
<h3 className="text-sm font-heading font-bold text-foreground">
|
|
{isPasteMode ? 'Import Script to Library' : 'Save to Library'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
|
|
|
{/* Paste mode: script input area (before confirmation) */}
|
|
{isPasteMode && !scriptConfirmed && (
|
|
<section className="space-y-3">
|
|
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
|
Paste Your Script
|
|
</p>
|
|
<div className="flex gap-2">
|
|
{LANGUAGES.map((lang) => (
|
|
<button
|
|
key={lang.value}
|
|
type="button"
|
|
onClick={() => setSelectedLanguage(lang.value)}
|
|
className={cn(
|
|
'px-3 py-1.5 rounded-md text-xs font-medium transition-all',
|
|
selectedLanguage === lang.value
|
|
? 'bg-primary text-white'
|
|
: 'text-muted-foreground hover:text-foreground bg-elevated'
|
|
)}
|
|
>
|
|
{lang.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<textarea
|
|
value={pastedScript}
|
|
onChange={(e) => setPastedScript(e.target.value)}
|
|
rows={12}
|
|
className={cn(
|
|
'w-full rounded-lg px-3 py-2 text-sm font-mono resize-none',
|
|
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
|
'focus:outline-none focus:border-primary/30 transition-colors'
|
|
)}
|
|
placeholder="Paste your PowerShell, Bash, or Python script here..."
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleConfirmPaste}
|
|
disabled={!pastedScript.trim()}
|
|
className={cn(
|
|
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all',
|
|
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
Find Variables
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{/* Script preview (visible after paste confirm, or always in script mode) */}
|
|
{(!isPasteMode || scriptConfirmed) && (
|
|
<section className="space-y-2">
|
|
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
|
Script Preview
|
|
</p>
|
|
<div className="rounded-lg bg-black/30 border border-default overflow-hidden max-h-48 overflow-y-auto">
|
|
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all">
|
|
{workingScript.split(/({{.*?}})/).map((part, i) =>
|
|
/^{{.*}}$/.test(part)
|
|
? <span key={i} className="text-warning font-semibold">{part}</span>
|
|
: <span key={i}>{part}</span>
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Parameter detection zone */}
|
|
{detectionRan && !showStepper && detectionSummary && (
|
|
<div className="flex items-start gap-2 rounded-lg bg-elevated p-3">
|
|
<AlertCircle size={14} className="text-muted-foreground mt-0.5 shrink-0" />
|
|
<p className="text-xs text-muted-foreground">{detectionSummary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{showStepper && candidates.length > 0 && (
|
|
<section className="space-y-2">
|
|
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
|
Configurable Variables
|
|
</p>
|
|
<ParameterDetectorStepper
|
|
candidates={candidates}
|
|
existingKeys={parameters.map(p => p.key)}
|
|
onAccept={handleAcceptCandidate}
|
|
onSkip={handleSkipCandidate}
|
|
onFinish={handleDetectionFinish}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Accepted parameters summary */}
|
|
{parameters.length > 0 && !showStepper && (
|
|
<section className="space-y-2">
|
|
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
|
Variables ({parameters.length})
|
|
</p>
|
|
<div className="space-y-1">
|
|
{parameters.map((p) => (
|
|
<div
|
|
key={p.key}
|
|
className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<code className="text-xs font-mono text-warning">{`{{${p.key}}}`}</code>
|
|
<span className="text-xs text-muted-foreground">{p.label}</span>
|
|
</div>
|
|
<span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide">
|
|
{p.type}{p.sensitive ? ' · sensitive' : ''}{p.required ? '' : ' · optional'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Metadata form — shown after detection is done (or immediately if no candidates) */}
|
|
{detectionRan && !showStepper && (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
|
Template Details
|
|
</p>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
|
Name <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
className={cn(
|
|
'w-full rounded-lg px-3 py-2 text-sm',
|
|
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
|
'focus:outline-none focus:border-primary/30 transition-colors'
|
|
)}
|
|
placeholder="Script name"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className={cn(
|
|
'w-full rounded-lg px-3 py-2 text-sm resize-none',
|
|
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
|
'focus:outline-none focus:border-primary/30 transition-colors'
|
|
)}
|
|
placeholder="What does this script do?"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div>
|
|
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
|
Category
|
|
</label>
|
|
<select
|
|
value={categoryId}
|
|
onChange={(e) => setCategoryId(e.target.value)}
|
|
className={cn(
|
|
'w-full rounded-lg px-3 py-2 text-sm',
|
|
'border border-default bg-card text-foreground',
|
|
'focus:outline-none focus:border-primary/30 transition-colors'
|
|
)}
|
|
>
|
|
<option value="">No category</option>
|
|
{categories.map((cat) => (
|
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Share with team */}
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={shareWithTeam}
|
|
onChange={(e) => setShareWithTeam(e.target.checked)}
|
|
className="w-4 h-4 rounded border-border bg-card text-blue-500 focus:ring-blue-500/20"
|
|
/>
|
|
<span className="text-sm text-foreground">Share with team</span>
|
|
</label>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<p className="text-xs text-rose-400">{error}</p>
|
|
)}
|
|
|
|
{/* Save button */}
|
|
<button
|
|
type="submit"
|
|
disabled={!canSave}
|
|
className={cn(
|
|
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
|
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
|
{isSaving ? 'Saving...' : 'Save to Library'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|