feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector
Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #105.
This commit is contained in:
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal file
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode, ArrowLeft } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { scriptsApi } from '@/api'
|
||||
import type { ScriptTemplateListItem, ScriptCategoryResponse } from '@/types'
|
||||
|
||||
const COMPLEXITY_CLASSES = {
|
||||
beginner: 'text-emerald-400 bg-emerald-400/10',
|
||||
intermediate: 'text-amber-400 bg-amber-400/10',
|
||||
advanced: 'text-rose-500 bg-rose-500/10',
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
onEdit: (id: string) => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export function ScriptTemplateListView({ onEdit, onCreate }: Props) {
|
||||
const [templates, setTemplates] = useState<ScriptTemplateListItem[]>([])
|
||||
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const { canManageScriptTemplate, canCreateScriptTemplate } = usePermissions()
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tpls, cats] = await Promise.all([
|
||||
scriptsApi.getManagedTemplates(searchQuery ? { search: searchQuery } : undefined),
|
||||
scriptsApi.getCategories(),
|
||||
])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadData()
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await scriptsApi.deleteTemplate(id)
|
||||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||
setDeleteConfirm(null)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId: string) =>
|
||||
categories.find(c => c.id === categoryId)?.name ?? 'Unknown'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/scripts"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to Script Library
|
||||
</Link>
|
||||
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">Manage Templates</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create and edit PowerShell script templates.
|
||||
</p>
|
||||
</div>
|
||||
{canCreateScriptTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-64">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search templates…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={28} className="text-primary animate-spin" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="glass-card-static flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<FileCode size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery ? 'No templates match your search' : 'No templates yet. Create your first one!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card-static overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Name</th>
|
||||
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Category</th>
|
||||
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Complexity</th>
|
||||
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Scope</th>
|
||||
<th className="text-right font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Uses</th>
|
||||
<th className="text-right font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map(t => (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-b border-border last:border-b-0 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-foreground font-medium">{t.name}</span>
|
||||
{t.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{t.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{getCategoryName(t.category_id)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[t.complexity])}>
|
||||
{t.complexity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
|
||||
t.team_id
|
||||
? 'text-primary bg-primary/10 border-primary/20'
|
||||
: 'text-muted-foreground bg-white/5 border-border'
|
||||
)}>
|
||||
{t.team_id ? <><Users size={10} /> Team</> : <><UserIcon size={10} /> Personal</>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-muted-foreground">{t.usage_count}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManageScriptTemplate(t) && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(t.id)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
|
||||
title="Edit template"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{deleteConfirm === t.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(t.id)}
|
||||
className="text-[0.625rem] font-label text-rose-500 hover:text-rose-400 px-1.5 py-0.5"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-[0.625rem] font-label text-muted-foreground hover:text-foreground px-1.5 py-0.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirm(t.id)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-rose-500 hover:bg-white/5 transition-colors"
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user