From 8b2ffbbf27d4de79ba0347b135ed01504fb51089 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 01:56:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20ScriptTemplateEditor=20=E2=80=94?= =?UTF-8?q?=20full=20CRUD=20form=20with=20metadata,=20script=20body,=20and?= =?UTF-8?q?=20parameters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../script-editor/ScriptTemplateEditor.tsx | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 frontend/src/components/script-editor/ScriptTemplateEditor.tsx diff --git a/frontend/src/components/script-editor/ScriptTemplateEditor.tsx b/frontend/src/components/script-editor/ScriptTemplateEditor.tsx new file mode 100644 index 00000000..f3508697 --- /dev/null +++ b/frontend/src/components/script-editor/ScriptTemplateEditor.tsx @@ -0,0 +1,423 @@ +import { useState, useEffect } from 'react' +import { ArrowLeft, Loader2, Save, Trash2 } from 'lucide-react' +import { Input } from '@/components/ui/Input' +import { Textarea } from '@/components/ui/Textarea' +import { usePermissions } from '@/hooks/usePermissions' +import { scriptsApi } from '@/api' +import { ScriptBodyEditor } from './ScriptBodyEditor' +import { ParameterSchemaBuilder } from './ParameterSchemaBuilder' +import type { + ScriptTemplateDetail, + ScriptCategoryResponse, + ScriptParametersSchema, + ScriptTemplateCreateRequest, + ScriptTemplateUpdateRequest, +} from '@/types' + +interface Props { + templateId: string | null // null = create mode + onBack: () => void + onSaved: () => void +} + +interface FormState { + name: string + description: string + use_case: string + category_id: string + complexity: 'beginner' | 'intermediate' | 'advanced' + tags: string + estimated_runtime: string + requires_elevation: boolean + requires_modules: string + script_body: string + parameters_schema: ScriptParametersSchema +} + +const EMPTY_FORM: FormState = { + name: '', + description: '', + use_case: '', + category_id: '', + complexity: 'beginner', + tags: '', + estimated_runtime: '', + requires_elevation: false, + requires_modules: '', + script_body: '', + parameters_schema: { parameters: [] }, +} + +export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) { + const [form, setForm] = useState(EMPTY_FORM) + const [categories, setCategories] = useState([]) + const [isLoading, setIsLoading] = useState(!!templateId) + const [isSaving, setIsSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [isDirty, setIsDirty] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState(false) + const [template, setTemplate] = useState(null) + + const { canShareScriptTemplate } = usePermissions() + + // Load categories + template detail (if editing) + useEffect(() => { + const load = async () => { + try { + const cats = await scriptsApi.getCategories() + setCategories(cats) + + if (templateId) { + const detail = await scriptsApi.getTemplateDetail(templateId) + setTemplate(detail) + const schema = detail.parameters_schema as ScriptParametersSchema + setForm({ + name: detail.name, + description: detail.description ?? '', + use_case: detail.use_case ?? '', + category_id: detail.category_id, + complexity: detail.complexity, + tags: detail.tags.join(', '), + estimated_runtime: detail.estimated_runtime ?? '', + requires_elevation: detail.requires_elevation, + requires_modules: detail.requires_modules.join(', '), + script_body: detail.script_body, + parameters_schema: schema ?? { parameters: [] }, + }) + } else if (cats.length > 0) { + setForm(f => ({ ...f, category_id: cats[0].id })) + } + } catch { + setSaveError('Failed to load data') + } finally { + setIsLoading(false) + } + } + load() + }, [templateId]) + + const updateField = (key: K, value: FormState[K]) => { + setForm(f => ({ ...f, [key]: value })) + setIsDirty(true) + } + + const handleSave = async () => { + if (!form.name.trim()) { + setSaveError('Name is required') + return + } + if (!form.script_body.trim()) { + setSaveError('Script body is required') + return + } + if (!form.category_id) { + setSaveError('Category is required') + return + } + + setIsSaving(true) + setSaveError(null) + + const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean) + const requires_modules = form.requires_modules.split(',').map(m => m.trim()).filter(Boolean) + + try { + if (templateId) { + const data: ScriptTemplateUpdateRequest = { + name: form.name, + description: form.description || null, + use_case: form.use_case || null, + script_body: form.script_body, + parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema, + tags, + complexity: form.complexity, + estimated_runtime: form.estimated_runtime || null, + requires_elevation: form.requires_elevation, + requires_modules, + } + await scriptsApi.updateTemplate(templateId, data) + } else { + const data: ScriptTemplateCreateRequest = { + category_id: form.category_id, + name: form.name, + description: form.description || null, + use_case: form.use_case || null, + script_body: form.script_body, + parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema, + tags, + complexity: form.complexity, + estimated_runtime: form.estimated_runtime || null, + requires_elevation: form.requires_elevation, + requires_modules, + } + await scriptsApi.createTemplate(data) + } + setIsDirty(false) + onSaved() + } catch (err: unknown) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setSaveError(axiosErr.response?.data?.detail ?? 'Failed to save template') + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + if (!templateId) return + try { + await scriptsApi.deleteTemplate(templateId) + onSaved() + } catch { + setSaveError('Failed to delete template') + } + } + + const handleShare = async (shared: boolean) => { + if (!templateId) return + try { + const updated = await scriptsApi.shareTemplate(templateId, shared) + setTemplate(updated) + } catch { + setSaveError('Failed to update sharing') + } + } + + const handleBack = () => { + if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) return + onBack() + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Back link */} + + +

+ {templateId ? 'Edit Template' : 'New Template'} +

+ + {/* ── Metadata ──────────────────────────────────────────────── */} +
+

Metadata

+ +
+ + updateField('name', e.target.value)} + placeholder="e.g. Create AD User" + /> +
+ +
+
+ +