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>
1603 lines
48 KiB
Markdown
1603 lines
48 KiB
Markdown
# Script Generator Phase 2 — Frontend Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the Script Library frontend — browse PowerShell templates by category, fill parameters, get a live preview, and generate + copy/download the final script.
|
||
|
||
**Architecture:** Zustand store owns all browsing/form/generation state; `ScriptLibraryPage` renders a top filter bar above a two-column layout (template list left, generator panel right); client-side `{{key}}` substitution drives the live preview; the real `POST /scripts/generate` is called only on the Generate button.
|
||
|
||
**Tech Stack:** React 19, TypeScript, Zustand, Axios (`apiClient`), Tailwind CSS v3, Lucide React, existing shared `<Input>` / `<Textarea>` components.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Action | Responsibility |
|
||
|------|--------|----------------|
|
||
| `frontend/src/types/scripts.ts` | Create | All script-domain TypeScript interfaces |
|
||
| `frontend/src/types/index.ts` | Modify | Re-export `* from './scripts'` |
|
||
| `frontend/src/api/scripts.ts` | Create | `scriptsApi` object — all HTTP calls for script domain |
|
||
| `frontend/src/api/index.ts` | Modify | Re-export `{ scriptsApi }` |
|
||
| `frontend/src/store/scriptGeneratorStore.ts` | Create | Zustand store — browsing state, form state, generation output |
|
||
| `frontend/src/pages/ScriptLibraryPage.tsx` | Create | Page shell — owns `inputValue` lift, bootstraps store, two-column layout |
|
||
| `frontend/src/components/scripts/ScriptFilterBar.tsx` | Create | Category tabs + debounced search input |
|
||
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Create | Scrollable template list with loading/empty states |
|
||
| `frontend/src/components/scripts/TemplateCard.tsx` | Create | Single template card — name, complexity badge, tags, active state |
|
||
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | Create | Right panel — permission check, header, form, preview, action bar |
|
||
| `frontend/src/components/scripts/ScriptParameterForm.tsx` | Create | Iterates parameters, groups by `group` field |
|
||
| `frontend/src/components/scripts/ScriptParameterField.tsx` | Create | Single parameter input — all 7 field types |
|
||
| `frontend/src/components/scripts/ScriptPreview.tsx` | Create | Draft/generated preview, copy button |
|
||
| `frontend/src/components/scripts/PowerShellHighlighter.tsx` | Create | Single-pass tokenizer → coloured `<span>` elements |
|
||
| `frontend/src/router.tsx` | Modify | Add `/scripts` route |
|
||
| `frontend/src/components/layout/Sidebar.tsx` | Modify | Add "Scripts" nav item |
|
||
| `frontend/src/components/layout/AppLayout.tsx` | Modify | Add "Scripts" to mobile nav items array |
|
||
|
||
---
|
||
|
||
## Chunk 1: Foundation — Types, API Client, Zustand Store
|
||
|
||
### Task 1: Types
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/types/scripts.ts`
|
||
- Modify: `frontend/src/types/index.ts`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/types/scripts.ts`**
|
||
|
||
```typescript
|
||
export interface ScriptCategoryResponse {
|
||
id: string
|
||
name: string
|
||
slug: string
|
||
description: string | null
|
||
icon: string | null
|
||
sort_order: number
|
||
template_count: number
|
||
}
|
||
|
||
export interface ScriptTemplateListItem {
|
||
id: string
|
||
category_id: string
|
||
team_id: string | null
|
||
name: string
|
||
slug: string
|
||
description: string | null
|
||
tags: string[]
|
||
complexity: 'beginner' | 'intermediate' | 'advanced' // must match backend ScriptComplexity enum exactly
|
||
estimated_runtime: string | null
|
||
requires_elevation: boolean
|
||
requires_modules: string[]
|
||
is_verified: boolean
|
||
usage_count: number
|
||
}
|
||
|
||
export interface ScriptParameterOption {
|
||
value: string
|
||
label: string
|
||
}
|
||
|
||
export interface ScriptParameterValidation {
|
||
min_value?: number // matches backend field name (not 'min')
|
||
max_value?: number // matches backend field name (not 'max')
|
||
pattern?: string
|
||
min_length?: number
|
||
max_length?: number
|
||
}
|
||
|
||
export interface ScriptParameter {
|
||
key: string
|
||
label: string
|
||
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea'
|
||
required: boolean
|
||
placeholder: string | null
|
||
group: string | null
|
||
order: number
|
||
help_text: string | null
|
||
options: ScriptParameterOption[] | null // for select type
|
||
default: string | boolean | number | null
|
||
validation: ScriptParameterValidation | null
|
||
sensitive: boolean
|
||
}
|
||
|
||
export interface ScriptParametersSchema {
|
||
parameters: ScriptParameter[]
|
||
}
|
||
|
||
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
|
||
use_case: string | null
|
||
script_body: string
|
||
// NOTE: backend types this as `dict` — arrives as unknown at runtime.
|
||
// Always access via cast: (detail.parameters_schema as ScriptParametersSchema).parameters ?? []
|
||
parameters_schema: ScriptParametersSchema
|
||
default_values: Record<string, unknown> // template-level metadata; not used in Phase 2
|
||
validation_rules: Record<string, unknown> // template-level metadata; not used in Phase 2
|
||
version: number
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
export interface ScriptGenerateRequest {
|
||
template_id: string
|
||
parameters: Record<string, unknown>
|
||
session_id?: string // Phase 3: passed when generating inside a session
|
||
}
|
||
|
||
export interface ScriptGenerateResponse {
|
||
id: string // generation UUID
|
||
script: string // rendered PowerShell
|
||
warnings: string[]
|
||
metadata: {
|
||
template_name: string
|
||
template_version: number
|
||
requires_elevation: boolean
|
||
[key: string]: unknown
|
||
}
|
||
}
|
||
|
||
export interface ScriptGenerationRecord {
|
||
id: string
|
||
template_id: string
|
||
template_name: string
|
||
parameters_used: Record<string, unknown> // sensitive values already redacted by backend
|
||
created_at: string
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add re-export to `frontend/src/types/index.ts`**
|
||
|
||
Add this line at the end of the existing export list (after the `kbAccelerator` export block):
|
||
|
||
```typescript
|
||
export * from './scripts'
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript compiles**
|
||
|
||
Run from `frontend/`:
|
||
```bash
|
||
npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: no type errors related to `scripts.ts`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/types/scripts.ts frontend/src/types/index.ts
|
||
git commit -m "feat: add script generator TypeScript types
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: API Client
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/scripts.ts`
|
||
- Modify: `frontend/src/api/index.ts`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/api/scripts.ts`**
|
||
|
||
```typescript
|
||
import apiClient from './client'
|
||
import type {
|
||
ScriptCategoryResponse,
|
||
ScriptTemplateListItem,
|
||
ScriptTemplateDetail,
|
||
ScriptGenerateRequest,
|
||
ScriptGenerateResponse,
|
||
ScriptGenerationRecord,
|
||
} from '@/types'
|
||
|
||
export const scriptsApi = {
|
||
async getCategories(): Promise<ScriptCategoryResponse[]> {
|
||
const response = await apiClient.get<ScriptCategoryResponse[]>('/scripts/categories')
|
||
return response.data
|
||
},
|
||
|
||
async getTemplates(params?: {
|
||
category_slug?: string
|
||
search?: string
|
||
tags?: string // Phase 3: comma-separated tag filter
|
||
}): Promise<ScriptTemplateListItem[]> {
|
||
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
|
||
return response.data
|
||
},
|
||
|
||
async getTemplateDetail(id: string): Promise<ScriptTemplateDetail> {
|
||
const response = await apiClient.get<ScriptTemplateDetail>(`/scripts/templates/${id}`)
|
||
return response.data
|
||
},
|
||
|
||
async generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> {
|
||
const response = await apiClient.post<ScriptGenerateResponse>('/scripts/generate', req)
|
||
return response.data
|
||
},
|
||
|
||
// Phase 3: fetch generation history for the current user
|
||
async getGenerations(): Promise<ScriptGenerationRecord[]> {
|
||
const response = await apiClient.get<ScriptGenerationRecord[]>('/scripts/generations')
|
||
return response.data
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add re-export to `frontend/src/api/index.ts`**
|
||
|
||
Add after the last existing `export` line:
|
||
|
||
```typescript
|
||
export { scriptsApi } from './scripts'
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: clean build, no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/scripts.ts frontend/src/api/index.ts
|
||
git commit -m "feat: add scriptsApi client
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Zustand Store
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/store/scriptGeneratorStore.ts`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/store/scriptGeneratorStore.ts`**
|
||
|
||
```typescript
|
||
import { create } from 'zustand'
|
||
import { scriptsApi } from '@/api'
|
||
import type {
|
||
ScriptCategoryResponse,
|
||
ScriptTemplateListItem,
|
||
ScriptTemplateDetail,
|
||
ScriptParametersSchema,
|
||
} from '@/types'
|
||
|
||
interface ScriptGeneratorState {
|
||
// Template browsing
|
||
categories: ScriptCategoryResponse[]
|
||
templates: ScriptTemplateListItem[]
|
||
selectedTemplate: ScriptTemplateDetail | null
|
||
searchQuery: string
|
||
activeCategoryId: string | null // null = "All"
|
||
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
|
||
isLoadingDetail: boolean // drives spinner in ScriptGeneratorPanel
|
||
|
||
// Form
|
||
paramValues: Record<string, string> // keyed by ScriptParameter.key; booleans as 'true'/'false'
|
||
formErrors: Record<string, string> // keyed by ScriptParameter.key
|
||
|
||
// Output
|
||
generatedScript: string | null
|
||
generationId: string | null
|
||
generationWarnings: string[]
|
||
isGenerating: boolean
|
||
generateError: string | null
|
||
|
||
// Actions
|
||
loadCategories: () => Promise<void>
|
||
loadTemplates: () => Promise<void>
|
||
selectTemplate: (id: string) => Promise<void>
|
||
setCategory: (id: string | null) => void
|
||
setSearch: (query: string) => void
|
||
setParamValue: (key: string, value: string) => void
|
||
validate: () => boolean
|
||
generate: (sessionId?: string) => Promise<void>
|
||
clearOutput: () => void
|
||
reset: () => void
|
||
}
|
||
|
||
export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get) => ({
|
||
// Initial state
|
||
categories: [],
|
||
templates: [],
|
||
selectedTemplate: null,
|
||
searchQuery: '',
|
||
activeCategoryId: null,
|
||
isLoadingTemplates: false,
|
||
isLoadingDetail: false,
|
||
paramValues: {},
|
||
formErrors: {},
|
||
generatedScript: null,
|
||
generationId: null,
|
||
generationWarnings: [],
|
||
isGenerating: false,
|
||
generateError: null,
|
||
|
||
loadCategories: async () => {
|
||
const categories = await scriptsApi.getCategories()
|
||
set({ categories })
|
||
},
|
||
|
||
loadTemplates: async () => {
|
||
set({ isLoadingTemplates: true })
|
||
const { activeCategoryId, categories, searchQuery } = get()
|
||
const category = categories.find(c => c.id === activeCategoryId)
|
||
const params: { category_slug?: string; search?: string } = {}
|
||
if (category) params.category_slug = category.slug
|
||
if (searchQuery) params.search = searchQuery
|
||
const templates = await scriptsApi.getTemplates(params)
|
||
set({ templates, isLoadingTemplates: false })
|
||
},
|
||
|
||
selectTemplate: async (id: string) => {
|
||
set({ isLoadingDetail: true })
|
||
const detail = await scriptsApi.getTemplateDetail(id)
|
||
// Populate paramValues from parameter defaults
|
||
const schema = detail.parameters_schema as ScriptParametersSchema
|
||
const parameters = schema?.parameters ?? []
|
||
const paramValues: Record<string, string> = {}
|
||
for (const param of parameters) {
|
||
const d = param.default
|
||
if (d === null || d === undefined) paramValues[param.key] = ''
|
||
else if (typeof d === 'boolean') paramValues[param.key] = d ? 'true' : 'false'
|
||
else paramValues[param.key] = String(d)
|
||
}
|
||
set({
|
||
selectedTemplate: detail,
|
||
paramValues,
|
||
formErrors: {},
|
||
generatedScript: null,
|
||
generationId: null,
|
||
generationWarnings: [],
|
||
generateError: null,
|
||
isLoadingDetail: false,
|
||
})
|
||
},
|
||
|
||
setCategory: (id: string | null) => {
|
||
set({ activeCategoryId: id })
|
||
get().loadTemplates()
|
||
},
|
||
|
||
setSearch: (query: string) => {
|
||
set({ searchQuery: query })
|
||
get().loadTemplates()
|
||
},
|
||
|
||
setParamValue: (key: string, value: string) => {
|
||
set(state => ({
|
||
paramValues: { ...state.paramValues, [key]: value },
|
||
formErrors: { ...state.formErrors, [key]: '' }, // clear error on change
|
||
}))
|
||
},
|
||
|
||
validate: () => {
|
||
const { selectedTemplate, paramValues } = get()
|
||
if (!selectedTemplate) return true
|
||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||
const parameters = schema?.parameters ?? []
|
||
const errors: Record<string, string> = {}
|
||
for (const param of parameters) {
|
||
if (param.required && !paramValues[param.key]) {
|
||
errors[param.key] = `${param.label} is required`
|
||
}
|
||
}
|
||
set({ formErrors: errors })
|
||
return Object.keys(errors).length === 0
|
||
},
|
||
|
||
generate: async (sessionId?: string) => {
|
||
const { selectedTemplate, paramValues } = get()
|
||
if (!selectedTemplate) return
|
||
if (!get().validate()) return
|
||
|
||
set({ isGenerating: true, generateError: null })
|
||
try {
|
||
const response = await scriptsApi.generate({
|
||
template_id: selectedTemplate.id,
|
||
parameters: paramValues,
|
||
...(sessionId ? { session_id: sessionId } : {}),
|
||
})
|
||
set({
|
||
generatedScript: response.script,
|
||
generationId: response.id,
|
||
generationWarnings: response.warnings,
|
||
isGenerating: false,
|
||
})
|
||
} catch (error: unknown) {
|
||
const axiosErr = error as { response?: { data?: { detail?: string } } }
|
||
const message = axiosErr.response?.data?.detail ?? 'Failed to generate script'
|
||
set({ generateError: message, isGenerating: false })
|
||
}
|
||
},
|
||
|
||
clearOutput: () => {
|
||
set({
|
||
generatedScript: null,
|
||
generationId: null,
|
||
generationWarnings: [],
|
||
generateError: null,
|
||
})
|
||
},
|
||
|
||
// Exposed for Phase 3 callers (session execution context).
|
||
// Does NOT clear selectedTemplate, categories, templates, or browsing state.
|
||
reset: () => {
|
||
set({
|
||
paramValues: {},
|
||
formErrors: {},
|
||
generatedScript: null,
|
||
generationId: null,
|
||
generationWarnings: [],
|
||
generateError: null,
|
||
})
|
||
},
|
||
}))
|
||
```
|
||
|
||
- [ ] **Step 2: Verify TypeScript compiles**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/store/scriptGeneratorStore.ts
|
||
git commit -m "feat: add scriptGeneratorStore Zustand store
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: UI — Components, Page, Routing, Navigation
|
||
|
||
### Task 4: PowerShellHighlighter
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/PowerShellHighlighter.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/PowerShellHighlighter.tsx`**
|
||
|
||
```tsx
|
||
/**
|
||
* Single-pass PowerShell syntax highlighter.
|
||
*
|
||
* Uses a combined alternation regex so tokens matched earlier in the list
|
||
* cannot be re-coloured by later rules (e.g. a variable inside a string
|
||
* is captured by the string rule and won't be re-matched by the variable rule).
|
||
*
|
||
* Priority order:
|
||
* 1. Comments /#[^\r\n]star/
|
||
* 2. String literals /"[^"]*"|'[^']*'/
|
||
* 3. Unfilled placeholders /\{\{[^}]+\}\}/
|
||
* 4. Variables /\$\w+/
|
||
* 5. Cmdlets /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
|
||
* 6. Parameters /-[A-Za-z]+/
|
||
* 7. Keywords /\b(if|else|...)\b/
|
||
*
|
||
* Note: variables (priority 4) consume $foreach before keywords (priority 7)
|
||
* can match — this is intentional PowerShell behaviour.
|
||
*/
|
||
|
||
const TOKEN_REGEX = new RegExp(
|
||
[
|
||
/#[^\r\n]*/, // 1. comments
|
||
/"[^"]*"|'[^']*'/, // 2. string literals
|
||
/\{\{[^}]+\}\}/, // 3. unfilled placeholders
|
||
/\$\w+/, // 4. variables
|
||
/[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun)
|
||
/-[A-Za-z]+/, // 6. parameters
|
||
/\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords
|
||
]
|
||
.map(r => r.source)
|
||
.join('|'),
|
||
'g'
|
||
)
|
||
|
||
const TOKEN_CLASSES: Record<string, string> = {
|
||
comment: 'text-[#8b949e]',
|
||
string: 'text-[#a5d6ff]',
|
||
placeholder: 'text-amber-400 underline decoration-dashed',
|
||
variable: 'text-[#79c0ff]',
|
||
cmdlet: 'text-[#22d3ee]',
|
||
parameter: 'text-[#d2a8ff]',
|
||
keyword: 'text-[#ff7b72]',
|
||
}
|
||
|
||
const KEYWORDS = new Set([
|
||
'if', 'else', 'elseif', 'foreach', 'for', 'while',
|
||
'function', 'return', 'try', 'catch', 'finally', 'param', 'switch',
|
||
])
|
||
|
||
function classify(token: string): string {
|
||
if (token.startsWith('#')) return 'comment'
|
||
if (token.startsWith('"') || token.startsWith("'")) return 'string'
|
||
if (token.startsWith('{{')) return 'placeholder'
|
||
if (token.startsWith('$')) return 'variable'
|
||
if (/^-[A-Za-z]+$/.test(token)) return 'parameter'
|
||
if (KEYWORDS.has(token)) return 'keyword'
|
||
return 'cmdlet'
|
||
}
|
||
|
||
interface Props {
|
||
script: string
|
||
}
|
||
|
||
export function PowerShellHighlighter({ script }: Props) {
|
||
const parts: React.ReactNode[] = []
|
||
let lastIndex = 0
|
||
|
||
TOKEN_REGEX.lastIndex = 0
|
||
let match: RegExpExecArray | null
|
||
|
||
while ((match = TOKEN_REGEX.exec(script)) !== null) {
|
||
if (match.index > lastIndex) {
|
||
parts.push(script.slice(lastIndex, match.index))
|
||
}
|
||
const token = match[0]
|
||
const kind = classify(token)
|
||
parts.push(
|
||
<span key={match.index} className={TOKEN_CLASSES[kind]}>
|
||
{token}
|
||
</span>
|
||
)
|
||
lastIndex = match.index + token.length
|
||
}
|
||
|
||
if (lastIndex < script.length) {
|
||
parts.push(script.slice(lastIndex))
|
||
}
|
||
|
||
return (
|
||
<pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto">
|
||
<code>{parts}</code>
|
||
</pre>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/PowerShellHighlighter.tsx
|
||
git commit -m "feat: add PowerShellHighlighter syntax highlighter
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: ScriptPreview
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptPreview.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptPreview.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react'
|
||
import { Copy, Check } from 'lucide-react'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import { PowerShellHighlighter } from './PowerShellHighlighter'
|
||
import type { ScriptParametersSchema } from '@/types'
|
||
|
||
export function ScriptPreview() {
|
||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||
const paramValues = useScriptGeneratorStore(s => s.paramValues)
|
||
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
if (!selectedTemplate) return null
|
||
|
||
// Compute the displayed script
|
||
let displayScript: string
|
||
if (generatedScript !== null) {
|
||
displayScript = generatedScript
|
||
} else {
|
||
// Draft mode: client-side {{key}} substitution
|
||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||
const parameters = schema?.parameters ?? []
|
||
displayScript = selectedTemplate.script_body
|
||
for (const param of parameters) {
|
||
const placeholder = `{{${param.key}}}`
|
||
const replacement = param.sensitive
|
||
? '****'
|
||
: (paramValues[param.key] ?? '')
|
||
displayScript = displayScript.replaceAll(placeholder, replacement || placeholder)
|
||
}
|
||
}
|
||
|
||
const handleCopy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(displayScript)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
} catch {
|
||
// silently fail — no error displayed
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
onClick={handleCopy}
|
||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
|
||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||
>
|
||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||
</button>
|
||
<PowerShellHighlighter script={displayScript} />
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptPreview.tsx
|
||
git commit -m "feat: add ScriptPreview with live substitution and copy
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: ScriptParameterField
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptParameterField.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptParameterField.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react'
|
||
import { Eye, EyeOff } from 'lucide-react'
|
||
import { Input } from '@/components/ui/Input'
|
||
import { Textarea } from '@/components/ui/Textarea'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import type { ScriptParameter } from '@/types'
|
||
|
||
interface Props {
|
||
param: ScriptParameter
|
||
value: string
|
||
error: string | undefined
|
||
disabled: boolean
|
||
}
|
||
|
||
export function ScriptParameterField({ param, value, error, disabled }: Props) {
|
||
const setParamValue = useScriptGeneratorStore(s => s.setParamValue)
|
||
const [showPassword, setShowPassword] = useState(false)
|
||
|
||
const id = `param-${param.key}`
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||
setParamValue(param.key, e.target.value)
|
||
}
|
||
|
||
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setParamValue(param.key, e.target.checked ? 'true' : 'false')
|
||
}
|
||
|
||
let input: React.ReactNode
|
||
|
||
if (param.type === 'text' || param.type === 'multi_text' || param.type === 'number') {
|
||
input = (
|
||
<Input
|
||
id={id}
|
||
type={param.type === 'number' ? 'number' : 'text'}
|
||
value={value}
|
||
onChange={handleChange}
|
||
placeholder={
|
||
param.type === 'multi_text'
|
||
? 'Comma-separated values'
|
||
: (param.placeholder ?? undefined)
|
||
}
|
||
disabled={disabled}
|
||
error={error}
|
||
/>
|
||
)
|
||
} else if (param.type === 'password') {
|
||
input = (
|
||
<div className="relative">
|
||
<Input
|
||
id={id}
|
||
type={showPassword ? 'text' : 'password'}
|
||
value={value}
|
||
onChange={handleChange}
|
||
placeholder={param.placeholder ?? undefined}
|
||
disabled={disabled}
|
||
error={error}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(v => !v)}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||
tabIndex={-1}
|
||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||
>
|
||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</div>
|
||
)
|
||
} else if (param.type === 'textarea') {
|
||
input = (
|
||
<Textarea
|
||
id={id}
|
||
value={value}
|
||
onChange={handleChange}
|
||
placeholder={param.placeholder ?? undefined}
|
||
disabled={disabled}
|
||
error={error}
|
||
rows={4}
|
||
/>
|
||
)
|
||
} else if (param.type === 'select') {
|
||
input = (
|
||
<>
|
||
<select
|
||
id={id}
|
||
value={value}
|
||
onChange={handleChange}
|
||
disabled={disabled}
|
||
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)] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<option value="">Select…</option>
|
||
{(param.options ?? []).map(opt => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{error && <p className="mt-1.5 text-xs text-red-400">{error}</p>}
|
||
</>
|
||
)
|
||
} else if (param.type === 'boolean') {
|
||
input = (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
id={id}
|
||
type="checkbox"
|
||
checked={value === 'true'}
|
||
onChange={handleCheckbox}
|
||
disabled={disabled}
|
||
className="rounded border-border disabled:cursor-not-allowed disabled:opacity-50"
|
||
/>
|
||
<label htmlFor={id} className="text-sm text-foreground">
|
||
{param.label}
|
||
</label>
|
||
</div>
|
||
{error && <p className="mt-1.5 text-xs text-red-400">{error}</p>}
|
||
</>
|
||
)
|
||
} else {
|
||
// Fallback for unknown types
|
||
input = (
|
||
<Input
|
||
id={id}
|
||
value={value}
|
||
onChange={handleChange}
|
||
disabled={disabled}
|
||
error={error}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// Boolean renders its own label inline; all others show the label above
|
||
const showTopLabel = param.type !== 'boolean'
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
{showTopLabel && (
|
||
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||
{param.label}
|
||
{param.required && <span className="text-red-400 ml-0.5">*</span>}
|
||
</label>
|
||
)}
|
||
{input}
|
||
{param.help_text && (
|
||
<p className="text-xs text-muted-foreground mt-1">{param.help_text}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptParameterField.tsx
|
||
git commit -m "feat: add ScriptParameterField — all 7 field types
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: ScriptParameterForm
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptParameterForm.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptParameterForm.tsx`**
|
||
|
||
```tsx
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import { ScriptParameterField } from './ScriptParameterField'
|
||
import type { ScriptParametersSchema, ScriptParameter } from '@/types'
|
||
|
||
interface Props {
|
||
canGenerate: boolean
|
||
}
|
||
|
||
export function ScriptParameterForm({ canGenerate }: Props) {
|
||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||
const paramValues = useScriptGeneratorStore(s => s.paramValues)
|
||
const formErrors = useScriptGeneratorStore(s => s.formErrors)
|
||
|
||
if (!selectedTemplate) return null
|
||
|
||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||
const parameters = (schema?.parameters ?? []).slice().sort((a, b) => a.order - b.order)
|
||
|
||
// Group parameters: null-group first, then named groups in order of first appearance
|
||
const ungrouped = parameters.filter(p => p.group === null)
|
||
const groupOrder: string[] = []
|
||
const grouped: Record<string, ScriptParameter[]> = {}
|
||
for (const p of parameters) {
|
||
if (p.group !== null) {
|
||
if (!grouped[p.group]) {
|
||
grouped[p.group] = []
|
||
groupOrder.push(p.group)
|
||
}
|
||
grouped[p.group].push(p)
|
||
}
|
||
}
|
||
|
||
const renderParam = (param: ScriptParameter) => (
|
||
<ScriptParameterField
|
||
key={param.key}
|
||
param={param}
|
||
value={paramValues[param.key] ?? ''}
|
||
error={formErrors[param.key] || undefined}
|
||
disabled={!canGenerate}
|
||
/>
|
||
)
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{ungrouped.map(renderParam)}
|
||
{groupOrder.map(group => (
|
||
<div key={group}>
|
||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
|
||
{group}
|
||
</p>
|
||
<div className="flex flex-col gap-4">
|
||
{grouped[group].map(renderParam)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptParameterForm.tsx
|
||
git commit -m "feat: add ScriptParameterForm with parameter grouping
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: TemplateCard
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/TemplateCard.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/TemplateCard.tsx`**
|
||
|
||
```tsx
|
||
import { ShieldAlert } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import type { ScriptTemplateListItem } from '@/types'
|
||
|
||
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
|
||
beginner: 'text-emerald-400 bg-emerald-400/10',
|
||
intermediate: 'text-amber-400 bg-amber-400/10',
|
||
advanced: 'text-rose-500 bg-rose-500/10',
|
||
}
|
||
|
||
interface Props {
|
||
template: ScriptTemplateListItem
|
||
}
|
||
|
||
export function TemplateCard({ template }: Props) {
|
||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
|
||
const isActive = selectedTemplate?.id === template.id
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => selectTemplate(template.id)}
|
||
className={cn(
|
||
'w-full text-left px-4 py-3 rounded-xl border transition-all',
|
||
'hover:border-white/12 hover:bg-white/4',
|
||
isActive
|
||
? 'bg-primary/10 border-primary/30 border-l-[3px] border-l-primary'
|
||
: 'border-border bg-transparent'
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 mb-1">
|
||
<span className="text-sm font-medium text-foreground line-clamp-1">
|
||
{template.name}
|
||
</span>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{template.requires_elevation && (
|
||
<span title="Requires administrator elevation">
|
||
<ShieldAlert size={13} className="text-amber-400" />
|
||
</span>
|
||
)}
|
||
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
|
||
{template.complexity}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{template.description && (
|
||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||
{template.description}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
|
||
<span>{template.usage_count}× used</span>
|
||
{template.tags.length > 0 && (
|
||
<div className="flex gap-1 flex-wrap">
|
||
{template.tags.slice(0, 3).map(tag => (
|
||
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{template.tags.length > 3 && (
|
||
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/TemplateCard.tsx
|
||
git commit -m "feat: add TemplateCard component
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: ScriptTemplateList
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptTemplateList.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptTemplateList.tsx`**
|
||
|
||
```tsx
|
||
import { FileCode, Search } from 'lucide-react'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import { TemplateCard } from './TemplateCard'
|
||
|
||
interface Props {
|
||
inputValue: string
|
||
onClearSearch: () => void
|
||
}
|
||
|
||
function TemplateSkeleton() {
|
||
return (
|
||
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
|
||
<div className="flex justify-between mb-2">
|
||
<div className="h-4 w-2/3 bg-white/8 rounded" />
|
||
<div className="h-4 w-14 bg-white/8 rounded" />
|
||
</div>
|
||
<div className="h-3 w-full bg-white/5 rounded mb-1" />
|
||
<div className="h-3 w-3/4 bg-white/5 rounded" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ScriptTemplateList({ inputValue, onClearSearch }: Props) {
|
||
const templates = useScriptGeneratorStore(s => s.templates)
|
||
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
|
||
|
||
if (isLoadingTemplates) {
|
||
return (
|
||
<div className="flex flex-col gap-2 p-2">
|
||
<TemplateSkeleton />
|
||
<TemplateSkeleton />
|
||
<TemplateSkeleton />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (templates.length === 0) {
|
||
if (inputValue !== '') {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
|
||
<Search size={32} className="text-muted-foreground/40" />
|
||
<p className="text-sm text-muted-foreground">No templates match your search</p>
|
||
<button
|
||
type="button"
|
||
onClick={onClearSearch}
|
||
className="text-xs text-primary hover:underline"
|
||
>
|
||
Clear search
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
|
||
<FileCode size={32} className="text-muted-foreground/40" />
|
||
<p className="text-sm text-muted-foreground">No templates found</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 p-2">
|
||
{templates.map(template => (
|
||
<TemplateCard key={template.id} template={template} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptTemplateList.tsx
|
||
git commit -m "feat: add ScriptTemplateList with skeleton and empty states
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: ScriptFilterBar
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptFilterBar.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptFilterBar.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useRef } from 'react'
|
||
import { Search } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
|
||
interface Props {
|
||
inputValue: string
|
||
setInputValue: (value: string) => void
|
||
}
|
||
|
||
export function ScriptFilterBar({ inputValue, setInputValue }: Props) {
|
||
const categories = useScriptGeneratorStore(s => s.categories)
|
||
const activeCategoryId = useScriptGeneratorStore(s => s.activeCategoryId)
|
||
const setCategory = useScriptGeneratorStore(s => s.setCategory)
|
||
const setSearch = useScriptGeneratorStore(s => s.setSearch)
|
||
|
||
// Debounce: 300ms after the input value settles, push to store
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
useEffect(() => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
debounceRef.current = setTimeout(() => {
|
||
setSearch(inputValue)
|
||
}, 300)
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
}
|
||
}, [inputValue, setSearch])
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
{/* Category pills */}
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCategory(null)}
|
||
className={cn(
|
||
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||
activeCategoryId === null
|
||
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
|
||
: 'border-border text-muted-foreground hover:border-white/12 hover:text-foreground'
|
||
)}
|
||
>
|
||
All
|
||
</button>
|
||
{categories.map(cat => (
|
||
<button
|
||
key={cat.id}
|
||
type="button"
|
||
onClick={() => setCategory(cat.id)}
|
||
className={cn(
|
||
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||
activeCategoryId === cat.id
|
||
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
|
||
: 'border-border text-muted-foreground hover:border-white/12 hover:text-foreground'
|
||
)}
|
||
>
|
||
{cat.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Search input */}
|
||
<div className="relative ml-auto">
|
||
<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={inputValue}
|
||
onChange={e => setInputValue(e.target.value)}
|
||
placeholder="Search templates…"
|
||
className="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)] w-52"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptFilterBar.tsx
|
||
git commit -m "feat: add ScriptFilterBar with category tabs and debounced search
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: ScriptGeneratorPanel
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`**
|
||
|
||
```tsx
|
||
import { Terminal, Download, Loader2, AlertTriangle } from 'lucide-react'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import { usePermissions } from '@/hooks/usePermissions'
|
||
import { ScriptParameterForm } from './ScriptParameterForm'
|
||
import { ScriptPreview } from './ScriptPreview'
|
||
|
||
export function ScriptGeneratorPanel() {
|
||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
|
||
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
|
||
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
|
||
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
|
||
const generateError = useScriptGeneratorStore(s => s.generateError)
|
||
const generate = useScriptGeneratorStore(s => s.generate)
|
||
|
||
const { isEngineer } = usePermissions()
|
||
const canGenerate = isEngineer
|
||
|
||
// No template selected
|
||
if (!selectedTemplate && !isLoadingDetail) {
|
||
return (
|
||
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
|
||
<Terminal size={40} className="text-muted-foreground/40" />
|
||
<p className="text-sm text-muted-foreground">Select a template to get started</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Loading template detail
|
||
if (isLoadingDetail) {
|
||
return (
|
||
<div className="glass-card-static h-full flex items-center justify-center">
|
||
<Loader2 size={28} className="text-primary animate-spin" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!selectedTemplate) return null
|
||
|
||
const handleDownload = () => {
|
||
if (!generatedScript) return
|
||
const blob = new Blob([generatedScript], { type: 'text/plain' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${selectedTemplate.slug}.ps1`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
return (
|
||
<div className="glass-card-static h-full flex flex-col gap-4 p-4 overflow-y-auto">
|
||
{/* Header */}
|
||
<div>
|
||
<h2 className="text-base font-semibold text-foreground">{selectedTemplate.name}</h2>
|
||
{selectedTemplate.description && (
|
||
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Parameter form */}
|
||
<ScriptParameterForm canGenerate={canGenerate} />
|
||
|
||
{/* Warnings */}
|
||
{generationWarnings.length > 0 && (
|
||
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3">
|
||
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
|
||
<AlertTriangle size={13} />
|
||
Warnings
|
||
</div>
|
||
{generationWarnings.map((w, i) => (
|
||
<p key={i} className="text-xs text-amber-400/80">{w}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview */}
|
||
<ScriptPreview />
|
||
|
||
{/* Action bar */}
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<span title={!canGenerate ? 'Engineer access required' : undefined}>
|
||
<button
|
||
type="button"
|
||
onClick={() => generate()}
|
||
disabled={isGenerating || !canGenerate}
|
||
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 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
|
||
>
|
||
{isGenerating && <Loader2 size={14} className="animate-spin" />}
|
||
Generate
|
||
</button>
|
||
</span>
|
||
|
||
<span title={!canGenerate ? 'Engineer access required' : undefined}>
|
||
<button
|
||
type="button"
|
||
onClick={handleDownload}
|
||
disabled={!generatedScript || !canGenerate}
|
||
className="flex items-center gap-1.5 bg-white/4 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-white/12 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Download size={14} />
|
||
Download .ps1
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{/* Generate error */}
|
||
{generateError && (
|
||
<p className="text-xs text-rose-500">{generateError}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/scripts/ScriptGeneratorPanel.tsx
|
||
git commit -m "feat: add ScriptGeneratorPanel with permission gating
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: ScriptLibraryPage
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/pages/ScriptLibraryPage.tsx`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/pages/ScriptLibraryPage.tsx`**
|
||
|
||
```tsx
|
||
import { useState, useEffect } from 'react'
|
||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
|
||
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
|
||
import { ScriptGeneratorPanel } from '@/components/scripts/ScriptGeneratorPanel'
|
||
|
||
export default function ScriptLibraryPage() {
|
||
// inputValue is owned here so ScriptFilterBar and ScriptTemplateList
|
||
// can coordinate clear-search without direct coupling.
|
||
const [inputValue, setInputValue] = useState('')
|
||
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
|
||
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
|
||
const setSearch = useScriptGeneratorStore(s => s.setSearch)
|
||
|
||
useEffect(() => {
|
||
// loadCategories must complete before loadTemplates can resolve slugs
|
||
loadCategories().then(() => loadTemplates())
|
||
}, [loadCategories, loadTemplates])
|
||
|
||
const onClearSearch = () => {
|
||
setInputValue('')
|
||
setSearch('')
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 p-6 h-full">
|
||
{/* Page header */}
|
||
<div>
|
||
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Filter bar */}
|
||
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
|
||
|
||
{/* Two-column layout */}
|
||
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
|
||
{/* Template list — scrollable */}
|
||
<div className="glass-card-static overflow-y-auto">
|
||
<ScriptTemplateList inputValue={inputValue} onClearSearch={onClearSearch} />
|
||
</div>
|
||
|
||
{/* Generator panel */}
|
||
<ScriptGeneratorPanel />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -10
|
||
```
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/ScriptLibraryPage.tsx
|
||
git commit -m "feat: add ScriptLibraryPage shell
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Routing and Navigation
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/router.tsx`
|
||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||
- Modify: `frontend/src/components/layout/AppLayout.tsx`
|
||
|
||
- [ ] **Step 1: Add lazy import to `frontend/src/router.tsx`**
|
||
|
||
After the existing `StepLibraryPage` import (around line 43), add:
|
||
|
||
```typescript
|
||
const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
|
||
```
|
||
|
||
- [ ] **Step 2: Add route to `frontend/src/router.tsx`**
|
||
|
||
In the protected/AppLayout children array, after the `step-library` route (around line 162), add:
|
||
|
||
```typescript
|
||
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||
```
|
||
|
||
- [ ] **Step 3: Add nav item to `frontend/src/components/layout/Sidebar.tsx`**
|
||
|
||
Add `Terminal` to the lucide import line at the top of the file.
|
||
|
||
The component renders nav items in two places. Search for `href="/step-library"` — it appears twice. After each occurrence, add the Scripts entry:
|
||
|
||
Collapsed section — find this line and add immediately after:
|
||
```tsx
|
||
// find:
|
||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||
// add after:
|
||
<NavItem href="/scripts" icon={Terminal} label="Script Library" collapsed />
|
||
```
|
||
|
||
Expanded section — find this line and add immediately after:
|
||
```tsx
|
||
// find:
|
||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||
// add after:
|
||
<NavItem href="/scripts" icon={Terminal} label="Script Library" />
|
||
```
|
||
|
||
- [ ] **Step 4: Add to mobile nav in `frontend/src/components/layout/AppLayout.tsx`**
|
||
|
||
`Terminal` needs to be added to the lucide import in `AppLayout.tsx`. Find the lucide import and add `Terminal`.
|
||
|
||
In the `mobileNavItems` array (after the step-library entry), add:
|
||
|
||
```typescript
|
||
{ path: '/scripts', label: 'Script Library', icon: Terminal },
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: clean build with no errors or type warnings.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx frontend/src/components/layout/AppLayout.tsx
|
||
git commit -m "feat: add /scripts route and Script Library nav entry
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: End-to-End Smoke Test
|
||
|
||
- [ ] **Step 1: Start dev server**
|
||
|
||
```bash
|
||
# Terminal 1 — backend
|
||
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
|
||
|
||
# Terminal 2 — frontend
|
||
cd frontend && npm run dev
|
||
```
|
||
|
||
- [ ] **Step 2: Navigate to Script Library**
|
||
|
||
Open http://localhost:5173 and log in as `engineer@resolutionflow.example.com` (password: `TestPass123!`).
|
||
|
||
Click "Script Library" in the sidebar. Verify:
|
||
- Page loads at `/scripts`
|
||
- Filter bar shows "All" tab (active) + any seeded categories
|
||
- Template list shows templates (or empty state if none seeded)
|
||
|
||
- [ ] **Step 3: Smoke test template selection**
|
||
|
||
Click any template card. Verify:
|
||
- Card gets active highlight (cyan left border + `bg-primary/10`)
|
||
- Right panel spinner appears briefly then shows template name + parameters
|
||
|
||
- [ ] **Step 4: Smoke test live preview**
|
||
|
||
Fill in a parameter value. Verify:
|
||
- Code block updates in real-time to replace `{{key}}` with the typed value
|
||
|
||
- [ ] **Step 5: Smoke test generate**
|
||
|
||
Click Generate. Verify:
|
||
- Button shows spinner while in flight
|
||
- Generated script appears in code block
|
||
- Download button becomes enabled
|
||
|
||
- [ ] **Step 6: Smoke test viewer permission**
|
||
|
||
Log out and log in as `viewer@resolutionflow.example.com` (if a viewer account exists in seed data) or check with a viewer-role account. Verify:
|
||
- Page is accessible and templates are browsable
|
||
- Generate and Download buttons show "Engineer access required" tooltip and are disabled
|
||
- Parameter fields are disabled (greyed out)
|
||
|
||
- [ ] **Step 7: Smoke test search**
|
||
|
||
Type in the search box. Verify:
|
||
- ~300ms delay then templates filter
|
||
- "Clear search" appears and works when no results
|
||
|
||
- [ ] **Step 8: Final build check**
|
||
|
||
```bash
|
||
cd frontend && npm run build 2>&1 | tail -20
|
||
```
|
||
Expected: clean build.
|
||
|
||
- [ ] **Step 9: Commit smoke test confirmation**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: smoke test complete — script library phase 2 working
|
||
|
||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Done
|
||
|
||
All tasks complete. Push the branch and update the PR:
|
||
|
||
```bash
|
||
git push origin feat/script-generator
|
||
```
|
||
|
||
Then update `CURRENT-STATE.md` to move "Step Library Frontend UI" from In Progress and add "Script Library Frontend (Phase 2)" as recently completed.
|