From e6edf34485a43180637a97986fa53088c7e1344f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 29 Mar 2026 05:52:17 +0000 Subject: [PATCH] feat: add ParameterizeAndSavePanel component Slide-in panel for saving scripts to library with parameter detection, stepper review, template rewriting, and metadata collection. Supports both script mode (from AI builder) and paste mode (raw script import). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/ParameterizeAndSavePanel.tsx | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 frontend/src/components/scripts/ParameterizeAndSavePanel.tsx diff --git a/frontend/src/components/scripts/ParameterizeAndSavePanel.tsx b/frontend/src/components/scripts/ParameterizeAndSavePanel.tsx new file mode 100644 index 00000000..95be3cfb --- /dev/null +++ b/frontend/src/components/scripts/ParameterizeAndSavePanel.tsx @@ -0,0 +1,472 @@ +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 + /** 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([]) + + // Detection state + const [candidates, setCandidates] = useState([]) + const [detectionRan, setDetectionRan] = useState(false) + const [showStepper, setShowStepper] = useState(false) + const [detectionSummary, setDetectionSummary] = useState(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([]) + + // Save state + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + const panelRef = useRef(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 parameters detected — script will be saved as-is. Parameter 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 */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+ +

+ {isPasteMode ? 'Import Script to Library' : 'Save to Library'} +

+
+ +
+ + {/* Scrollable content */} +
+ + {/* Paste mode: script input area (before confirmation) */} + {isPasteMode && !scriptConfirmed && ( +
+

+ Paste Your Script +

+
+ {LANGUAGES.map((lang) => ( + + ))} +
+