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:
157
docs/plans/2026-03-13-script-template-editor-design.md
Normal file
157
docs/plans/2026-03-13-script-template-editor-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Script Template Editor — Design Document
|
||||
|
||||
> **Date:** 2026-03-13
|
||||
> **Status:** Approved
|
||||
> **Depends on:** Script Generator Phase 1 (backend) + Phase 2 (Script Library frontend)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Build a Script Template Editor page where engineers can create and manage their own PowerShell script templates, and owners/admins can promote personal templates to team-wide visibility via a "Share with team" toggle.
|
||||
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
**Route:** `/scripts/manage`
|
||||
|
||||
**Two modes:**
|
||||
|
||||
- **List mode** — all templates the user can see/edit. Filterable by category, searchable by name. Each row: name, category, complexity badge, usage count, scope badge ("Personal" / "Team"), action buttons (Edit, Delete).
|
||||
- **Editor mode** — full-page form for creating or editing a template. No modal — the template has too many fields. "Back to templates" link with unsaved-changes warning if dirty.
|
||||
|
||||
**Navigation entry points:**
|
||||
- "Manage Templates" link on the Script Library page header (visible to engineers+)
|
||||
- Direct URL `/scripts/manage`
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
### Who sees what in the list
|
||||
|
||||
| Role | Visible templates |
|
||||
|------|------------------|
|
||||
| Engineer | Own templates (`created_by = user.id`) + team templates (`team_id = user.team_id`) |
|
||||
| Owner / Admin | All templates in their account scope |
|
||||
| Super admin | All templates across all accounts |
|
||||
|
||||
### Who can do what
|
||||
|
||||
| Action | Engineer | Owner/Admin | Super Admin |
|
||||
|--------|----------|-------------|-------------|
|
||||
| Create template | Yes (personal scope) | Yes (personal or team) | Yes (any scope) |
|
||||
| Edit own template | Yes | Yes | Yes |
|
||||
| Edit others' templates | No | Yes (within account) | Yes (all) |
|
||||
| Delete own template | Yes (soft delete) | Yes | Yes |
|
||||
| Delete others' templates | No | Yes (within account) | Yes (all) |
|
||||
| "Share with team" toggle | No | Yes | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Permission refactor
|
||||
|
||||
Replace `_require_team_admin()` with `_check_template_permission(user, template?)`:
|
||||
- **Create:** engineers+ can create (personal scope)
|
||||
- **Edit/Delete:** engineers can modify own templates (`created_by == user.id`); owners/admins can modify any template in their account; super admins can modify any
|
||||
- Existing `POST /scripts/templates` sets `created_by = user.id` and `team_id = null` for engineers (personal scope)
|
||||
|
||||
### New endpoint
|
||||
|
||||
`PATCH /scripts/templates/{id}/share` — owner/admin/super_admin only
|
||||
- Body: `{ "shared": boolean }`
|
||||
- `shared=true` → sets `team_id` to the user's `team_id`
|
||||
- `shared=false` → clears `team_id` to `null` (reverts to personal scope for original author)
|
||||
- Returns updated `ScriptTemplateDetail`
|
||||
|
||||
### Query filter
|
||||
|
||||
Update `GET /scripts/templates` to support `managed=true` query param:
|
||||
- Returns templates the user can edit (own templates + team templates for owners/admins)
|
||||
- Used by the manage page list view
|
||||
|
||||
---
|
||||
|
||||
## Template Editor Form
|
||||
|
||||
Single scrollable page with sections separated by dividers. Fixed bottom action bar.
|
||||
|
||||
### Section 1: Metadata
|
||||
|
||||
- **Name** — text input, required
|
||||
- **Description** — textarea
|
||||
- **Use Case** — textarea ("when would you use this?")
|
||||
- **Category** — select dropdown from existing categories
|
||||
- **Complexity** — select: beginner / intermediate / advanced
|
||||
- **Tags** — multi-text input (comma-separated or chip-style)
|
||||
- **Estimated Runtime** — text input (e.g., "30 seconds")
|
||||
- **Requires Elevation** — checkbox
|
||||
- **Required Modules** — multi-text input
|
||||
- **Share with team** — toggle switch, visible only to owners/admins/super_admins. Help text: "When enabled, all team members can browse and use this template."
|
||||
|
||||
### Section 2: Script Body
|
||||
|
||||
- Large textarea with `PowerShellHighlighter` for syntax coloring
|
||||
- Monospace font (`font-label` / JetBrains Mono)
|
||||
- `{{parameter_key}}` placeholders highlighted in amber so authors can see where parameters slot in
|
||||
- Simple textarea with highlighting overlay (no Monaco/CodeMirror dependency)
|
||||
|
||||
### Section 3: Parameters Schema
|
||||
|
||||
Two modes with a toggle at the top of the section:
|
||||
|
||||
**Visual mode (default):**
|
||||
- List of parameter cards, each expandable/collapsible
|
||||
- "Add Parameter" button at bottom
|
||||
- Each card: key, label, type (select from 7 types), required toggle, placeholder, group, order, help_text, default value, sensitive toggle
|
||||
- For `select` type: options sub-list (value + label pairs)
|
||||
- For types with validation: min/max/pattern fields
|
||||
- Drag-to-reorder or up/down arrows for parameter ordering
|
||||
|
||||
**JSON mode:**
|
||||
- Raw JSON editor showing the `parameters_schema` object
|
||||
- Edits sync back to visual mode on switch
|
||||
- Parse errors shown inline
|
||||
|
||||
### Section 4: Fixed Action Bar
|
||||
|
||||
- **Save** — primary button, creates or updates template
|
||||
- **Cancel** — back to list with dirty-state warning
|
||||
- **Delete** — danger button (right-aligned), only in edit mode, confirmation modal, soft delete
|
||||
|
||||
---
|
||||
|
||||
## Team Sharing Behavior
|
||||
|
||||
- **Default for engineer-created templates:** `team_id = null` (personal, only visible to creator)
|
||||
- **Shared:** `team_id` set to account's team — template appears in Script Library for all team members
|
||||
- **Unsharing:** reverts to personal scope for original author; author retains edit access via `created_by`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Components (Expected)
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|---------------|
|
||||
| `ScriptManagePage.tsx` | Page shell, list/editor mode toggle |
|
||||
| `ScriptTemplateListView.tsx` | Template list with filters, search, action buttons |
|
||||
| `ScriptTemplateEditor.tsx` | Full editor form — metadata, script body, parameters |
|
||||
| `ParameterSchemaBuilder.tsx` | Visual parameter builder with add/remove/reorder |
|
||||
| `ParameterCard.tsx` | Single parameter editor (expandable card) |
|
||||
| `ParameterJsonEditor.tsx` | Raw JSON mode for parameters schema |
|
||||
| `ScriptBodyEditor.tsx` | Textarea with PowerShell highlighting overlay |
|
||||
| `ShareToggle.tsx` | Team sharing toggle (owner/admin only) |
|
||||
|
||||
---
|
||||
|
||||
## Design System Compliance
|
||||
|
||||
- Dark glassmorphism theme, `.glass-card-static` containers
|
||||
- Primary actions: `bg-gradient-brand`
|
||||
- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em]`
|
||||
- Form inputs: `border-border bg-card text-foreground` with cyan focus ring
|
||||
- Complexity badges: emerald (beginner), amber (intermediate), rose (advanced)
|
||||
- Scope badges: "Personal" (muted border) / "Team" (cyan/primary tint)
|
||||
2253
docs/plans/2026-03-13-script-template-editor-impl.md
Normal file
2253
docs/plans/2026-03-13-script-template-editor-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
116
docs/plans/2026-03-14-parameter-detector-design.md
Normal file
116
docs/plans/2026-03-14-parameter-detector-design.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Parameter Detector — Design Doc
|
||||
|
||||
> **Date:** 2026-03-14
|
||||
> **Status:** Approved
|
||||
> **Scope:** Frontend only (no backend changes)
|
||||
|
||||
## Overview
|
||||
|
||||
A client-side tool in the Script Template Editor that scans a PowerShell script body, detects hardcoded values that should be parameterized, and walks the user through converting them one-by-one via a stepper UI.
|
||||
|
||||
## Detection Engine
|
||||
|
||||
Pure TypeScript utility (`lib/scriptParameterDetector.ts`) that takes a script body string and returns `ParameterCandidate[]`.
|
||||
|
||||
### Detection targets (in order)
|
||||
|
||||
1. **Script-level `param()` blocks** — the `param(...)` block at the top of the script, before any `function` keyword. Extracts name, type annotation, and default value. Skips `param()` blocks inside `function` declarations.
|
||||
2. **Variable assignments** — `$VarName = 'value'`, `$VarName = "value"`, `$VarName = 123`, `$VarName = $true/$false`. Skips variables already found in the param block and PowerShell internals like `$ErrorActionPreference`.
|
||||
|
||||
### Type inference
|
||||
|
||||
| Detection | Suggested Type | Sensitive |
|
||||
|-----------|---------------|-----------|
|
||||
| `[string]` or plain string value | `text` | no |
|
||||
| `[switch]` or `$true`/`$false` | `boolean` | no |
|
||||
| `[int]`, `[int32]`, `[int64]` or numeric value | `number` | no |
|
||||
| `[SecureString]` or name contains password/secret/key/credential | `password` | yes |
|
||||
| No type info, string value | `text` | no |
|
||||
|
||||
### Candidate shape
|
||||
|
||||
```typescript
|
||||
interface ParameterCandidate {
|
||||
variableName: string // "$OUPath"
|
||||
suggestedKey: string // "ou_path"
|
||||
suggestedLabel: string // "OU Path"
|
||||
suggestedType: ScriptParameter['type']
|
||||
sensitive: boolean
|
||||
defaultValue: string | boolean | number | null
|
||||
source: 'param_block' | 'assignment'
|
||||
lineNumber: number
|
||||
matchedLine: string
|
||||
inferenceReason: string // "Detected [switch] type declaration"
|
||||
}
|
||||
```
|
||||
|
||||
## Stepper UI
|
||||
|
||||
`ParameterDetectorStepper` component renders inline below the ScriptBodyEditor in the Script Body section.
|
||||
|
||||
### Trigger
|
||||
|
||||
- "Detect Parameters" button (secondary style, Wand2/Scan icon) below the script body textarea
|
||||
- Hidden if script body is empty; disabled while stepper is active
|
||||
- If no candidates found: brief "No parameter candidates detected" message
|
||||
|
||||
### Stepper layout
|
||||
|
||||
Shows one candidate at a time with:
|
||||
- Progress indicator ("Candidate 2 of 5" + dots)
|
||||
- Matched line displayed in monospace
|
||||
- Editable fields: Key, Label, Type (with info icon showing inferenceReason), Default value
|
||||
- Checkboxes: Required, Sensitive
|
||||
- Actions: Skip, Accept & Next (last item: Accept & Finish / Skip & Finish)
|
||||
|
||||
### On accept
|
||||
|
||||
1. Script body: replace the hardcoded value with `{{key}}`
|
||||
2. Parameters schema: append a new `ScriptParameter` with suggested values + original value as `default`
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Script body edited during detection → stepper dismisses
|
||||
- Key conflicts with existing parameter → warning + suggested alternative
|
||||
- Re-running after partial conversion → skips already-converted `{{key}}` placeholders
|
||||
|
||||
## Integration
|
||||
|
||||
### Component tree
|
||||
|
||||
```
|
||||
ScriptTemplateEditor
|
||||
├── Metadata section
|
||||
├── Script Body section
|
||||
│ ├── ScriptBodyEditor
|
||||
│ ├── "Detect Parameters" button ← NEW
|
||||
│ └── ParameterDetectorStepper ← NEW (conditional)
|
||||
├── Parameters section
|
||||
│ └── ParameterSchemaBuilder
|
||||
└── Fixed Action Bar
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
- Detection runs client-side via `detectParameterCandidates(script_body)`
|
||||
- Candidates stored in local React state on ScriptTemplateEditor
|
||||
- Accept updates `form.script_body` and `form.parameters_schema` via existing `updateField()`
|
||||
- `isDirty` flag set automatically — user can cancel without saving to undo everything
|
||||
- No new backend endpoints needed
|
||||
|
||||
## File changes
|
||||
|
||||
**New files:**
|
||||
- `frontend/src/lib/scriptParameterDetector.ts`
|
||||
- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
|
||||
|
||||
**Modified files:**
|
||||
- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
|
||||
- `frontend/src/types/scripts.ts`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No backend changes
|
||||
- No AI-powered detection (future enhancement)
|
||||
- No auto-detection on paste
|
||||
- No individual undo for accepted parameters
|
||||
920
docs/plans/2026-03-14-parameter-detector-plan.md
Normal file
920
docs/plans/2026-03-14-parameter-detector-plan.md
Normal file
@@ -0,0 +1,920 @@
|
||||
# Parameter Detector Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI.
|
||||
|
||||
**Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended).
|
||||
|
||||
**Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add ParameterCandidate type
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`)
|
||||
|
||||
**Step 1: Add the interface**
|
||||
|
||||
Add to the end of `frontend/src/types/scripts.ts`:
|
||||
|
||||
```typescript
|
||||
export interface ParameterCandidate {
|
||||
variableName: string
|
||||
suggestedKey: string
|
||||
suggestedLabel: string
|
||||
suggestedType: ScriptParameter['type']
|
||||
sensitive: boolean
|
||||
defaultValue: string | boolean | number | null
|
||||
source: 'param_block' | 'assignment'
|
||||
lineNumber: number
|
||||
matchedLine: string
|
||||
inferenceReason: string
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Export from types index**
|
||||
|
||||
Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export.
|
||||
|
||||
**Step 3: Run build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: SUCCESS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/scripts.ts
|
||||
git commit -m "feat: add ParameterCandidate type for script parameter detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Build detection engine
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/scriptParameterDetector.ts`
|
||||
|
||||
**Step 1: Create the detection utility**
|
||||
|
||||
Create `frontend/src/lib/scriptParameterDetector.ts` with the following:
|
||||
|
||||
```typescript
|
||||
import type { ScriptParameter, ParameterCandidate } from '@/types'
|
||||
|
||||
/**
|
||||
* PowerShell variable names to skip — these are PS internals, not user inputs.
|
||||
*/
|
||||
const SKIP_VARIABLES = new Set([
|
||||
'$ErrorActionPreference',
|
||||
'$WarningPreference',
|
||||
'$VerbosePreference',
|
||||
'$DebugPreference',
|
||||
'$InformationPreference',
|
||||
'$ConfirmPreference',
|
||||
'$ProgressPreference',
|
||||
'$PSDefaultParameterValues',
|
||||
'$PSModuleAutoLoadingPreference',
|
||||
'$OFS',
|
||||
'$FormatEnumerationLimit',
|
||||
'$MaximumHistoryCount',
|
||||
'$_',
|
||||
'$PSItem',
|
||||
'$args',
|
||||
'$input',
|
||||
'$this',
|
||||
'$null',
|
||||
'$true',
|
||||
'$false',
|
||||
])
|
||||
|
||||
/**
|
||||
* Sensitive variable name patterns — if the variable name contains any of these,
|
||||
* suggest password type and mark sensitive.
|
||||
*/
|
||||
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
|
||||
|
||||
/**
|
||||
* Convert a PowerShell variable name to a snake_case key.
|
||||
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
|
||||
*/
|
||||
function toSnakeCase(varName: string): string {
|
||||
// Strip leading $
|
||||
const name = varName.replace(/^\$/, '')
|
||||
// Insert underscore before uppercase letters, then lowercase everything
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a snake_case key to a human-readable label.
|
||||
* "ou_path" → "OU Path", "server_name" → "Server Name"
|
||||
*/
|
||||
function toLabel(key: string): string {
|
||||
return key
|
||||
.split('_')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
|
||||
*/
|
||||
function inferType(
|
||||
typeAnnotation: string | null,
|
||||
value: string | null,
|
||||
varName: string
|
||||
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
|
||||
// Check type annotation first
|
||||
if (typeAnnotation) {
|
||||
const t = typeAnnotation.toLowerCase()
|
||||
if (t === 'switch') {
|
||||
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
|
||||
}
|
||||
if (t === 'securestring') {
|
||||
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
|
||||
}
|
||||
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
|
||||
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
|
||||
}
|
||||
if (t === 'bool' || t === 'boolean') {
|
||||
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
|
||||
}
|
||||
// [string] or other → fall through to value/name checks
|
||||
}
|
||||
|
||||
// Check variable name for sensitive patterns
|
||||
if (SENSITIVE_PATTERNS.test(varName)) {
|
||||
return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` }
|
||||
}
|
||||
|
||||
// Check value patterns
|
||||
if (value !== null) {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '$true' || trimmed === '$false') {
|
||||
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
|
||||
}
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
|
||||
}
|
||||
}
|
||||
|
||||
// Default
|
||||
const reason = typeAnnotation
|
||||
? `Detected [${typeAnnotation}] type declaration`
|
||||
: 'Defaulting to text (no type annotation detected)'
|
||||
return { type: 'text', sensitive: false, reason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the default value into the correct JS type.
|
||||
*/
|
||||
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
|
||||
if (value === null) return null
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (type === 'boolean') {
|
||||
if (trimmed === '$true') return true
|
||||
if (trimmed === '$false') return false
|
||||
return null
|
||||
}
|
||||
if (type === 'number') {
|
||||
const n = Number(trimmed)
|
||||
return isNaN(n) ? null : n
|
||||
}
|
||||
// Strip surrounding quotes for string values
|
||||
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end index of a script-level param() block.
|
||||
* Returns -1 if no script-level param block is found.
|
||||
* Skips param() blocks inside function declarations.
|
||||
*/
|
||||
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
|
||||
const lines = script.split('\n')
|
||||
let inFunction = false
|
||||
let paramStart = -1
|
||||
let parenDepth = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim()
|
||||
|
||||
// Track function blocks — skip param() inside functions
|
||||
if (/^function\s+/i.test(trimmed)) {
|
||||
inFunction = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Find script-level param keyword
|
||||
if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
|
||||
paramStart = i
|
||||
// Count parens to find the closing )
|
||||
for (let j = i; j < lines.length; j++) {
|
||||
for (const ch of lines[j]) {
|
||||
if (ch === '(') parenDepth++
|
||||
if (ch === ')') parenDepth--
|
||||
if (parenDepth === 0 && paramStart !== -1) {
|
||||
return { start: paramStart, end: j }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset function tracking at closing brace (simplified)
|
||||
if (inFunction && trimmed === '}') {
|
||||
inFunction = false
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter candidates from a script-level param() block.
|
||||
*/
|
||||
function extractParamBlockCandidates(
|
||||
script: string,
|
||||
block: { start: number; end: number }
|
||||
): ParameterCandidate[] {
|
||||
const lines = script.split('\n')
|
||||
const blockText = lines.slice(block.start, block.end + 1).join('\n')
|
||||
const candidates: ParameterCandidate[] = []
|
||||
|
||||
// Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName
|
||||
// Supports [Parameter(Mandatory=$true)] attributes on preceding lines
|
||||
const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = paramRegex.exec(blockText)) !== null) {
|
||||
const typeAnnotation = match[1] || null
|
||||
const varName = match[2]
|
||||
const rawDefault = match[3]?.trim() ?? null
|
||||
|
||||
// Skip Parameter() attributes — they look like [Parameter(...)]
|
||||
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
|
||||
|
||||
const key = toSnakeCase(varName)
|
||||
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
|
||||
const defaultValue = parseDefault(rawDefault, type)
|
||||
|
||||
// Find the actual line number in the original script
|
||||
const lineIndex = lines.findIndex((line, idx) =>
|
||||
idx >= block.start && idx <= block.end && line.includes(`$${varName}`)
|
||||
)
|
||||
|
||||
candidates.push({
|
||||
variableName: `$${varName}`,
|
||||
suggestedKey: key,
|
||||
suggestedLabel: toLabel(key),
|
||||
suggestedType: type,
|
||||
sensitive,
|
||||
defaultValue,
|
||||
source: 'param_block',
|
||||
lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1,
|
||||
matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`,
|
||||
inferenceReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter candidates from variable assignments ($Var = 'value').
|
||||
*/
|
||||
function extractAssignmentCandidates(
|
||||
script: string,
|
||||
existingVarNames: Set<string>
|
||||
): ParameterCandidate[] {
|
||||
const lines = script.split('\n')
|
||||
const candidates: ParameterCandidate[] = []
|
||||
const seenVars = new Set<string>()
|
||||
|
||||
// Match: $VarName = 'value' | "value" | 123 | $true | $false
|
||||
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(assignRegex)
|
||||
if (!match) continue
|
||||
|
||||
const fullVar = match[1]
|
||||
const rawValue = match[2].trim()
|
||||
|
||||
// Skip PS internals
|
||||
if (SKIP_VARIABLES.has(fullVar)) continue
|
||||
|
||||
// Skip if already found in param block
|
||||
if (existingVarNames.has(fullVar)) continue
|
||||
|
||||
// Skip if already seen (take first assignment only)
|
||||
if (seenVars.has(fullVar)) continue
|
||||
|
||||
// Skip if value is a complex expression (function call, pipeline, etc.)
|
||||
// Only match: quoted strings, numbers, $true/$false
|
||||
if (!/^['"].*['"]$/.test(rawValue) &&
|
||||
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
|
||||
!/^\$(true|false)$/i.test(rawValue)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if the value already contains a {{placeholder}}
|
||||
if (/\{\{.*?\}\}/.test(rawValue)) continue
|
||||
|
||||
seenVars.add(fullVar)
|
||||
|
||||
const varName = fullVar.replace(/^\$/, '')
|
||||
const key = toSnakeCase(varName)
|
||||
const { type, sensitive, reason } = inferType(null, rawValue, varName)
|
||||
const defaultValue = parseDefault(rawValue, type)
|
||||
|
||||
candidates.push({
|
||||
variableName: fullVar,
|
||||
suggestedKey: key,
|
||||
suggestedLabel: toLabel(key),
|
||||
suggestedType: type,
|
||||
sensitive,
|
||||
defaultValue,
|
||||
source: 'assignment',
|
||||
lineNumber: i + 1,
|
||||
matchedLine: lines[i].trim(),
|
||||
inferenceReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect parameter candidates in a PowerShell script body.
|
||||
* Returns candidates from script-level param() block first, then variable assignments.
|
||||
*/
|
||||
export function detectParameterCandidates(script: string): ParameterCandidate[] {
|
||||
if (!script.trim()) return []
|
||||
|
||||
// 1. Find and extract script-level param block
|
||||
const paramBlock = findScriptLevelParamBlock(script)
|
||||
const paramCandidates = paramBlock
|
||||
? extractParamBlockCandidates(script, paramBlock)
|
||||
: []
|
||||
|
||||
// Track param block var names to avoid duplicates in assignment scan
|
||||
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
|
||||
|
||||
// 2. Extract variable assignments
|
||||
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
|
||||
|
||||
return [...paramCandidates, ...assignmentCandidates]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: SUCCESS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/scriptParameterDetector.ts
|
||||
git commit -m "feat: add PowerShell parameter detection engine"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Build ParameterDetectorStepper component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
|
||||
|
||||
**Step 1: Create the stepper component**
|
||||
|
||||
Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import type { ParameterCandidate, ScriptParameter } from '@/types'
|
||||
|
||||
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'textarea', label: 'Textarea' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'select', label: 'Select' },
|
||||
{ value: 'multi_text', label: 'Multi-text' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
candidates: ParameterCandidate[]
|
||||
existingKeys: string[]
|
||||
onAccept: (candidate: ParameterCandidate, overrides: {
|
||||
key: string
|
||||
label: string
|
||||
type: ScriptParameter['type']
|
||||
sensitive: boolean
|
||||
required: boolean
|
||||
defaultValue: string | boolean | number | null
|
||||
}) => void
|
||||
onSkip: (candidate: ParameterCandidate) => void
|
||||
onFinish: (acceptedCount: number, totalCount: number) => void
|
||||
}
|
||||
|
||||
export function ParameterDetectorStepper({
|
||||
candidates,
|
||||
existingKeys,
|
||||
onAccept,
|
||||
onSkip,
|
||||
onFinish,
|
||||
}: Props) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
|
||||
|
||||
// Editable overrides for the current candidate
|
||||
const current = candidates[currentIndex]
|
||||
const [key, setKey] = useState(current.suggestedKey)
|
||||
const [label, setLabel] = useState(current.suggestedLabel)
|
||||
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
|
||||
const [sensitive, setSensitive] = useState(current.sensitive)
|
||||
const [required, setRequired] = useState(true)
|
||||
const [defaultValue, setDefaultValue] = useState(
|
||||
current.defaultValue !== null ? String(current.defaultValue) : ''
|
||||
)
|
||||
|
||||
const isLast = currentIndex === candidates.length - 1
|
||||
const keyConflict = existingKeys.includes(key) ||
|
||||
candidates.slice(0, currentIndex).some((_, i) => {
|
||||
// This is a simplification — actual conflict check happens against
|
||||
// the running list of accepted keys which is managed by the parent
|
||||
return false
|
||||
})
|
||||
|
||||
const resetFieldsForIndex = (index: number) => {
|
||||
const c = candidates[index]
|
||||
setKey(c.suggestedKey)
|
||||
setLabel(c.suggestedLabel)
|
||||
setType(c.suggestedType)
|
||||
setSensitive(c.sensitive)
|
||||
setRequired(true)
|
||||
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
|
||||
setShowInferenceInfo(false)
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
const parsedDefault = type === 'boolean'
|
||||
? defaultValue === 'true'
|
||||
: type === 'number'
|
||||
? (defaultValue ? Number(defaultValue) : null)
|
||||
: (defaultValue || null)
|
||||
|
||||
onAccept(current, {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
sensitive,
|
||||
required,
|
||||
defaultValue: parsedDefault,
|
||||
})
|
||||
|
||||
const newAccepted = acceptedCount + 1
|
||||
setAcceptedCount(newAccepted)
|
||||
|
||||
if (isLast) {
|
||||
onFinish(newAccepted, candidates.length)
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
resetFieldsForIndex(nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip(current)
|
||||
|
||||
if (isLast) {
|
||||
onFinish(acceptedCount, candidates.length)
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
resetFieldsForIndex(nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Candidate {currentIndex + 1} of {candidates.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{candidates.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full transition-colors',
|
||||
i < currentIndex ? 'bg-primary' :
|
||||
i === currentIndex ? 'bg-primary animate-pulse' :
|
||||
'bg-border'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matched line */}
|
||||
<div className="rounded-lg bg-black/20 px-3 py-2">
|
||||
<p className="font-label text-xs text-amber-400 break-all">
|
||||
{current.matchedLine}
|
||||
</p>
|
||||
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
|
||||
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Editable fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||
placeholder="param_key"
|
||||
/>
|
||||
{existingKeys.includes(key) && (
|
||||
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists — consider a different name</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="Display Label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
|
||||
Type
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
title={current.inferenceReason}
|
||||
>
|
||||
<Info size={11} />
|
||||
</button>
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value as ScriptParameter['type'])}
|
||||
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)]"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{showInferenceInfo && (
|
||||
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
|
||||
{current.inferenceReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
|
||||
<Input
|
||||
value={defaultValue}
|
||||
onChange={e => setDefaultValue(e.target.value)}
|
||||
placeholder="Original value preserved"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={required}
|
||||
onChange={e => setRequired(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sensitive}
|
||||
onChange={e => setSensitive(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Sensitive
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
|
||||
>
|
||||
<SkipForward size={13} />
|
||||
{isLast ? 'Skip & Finish' : 'Skip'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
disabled={!key.trim() || !label.trim()}
|
||||
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLast ? (
|
||||
<><Check size={13} /> Accept & Finish</>
|
||||
) : (
|
||||
<><ChevronRight size={13} /> Accept & Next</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: SUCCESS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx
|
||||
git commit -m "feat: add ParameterDetectorStepper component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire detection into ScriptTemplateEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
|
||||
|
||||
**Step 1: Add imports**
|
||||
|
||||
At the top of `ScriptTemplateEditor.tsx`, add:
|
||||
|
||||
```typescript
|
||||
import { Scan } from 'lucide-react'
|
||||
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
|
||||
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
|
||||
import type { ParameterCandidate, ScriptParameter } from '@/types'
|
||||
```
|
||||
|
||||
Update the existing `lucide-react` import to include `Scan` alongside the existing icons.
|
||||
|
||||
**Step 2: Add detection state**
|
||||
|
||||
Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add:
|
||||
|
||||
```typescript
|
||||
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
|
||||
const [showStepper, setShowStepper] = useState(false)
|
||||
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
|
||||
```
|
||||
|
||||
**Step 3: Add detection handler**
|
||||
|
||||
After `handleBack` (around line 188), add:
|
||||
|
||||
```typescript
|
||||
const handleDetectParameters = () => {
|
||||
const candidates = detectParameterCandidates(form.script_body)
|
||||
if (candidates.length === 0) {
|
||||
setDetectionSummary('No parameter candidates detected in the script body.')
|
||||
setShowStepper(false)
|
||||
setTimeout(() => setDetectionSummary(null), 4000)
|
||||
return
|
||||
}
|
||||
setDetectedCandidates(candidates)
|
||||
setDetectionSummary(null)
|
||||
setShowStepper(true)
|
||||
}
|
||||
|
||||
const handleAcceptCandidate = (
|
||||
candidate: ParameterCandidate,
|
||||
overrides: {
|
||||
key: string
|
||||
label: string
|
||||
type: ScriptParameter['type']
|
||||
sensitive: boolean
|
||||
required: boolean
|
||||
defaultValue: string | boolean | number | null
|
||||
}
|
||||
) => {
|
||||
// 1. Replace the value in the script body with {{key}}
|
||||
let updatedScript = form.script_body
|
||||
if (candidate.source === 'param_block') {
|
||||
// For param block: replace the default value portion
|
||||
// e.g., $VarName = "default" → $VarName = "{{key}}"
|
||||
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
|
||||
if (defaultMatch) {
|
||||
updatedScript = updatedScript.replace(
|
||||
candidate.matchedLine,
|
||||
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// For assignment: replace the right-hand side value
|
||||
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
|
||||
if (assignMatch) {
|
||||
updatedScript = updatedScript.replace(
|
||||
candidate.matchedLine,
|
||||
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Append new parameter to the schema
|
||||
const existingParams = form.parameters_schema.parameters
|
||||
const newParam: ScriptParameter = {
|
||||
key: overrides.key,
|
||||
label: overrides.label,
|
||||
type: overrides.type,
|
||||
required: overrides.required,
|
||||
placeholder: null,
|
||||
group: null,
|
||||
order: existingParams.length + 1,
|
||||
help_text: null,
|
||||
options: null,
|
||||
default: overrides.defaultValue,
|
||||
validation: null,
|
||||
sensitive: overrides.sensitive,
|
||||
}
|
||||
|
||||
// Update both fields
|
||||
setForm(f => ({
|
||||
...f,
|
||||
script_body: updatedScript,
|
||||
parameters_schema: {
|
||||
parameters: [...f.parameters_schema.parameters, newParam],
|
||||
},
|
||||
}))
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
const handleSkipCandidate = () => {
|
||||
// Nothing to do — stepper advances internally
|
||||
}
|
||||
|
||||
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
|
||||
setShowStepper(false)
|
||||
setDetectedCandidates([])
|
||||
setDetectionSummary(
|
||||
acceptedCount === 0
|
||||
? 'No parameters were added.'
|
||||
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
|
||||
)
|
||||
setTimeout(() => setDetectionSummary(null), 5000)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add UI elements to the Script Body section**
|
||||
|
||||
In the JSX, find the Script Body section (around line 334-348). After the `<ScriptBodyEditor>` and before `</section>`, add the detect button and stepper:
|
||||
|
||||
```tsx
|
||||
<ScriptBodyEditor
|
||||
value={form.script_body}
|
||||
onChange={v => updateField('script_body', v)}
|
||||
/>
|
||||
|
||||
{/* Detect Parameters button + stepper */}
|
||||
{form.script_body.trim() && !showStepper && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetectParameters}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
|
||||
>
|
||||
<Scan size={14} />
|
||||
Detect Parameters
|
||||
</button>
|
||||
)}
|
||||
|
||||
{detectionSummary && (
|
||||
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
|
||||
)}
|
||||
|
||||
{showStepper && detectedCandidates.length > 0 && (
|
||||
<ParameterDetectorStepper
|
||||
candidates={detectedCandidates}
|
||||
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
|
||||
onAccept={handleAcceptCandidate}
|
||||
onSkip={handleSkipCandidate}
|
||||
onFinish={handleDetectionFinish}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 5: Run build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: SUCCESS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx
|
||||
git commit -m "feat: wire parameter detection into ScriptTemplateEditor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual testing checklist
|
||||
|
||||
**Step 1: Test with variable assignments**
|
||||
|
||||
1. Navigate to `/scripts/manage` → click New Template
|
||||
2. Paste this script body:
|
||||
```powershell
|
||||
$ServerName = 'DC01'
|
||||
$OUPath = 'OU=Users,DC=contoso,DC=com'
|
||||
$DefaultPassword = 'Welcome123!'
|
||||
$ForceChange = $true
|
||||
$MaxRetries = 3
|
||||
```
|
||||
3. Click "Detect Parameters"
|
||||
4. Verify 5 candidates appear in stepper
|
||||
5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number
|
||||
6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved
|
||||
|
||||
**Step 2: Test with param() block**
|
||||
|
||||
Paste:
|
||||
```powershell
|
||||
param(
|
||||
[string]$ServerName = "DC01",
|
||||
[switch]$WhatIf,
|
||||
[SecureString]$AdminPassword,
|
||||
[int]$Port = 443
|
||||
)
|
||||
|
||||
$Connection = "https://$ServerName:$Port"
|
||||
```
|
||||
Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal)
|
||||
|
||||
**Step 3: Test with function-level param (should be skipped)**
|
||||
|
||||
Paste:
|
||||
```powershell
|
||||
$GlobalPath = 'C:\Scripts'
|
||||
|
||||
function Load-Users {
|
||||
param($filter = "*")
|
||||
Get-ADUser -Filter $filter
|
||||
}
|
||||
```
|
||||
Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear.
|
||||
|
||||
**Step 4: Test edge cases**
|
||||
|
||||
- Empty script body → Detect Parameters button hidden
|
||||
- Script with only `{{key}}` placeholders → "No parameter candidates detected"
|
||||
- Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped
|
||||
- Re-running detect after accepting some → already-converted values skipped
|
||||
|
||||
**Step 5: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git commit -m "fix: address issues found during parameter detector testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final build verification and push
|
||||
|
||||
**Step 1: Run full build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: SUCCESS with no type errors
|
||||
|
||||
**Step 2: Push**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
1602
docs/superpowers/plans/2026-03-13-script-generator-phase2.md
Normal file
1602
docs/superpowers/plans/2026-03-13-script-generator-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
# Script Library Pane Takeover — 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:** Redesign the Script Library left pane to have Browse mode (template list + filter bar) and Configure mode (full-height parameter form + action buttons), with the right pane becoming a read-only `ScriptPreview`.
|
||||
|
||||
**Architecture:** `ScriptLibraryPage` owns `paneMode` local state (`'browse' | 'configure'`). Clicking "Configure →" on a `TemplateCard` calls `store.selectTemplate(id)` then flips the pane. `ScriptConfigurePane` (new component) owns the configure-mode layout — back button, template header, param form, action bar. `ScriptGeneratorPanel` is deleted; the right pane becomes `ScriptPreview` in isolation.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Zustand (`useScriptGeneratorStore`), Tailwind CSS v3, Lucide React. Verification: `npx tsc -b --noEmit` (NOT `npm run build` — pre-existing Node 18 incompatibility with Vite).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `frontend/src/components/scripts/TemplateCard.tsx` | Modify | Non-interactive card with "Configure →" button; no store subscription |
|
||||
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Modify | Thread `onConfigure` prop to each `TemplateCard` |
|
||||
| `frontend/src/components/scripts/ScriptConfigurePane.tsx` | Create | Configure mode layout: back button, template header, form, action bar |
|
||||
| `frontend/src/pages/ScriptLibraryPage.tsx` | Modify | `paneMode` state, filter-bar moved into left pane, right pane simplified |
|
||||
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | Delete | Superseded by `ScriptConfigurePane` + right-pane simplification |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: All Tasks
|
||||
|
||||
### Task 1: Modify `TemplateCard` — remove store subscription, add "Configure →" button
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/scripts/TemplateCard.tsx`
|
||||
|
||||
Current state: `TemplateCard` is a `<button>` that calls `store.selectTemplate()` on click and applies active-border styling when `selectedTemplate?.id === template.id`. It imports `useScriptGeneratorStore`.
|
||||
|
||||
- [ ] **Step 1: Replace the entire file with the updated implementation**
|
||||
|
||||
```tsx
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
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
|
||||
onConfigure: (id: string) => void
|
||||
}
|
||||
|
||||
export function TemplateCard({ template, onConfigure }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-3 rounded-xl border transition-all',
|
||||
'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 justify-between gap-3">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => onConfigure(template.id)}
|
||||
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Configure →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: errors only from `ScriptTemplateList` (it still passes no `onConfigure` prop) — that's fine, it's fixed in Task 2.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/scripts/TemplateCard.tsx
|
||||
git commit -m "refactor: TemplateCard — remove store subscription, add Configure button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Modify `ScriptTemplateList` — thread `onConfigure` prop
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/scripts/ScriptTemplateList.tsx`
|
||||
|
||||
Current state: `ScriptTemplateList` accepts `{ inputValue, onClearSearch }` and renders `<TemplateCard template={template} />` with no extra props.
|
||||
|
||||
- [ ] **Step 1: Update the file**
|
||||
|
||||
```tsx
|
||||
import { FileCode, Search } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { TemplateCard } from './TemplateCard'
|
||||
|
||||
interface Props {
|
||||
inputValue: string
|
||||
onClearSearch: () => void
|
||||
onConfigure: (id: string) => 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/10 rounded" />
|
||||
<div className="h-4 w-14 bg-white/10 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, onConfigure }: 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} onConfigure={onConfigure} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: errors now only from `ScriptLibraryPage` (it passes no `onConfigure` to `ScriptTemplateList` yet) — that's fine.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/scripts/ScriptTemplateList.tsx
|
||||
git commit -m "refactor: ScriptTemplateList — add onConfigure prop threading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `ScriptConfigurePane` — configure mode layout
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/scripts/ScriptConfigurePane.tsx`
|
||||
|
||||
This new component renders the full configure-mode left pane: back button, loading spinner (when `isLoadingDetail`), first-selection error state, template header with tags, `ScriptParameterForm`, warnings callout, and the Generate/Download/Copy action bar.
|
||||
|
||||
Data comes from `useScriptGeneratorStore` directly. `canGenerate` and `onBack` come from props.
|
||||
|
||||
The action-bar Copy button copies `store.generatedScript` and is disabled when `generatedScript === null`. The Download button uses `selectedTemplate.slug` for the filename. Both Generate and Download are disabled when `isGenerating || !canGenerate`. Copy is disabled when `!generatedScript || !canGenerate`.
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { ScriptParameterForm } from './ScriptParameterForm'
|
||||
|
||||
const COMPLEXITY_CLASSES = {
|
||||
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
|
||||
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
|
||||
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
canGenerate: boolean
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
|
||||
const categories = useScriptGeneratorStore(s => s.categories)
|
||||
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 [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!generatedScript) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedScript)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!generatedScript || !selectedTemplate) 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`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoadingDetail) {
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 size={28} className="text-primary animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// First-selection failure state
|
||||
if (!selectedTemplate) {
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<Terminal size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Failed to load template.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
|
||||
const displayTags = selectedTemplate.tags.slice(0, 3)
|
||||
const extraTagCount = selectedTemplate.tags.length - 3
|
||||
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
|
||||
{/* Template header */}
|
||||
<div className="mb-3">
|
||||
<h2 className="text-base font-semibold font-heading text-foreground">
|
||||
{selectedTemplate.name}
|
||||
</h2>
|
||||
{selectedTemplate.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
||||
{selectedTemplate.requires_elevation && (
|
||||
<span
|
||||
title="Requires administrator elevation"
|
||||
className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
|
||||
>
|
||||
⚠ Elevated
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
|
||||
COMPLEXITY_CLASSES[selectedTemplate.complexity]
|
||||
)}>
|
||||
{selectedTemplate.complexity}
|
||||
</span>
|
||||
{categoryName && (
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
{displayTags.map(tag => (
|
||||
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTagCount > 0 && (
|
||||
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mb-4" />
|
||||
|
||||
{/* 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 mt-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-col gap-2 mt-4 pt-1">
|
||||
<span title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generate()}
|
||||
disabled={isGenerating || !canGenerate}
|
||||
className="w-full flex items-center justify-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 Script
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={!generatedScript || !canGenerate}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download .ps1
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!generatedScript || !canGenerate}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate error */}
|
||||
{generateError && (
|
||||
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: No new errors from this file. Errors may still exist from `ScriptLibraryPage` not yet updated — that's fine.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/scripts/ScriptConfigurePane.tsx
|
||||
git commit -m "feat: add ScriptConfigurePane — configure mode layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite `ScriptLibraryPage` — pane mode, filter bar in column, right pane simplified
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ScriptLibraryPage.tsx`
|
||||
|
||||
Changes:
|
||||
1. Add `paneMode` state (`'browse' | 'configure'`)
|
||||
2. Add `usePermissions` for `canGenerate`
|
||||
3. Add `selectedTemplate` subscription for right-pane conditional
|
||||
4. Move `ScriptFilterBar` into the left pane column (only rendered in browse mode)
|
||||
5. Add `onConfigure` / `onBack` callbacks
|
||||
6. Left pane conditionally renders browse content or `ScriptConfigurePane`
|
||||
7. Right pane: empty state when `selectedTemplate === null`, otherwise `ScriptPreview` in `overflow-hidden` wrapper
|
||||
|
||||
- [ ] **Step 1: Replace the entire file**
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
|
||||
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
|
||||
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
|
||||
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
|
||||
|
||||
export default function ScriptLibraryPage() {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
|
||||
|
||||
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
|
||||
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
|
||||
const setSearch = useScriptGeneratorStore(s => s.setSearch)
|
||||
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
|
||||
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
|
||||
const { isEngineer } = usePermissions()
|
||||
const canGenerate = isEngineer
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories().then(() => loadTemplates())
|
||||
}, [loadCategories, loadTemplates])
|
||||
|
||||
const onClearSearch = () => {
|
||||
setInputValue('')
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
const onConfigure = (id: string) => {
|
||||
selectTemplate(id)
|
||||
setPaneMode('configure')
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
clearOutput()
|
||||
setPaneMode('browse')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
|
||||
{/* Left pane — Browse or Configure */}
|
||||
{paneMode === 'browse' ? (
|
||||
<div className="glass-card-static flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScriptTemplateList
|
||||
inputValue={inputValue}
|
||||
onClearSearch={onClearSearch}
|
||||
onConfigure={onConfigure}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
|
||||
)}
|
||||
|
||||
{/* Right pane — always ScriptPreview */}
|
||||
{selectedTemplate === null ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="glass-card-static h-full overflow-hidden p-4">
|
||||
<ScriptPreview />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript — expect clean**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: Zero errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ScriptLibraryPage.tsx
|
||||
git commit -m "feat: ScriptLibraryPage — pane takeover with Browse/Configure modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delete `ScriptGeneratorPanel`
|
||||
|
||||
**Files:**
|
||||
- Delete: `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`
|
||||
|
||||
`ScriptGeneratorPanel` is no longer imported or used anywhere — `ScriptLibraryPage` now uses `ScriptConfigurePane` for the left pane and `ScriptPreview` directly for the right pane.
|
||||
|
||||
- [ ] **Step 1: Verify nothing imports `ScriptGeneratorPanel`**
|
||||
|
||||
```bash
|
||||
grep -r "ScriptGeneratorPanel" /home/michaelchihlas/dev/patherly/frontend/src
|
||||
```
|
||||
|
||||
Expected: No output (zero matches).
|
||||
|
||||
- [ ] **Step 2: Delete the file**
|
||||
|
||||
```bash
|
||||
rm /home/michaelchihlas/dev/patherly/frontend/src/components/scripts/ScriptGeneratorPanel.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript still clean**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
|
||||
```
|
||||
|
||||
Expected: Zero errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -u frontend/src/components/scripts/ScriptGeneratorPanel.tsx
|
||||
git commit -m "chore: delete ScriptGeneratorPanel — superseded by ScriptConfigurePane"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Smoke test
|
||||
|
||||
- [ ] **Step 1: Start dev server**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify browse mode**
|
||||
|
||||
Open `http://localhost:5173/scripts`.
|
||||
|
||||
Expected:
|
||||
- Template list shows with filter bar above it (inside the left pane, no filter bar outside the pane)
|
||||
- Each template card has a "Configure →" button at bottom-right
|
||||
- Clicking anywhere on the card body (not the button) does nothing
|
||||
- Right pane shows Terminal icon + "Select a template to get started"
|
||||
|
||||
- [ ] **Step 3: Verify configure mode**
|
||||
|
||||
Click "Configure →" on any template.
|
||||
|
||||
Expected:
|
||||
- Left pane transitions to configure view (filter bar and list hidden)
|
||||
- Loading spinner visible briefly, then full configure view appears
|
||||
- Template name, description, complexity badge, category name, tags visible at top
|
||||
- Parameter form below (all fields interactive if engineer role)
|
||||
- "Generate Script" button full-width, cyan gradient
|
||||
- "Download .ps1" and "Copy" buttons disabled (no generated script yet)
|
||||
- Right pane still shows empty state (first click) or previous preview
|
||||
|
||||
- [ ] **Step 4: Verify generate flow**
|
||||
|
||||
Fill required parameters, click "Generate Script".
|
||||
|
||||
Expected:
|
||||
- Spinner appears on Generate button during generation
|
||||
- Right pane updates to show generated PowerShell (with syntax highlighting)
|
||||
- "Download .ps1" and "Copy" buttons become enabled
|
||||
- Copy button copies text; shows "Copied!" for 2 seconds
|
||||
- Download button triggers `.ps1` file download
|
||||
|
||||
- [ ] **Step 5: Verify Back**
|
||||
|
||||
Click "← Back to library".
|
||||
|
||||
Expected:
|
||||
- Left pane returns to browse mode (filter bar + template list visible)
|
||||
- Search input and category pills restore to previous state
|
||||
- Right pane continues showing the previously generated output
|
||||
|
||||
- [ ] **Step 6: Stop dev server and push**
|
||||
|
||||
```bash
|
||||
git push origin feat/script-generator
|
||||
```
|
||||
@@ -0,0 +1,410 @@
|
||||
# Script Generator Phase 2 — Frontend Design
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Phase:** 2 of Script Generator feature
|
||||
**Builds on:** Phase 1 backend (`feat/script-generator` PR #105)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
**Layout:** Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
|
||||
|
||||
**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. `reset()` is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; `selectTemplate()` clears form/output state inline via its own atomic `set()` call without calling `reset()`.
|
||||
|
||||
**Preview:** Client-side lightweight substitution for live preview as the user types (`{{key}}` replacement only, matching the backend template variable syntax). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend.
|
||||
|
||||
**Security note:** The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend `POST /scripts/generate` endpoint uses only `get_current_active_user` with no role check. Adding a backend engineer role guard is deferred — this is a known limitation.
|
||||
|
||||
**Search note:** The backend `?search=` parameter matches against `name`, `description`, and `slug`. Tags are filtered separately via `?tags=` (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only.
|
||||
|
||||
---
|
||||
|
||||
## Zustand Store — `scriptGeneratorStore`
|
||||
|
||||
**File:** `frontend/src/store/scriptGeneratorStore.ts`
|
||||
|
||||
### State shape
|
||||
|
||||
```typescript
|
||||
interface ScriptGeneratorState {
|
||||
// Template browsing
|
||||
categories: ScriptCategoryResponse[];
|
||||
templates: ScriptTemplateListItem[];
|
||||
selectedTemplate: ScriptTemplateDetail | null;
|
||||
searchQuery: string;
|
||||
activeCategoryId: string | null; // null = "All"; used to derive slug for API calls
|
||||
isLoadingTemplates: boolean; // drives skeleton in ScriptTemplateList only
|
||||
isLoadingDetail: boolean; // drives spinner in ScriptGeneratorPanel only
|
||||
|
||||
// Form
|
||||
paramValues: Record<string, string>; // keyed by ScriptParameter.key; booleans stored 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;
|
||||
}
|
||||
```
|
||||
|
||||
### Behaviour notes
|
||||
|
||||
- `setCategory` updates `activeCategoryId`, then calls `loadTemplates()`
|
||||
- `setSearch` updates `searchQuery`, then calls `loadTemplates()`. The component (not the store) debounces its call to `setSearch` — `setSearch` always calls `loadTemplates()` immediately on invocation
|
||||
- `loadTemplates()` resolves the slug from the `categories` array (`categories.find(c => c.id === activeCategoryId)?.slug`) before sending `{ category_slug, search }` to the API. When `activeCategoryId` is `null` ("All"), `category_slug` is omitted from the request. Prerequisite: `loadCategories()` must complete before `loadTemplates()` or `setCategory()` can resolve slugs — the page bootstrap calls them in this order
|
||||
- `selectTemplate(id)` workflow:
|
||||
1. Sets `isLoadingDetail: true` (does NOT touch `isLoadingTemplates`)
|
||||
2. Fetches `ScriptTemplateDetail` from the API
|
||||
3. In a single `set()` call: sets `selectedTemplate`, populates `paramValues` by converting each `parameter.default` to string (`null` → `''`, `true` → `'true'`, `false` → `'false'`, numbers → `String(n)`), clears `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`, sets `isLoadingDetail: false`
|
||||
4. The template list remains fully visible and interactive throughout
|
||||
- `reset()` clears exactly: `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`. Does NOT clear `selectedTemplate`, `categories`, `templates`, or any browsing state. Not called by `selectTemplate()` — that action handles its own inline clear. Exposed as a public store action for Phase 3 callers
|
||||
- `validate()`: if `selectedTemplate` is `null`, returns `true` immediately (nothing to validate). Otherwise iterates `(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters` (cast required — backend types `parameters_schema` as `dict`; see `ScriptTemplateDetail` type comment), checks `required && !paramValues[key]` for each, writes errors to `formErrors` via `set()`, returns `false` if any required param is missing, `true` otherwise. Client-side validation of `ScriptParameterValidation` fields (`pattern`, `min_length`, `max_length`, `min_value`, `max_value`) is NOT implemented in Phase 2 — only required-field presence is checked. The backend validates these constraints on `POST /scripts/generate` and returns errors in `detail`. Phase 2 does not render per-field errors from the backend — all backend validation errors surface as a single `generateError` below the action bar
|
||||
- `generate(sessionId?)`: if `selectedTemplate` is `null`, returns immediately (no-op). Otherwise calls `validate()` first — if it returns `false`, stops (errors are already in store). If valid, sets `isGenerating: true`, clears `generateError`, calls `POST /scripts/generate`. On success: sets `generatedScript`/`generationId`/`generationWarnings`, sets `isGenerating: false`. On error: extracts `error.response?.data?.detail` (FastAPI detail string) or falls back to `'Failed to generate script'`, sets `generateError`, sets `isGenerating: false`
|
||||
- On generate success: `generatedScript` = `response.script`, `generationId` = `response.id`, `generationWarnings` = `response.warnings`
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
Added to `frontend/src/types/index.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: [{value, label}]
|
||||
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` — FastAPI serializes it as plain JSON.
|
||||
// At runtime this arrives as `unknown`. Access via a helper:
|
||||
// function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] {
|
||||
// return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? [])
|
||||
// }
|
||||
parameters_schema: ScriptParametersSchema;
|
||||
default_values: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
|
||||
validation_rules: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ScriptGenerateRequest {
|
||||
template_id: string;
|
||||
parameters: Record<string, unknown>;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Client
|
||||
|
||||
**File:** `frontend/src/api/scripts.ts` — use a named export object (matching the `copilotApi`/`assistantChatApi` pattern in `api/index.ts`):
|
||||
|
||||
```typescript
|
||||
// scripts.ts
|
||||
export const scriptsApi = {
|
||||
getCategories(): Promise<ScriptCategoryResponse[]> { ... },
|
||||
getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise<ScriptTemplateListItem[]> { ... },
|
||||
getTemplateDetail(id: string): Promise<ScriptTemplateDetail> { ... },
|
||||
generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> { ... },
|
||||
getGenerations(): Promise<ScriptGenerationRecord[]> { ... },
|
||||
}
|
||||
```
|
||||
|
||||
Re-exported from `frontend/src/api/index.ts` as:
|
||||
|
||||
```typescript
|
||||
export { scriptsApi } from './scripts'
|
||||
```
|
||||
|
||||
All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh).
|
||||
|
||||
> `getGenerations()` and the `tags` param on `getTemplates` are Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a `// Phase 3` comment in the implementation.
|
||||
|
||||
---
|
||||
|
||||
## Component Tree
|
||||
|
||||
```text
|
||||
ScriptLibraryPage pages/ScriptLibraryPage.tsx
|
||||
├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx
|
||||
├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx
|
||||
│ └── TemplateCard components/scripts/TemplateCard.tsx
|
||||
└── ScriptGeneratorPanel components/scripts/ScriptGeneratorPanel.tsx
|
||||
├── ScriptParameterForm components/scripts/ScriptParameterForm.tsx
|
||||
│ └── ScriptParameterField components/scripts/ScriptParameterField.tsx
|
||||
└── ScriptPreview components/scripts/ScriptPreview.tsx
|
||||
└── PowerShellHighlighter components/scripts/PowerShellHighlighter.tsx
|
||||
```
|
||||
|
||||
### Component responsibilities
|
||||
|
||||
**`ScriptLibraryPage`**
|
||||
|
||||
- Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()`
|
||||
- Owns `inputValue` state (search input text) and `onClearSearch` callback — lifted here so `ScriptFilterBar` and `ScriptTemplateList` can coordinate clear-search without direct coupling
|
||||
- Renders `ScriptFilterBar` (passing `inputValue`, `setInputValue`, `onClearSearch`) above the two columns
|
||||
- Renders two-column layout: `ScriptTemplateList` (left, passing `inputValue` + `onClearSearch`) and `ScriptGeneratorPanel` (right)
|
||||
- Minimal logic — only the search state lift, everything else in the store or child components
|
||||
|
||||
**`ScriptFilterBar`**
|
||||
|
||||
- Renders an "All" tab first (calls `setCategory(null)`, active when `activeCategoryId === null`), followed by one tab per category
|
||||
- Category tabs: pill style, `bg-primary/10` + left 3px cyan accent bar on active tab
|
||||
- Receives `inputValue: string` and `setInputValue: (v: string) => void` as props from `ScriptLibraryPage` (state is lifted — `ScriptFilterBar` does NOT own local search state)
|
||||
- `<Input>` is controlled by `inputValue` prop. `useEffect` inside `ScriptFilterBar` watches `inputValue`, schedules `setTimeout(() => setSearch(inputValue), 300)`, clears timeout on cleanup — debounce lives here but the value lives in the page
|
||||
- Reads `categories`, `activeCategoryId` from store
|
||||
|
||||
**`ScriptTemplateList`**
|
||||
|
||||
- Scrollable list of `TemplateCard` components
|
||||
- Reads `templates`, `isLoadingTemplates`, `selectedTemplate` from store
|
||||
- Accepts `inputValue: string` and `onClearSearch: () => void` props from `ScriptLibraryPage`
|
||||
- Shows 3 skeleton placeholder cards while `isLoadingTemplates` is true
|
||||
- Shows "No templates found" empty state when `templates.length === 0` and `!isLoadingTemplates` and `inputValue === ''`
|
||||
- Shows "No templates match your search" + "Clear search" button when `templates.length === 0` and `!isLoadingTemplates` and `inputValue !== ''`. "Clear search" calls `onClearSearch()` — a callback prop from `ScriptLibraryPage` defined as `() => { setInputValue(''); store.setSearch(''); }`. Using `inputValue` (not `store.searchQuery`) avoids the 300ms debounce lag in empty-state detection
|
||||
|
||||
**`TemplateCard`**
|
||||
|
||||
- Displays: name, `complexity` badge, `usage_count`, description (2-line clamp via `line-clamp-2`), `tags`
|
||||
- Shows a `requires_elevation` warning icon (Lucide `ShieldAlert`, amber-400) if true
|
||||
- Active state when `template.id === selectedTemplate?.id`: `bg-primary/10` background + 3px left cyan gradient accent bar
|
||||
- Calls `selectTemplate(template.id)` on click
|
||||
- Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
|
||||
- Slug is already URL/filesystem-safe by backend convention — no sanitization needed
|
||||
|
||||
**`ScriptGeneratorPanel`**
|
||||
|
||||
- Shows placeholder (Terminal icon + "Select a template to get started") when `selectedTemplate` is null
|
||||
- Shows a full-panel centered spinner when `isLoadingDetail` is true, replacing the panel content (not an overlay — no prior template content shown while loading). The template list column remains fully visible and interactive
|
||||
- When template selected and `!isLoadingDetail`: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, action bar (in that order, top to bottom)
|
||||
- Visual order within the panel (top to bottom): warnings callout → `ScriptPreview` → action bar → error text
|
||||
- `generationWarnings` shown as amber-400 callout above the preview when `generationWarnings.length > 0`
|
||||
- Action bar (below preview):
|
||||
- Generate button (`bg-gradient-brand`) — calls `generate()` with no arguments (Phase 3 will pass `sessionId`); disabled when `isGenerating` OR `!canGenerate`; shows spinner while `isGenerating`
|
||||
- Download `.ps1` button — disabled when `generatedScript` is null OR `!canGenerate`; on click triggers `new Blob([generatedScript], { type: 'text/plain' })` → programmatic anchor click with `download="${selectedTemplate.slug}.ps1"`
|
||||
- `generateError` shown as rose-500 text inline below action bar
|
||||
- Permission check: call `usePermissions()` directly in this component; derive `canGenerate = isEngineer`. Pass `canGenerate` as a prop down to `ScriptParameterForm`. Generate and Download buttons disabled with tooltip "Engineer access required" when `!canGenerate`
|
||||
|
||||
**`ScriptParameterForm`**
|
||||
|
||||
- Accepts `canGenerate: boolean` prop from `ScriptGeneratorPanel`
|
||||
- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `order`. Access via cast: `(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters` — `parameters_schema` arrives as `dict` at runtime (backend types it as `dict`; helper comment in `ScriptTemplateDetail` type definition)
|
||||
- Groups by `group` field: renders a `font-label uppercase text-muted-foreground` section label before each group boundary (parameters with `group: null` rendered ungrouped at the top)
|
||||
- Renders a `ScriptParameterField` per parameter, passing `disabled={!canGenerate}` (converts `canGenerate` boolean to the `disabled` prop expected by `ScriptParameterField`)
|
||||
- Does NOT own the Generate button and does NOT call `generate()` or `validate()` directly — validation is triggered from `ScriptGeneratorPanel` via the store's `generate()` action (which calls `validate()` internally)
|
||||
|
||||
**`ScriptParameterField`**
|
||||
|
||||
- Accepts `param: ScriptParameter`, `value: string`, `error: string | undefined`, `disabled: boolean`
|
||||
- Renders input by `type`. Two error rendering approaches depending on type:
|
||||
- **Shared error rendering** — pass `error` prop to the shared component; it renders its own error message (do NOT add a separate `<p>` below):
|
||||
- `text` → `<Input error={error} />`
|
||||
- `password` → `<Input type="password" error={error} />` with Lucide `Eye`/`EyeOff` toggle
|
||||
- `textarea` → `<Textarea error={error} />`
|
||||
- `number` → `<Input type="number" error={error} />`
|
||||
- `multi_text` → `<Input placeholder="Comma-separated values" error={error} />`; stored as single string, backend splits on comma
|
||||
- **Manual error rendering** — no shared component; render error explicitly as `<p className="mt-1.5 text-xs text-red-400">{error}</p>` below the input:
|
||||
- `select` → native `<select 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={disabled}>`; options from `parameter.options`
|
||||
- `boolean` → `<input type="checkbox" className="rounded border-border" checked={value === 'true'} onChange={e => setParamValue(key, e.target.checked ? 'true' : 'false')} disabled={disabled} />`
|
||||
- Shows `help_text` as `<p className="text-xs text-muted-foreground mt-1">` below the input (before the error)
|
||||
- Calls `setParamValue(key, value)` on every change event; boolean uses `e.target.checked ? 'true' : 'false'`
|
||||
|
||||
**`ScriptPreview`**
|
||||
|
||||
- Two modes determined by `generatedScript` in store:
|
||||
- **Draft mode** (`generatedScript === null`): client-side `{{key}}` substitution in `selectedTemplate.script_body`. Sensitive params (`parameter.sensitive === true`) rendered as exactly four asterisks `****` regardless of `paramValues` value. Unfilled non-sensitive params left as `{{key}}` for `PowerShellHighlighter` to color amber
|
||||
- **Generated mode** (`generatedScript !== null`): shows `generatedScript`
|
||||
- The component computes `displayScript` as a local variable (the substituted preview string in draft mode, or `generatedScript` in generated mode). Both the render and the copy handler reference this same local variable — no separate store field needed
|
||||
- Copy icon (Lucide `Copy`, 14px) in top-right corner of code block, visible in both modes:
|
||||
- On click: calls `navigator.clipboard.writeText(displayScript)`. On success: shows "Copied!" for 2s then resets. On failure: silently fails — no error displayed, icon does not change state
|
||||
- Passes `displayScript` to `PowerShellHighlighter`
|
||||
|
||||
**`PowerShellHighlighter`**
|
||||
|
||||
- Pure component: `({ script: string }) => JSX.Element`
|
||||
- Single-pass tokenizer using a combined alternation regex (not sequential replace). The regex alternation matches tokens in priority order; each match is replaced with a `<span>` and the remaining string continues from after the match. This prevents a variable inside a string literal from being re-colored by the variable rule:
|
||||
|
||||
```text
|
||||
Priority order in alternation:
|
||||
1. Comments: /#[^\r\n]*/
|
||||
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|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/
|
||||
```
|
||||
|
||||
Note: variables (priority 4) consume tokens like `$foreach` before keywords (priority 7) can match — this is intentional. Do not reorder the alternation without reviewing this interaction.
|
||||
|
||||
Token colors:
|
||||
- Comments → `text-[#8b949e]`
|
||||
- String literals → `text-[#a5d6ff]`
|
||||
- Unfilled placeholders → `text-amber-400 underline decoration-dashed`
|
||||
- Variables → `text-[#79c0ff]`
|
||||
- Cmdlets → `text-[#22d3ee]`
|
||||
- Parameters → `text-[#d2a8ff]`
|
||||
- Keywords → `text-[#ff7b72]`
|
||||
- Unmatched text → unstyled
|
||||
|
||||
- Renders as `<pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"><code>`
|
||||
|
||||
---
|
||||
|
||||
## Routing & Navigation
|
||||
|
||||
- Route: `/scripts` added to `frontend/src/router.tsx` inside the `ProtectedRoute`/`AppLayout` children
|
||||
- Sidebar nav entry: "Scripts" with a `Terminal` icon (Lucide), added to `AppLayout.tsx` in the main nav group
|
||||
- No sub-routes needed for Phase 2
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Minimum role |
|
||||
| ------ | ------------ |
|
||||
| View Script Library page | Any authenticated user |
|
||||
| Browse templates, see draft preview | Any authenticated user |
|
||||
| Fill form, generate, copy, download | Engineer, owner, or super_admin |
|
||||
|
||||
`usePermissions()` is called once in `ScriptGeneratorPanel`. Derive `canGenerate = isEngineer` (`usePermissions().isEngineer` returns `true` for engineer, owner, and super_admin via the role hierarchy check — equivalent to `!isViewer` given the four-role system, but `isEngineer` is more semantically explicit). Pass `canGenerate` as a prop down to `ScriptParameterForm`. Leaf components (`ScriptParameterField`) receive `disabled` as a prop — they do not call `usePermissions()` directly.
|
||||
|
||||
Viewer-blocking is frontend-only in Phase 2 (backend role guard deferred — known limitation, documented in Architecture section).
|
||||
|
||||
**Tooltip implementation:** Use the HTML `title` attribute on the wrapper `<span>` around disabled buttons — consistent with the existing codebase pattern (e.g. `TopBar.tsx`, `NavItem.tsx`, `CheckoutButton.tsx`). Example: `<span title="Engineer access required"><button disabled ...>Generate</button></span>`.
|
||||
|
||||
---
|
||||
|
||||
## Search Behaviour
|
||||
|
||||
- Search query sent as `?search=` to `GET /scripts/templates`; matches `name`, `description`, `slug` (not tags)
|
||||
- 300ms debounce in `ScriptFilterBar`: local `inputValue` state, `useEffect` + `setTimeout`/`clearTimeout`
|
||||
- `setSearch` is called once after the debounce delay; it immediately calls `loadTemplates()`
|
||||
- Category filter and search compose: `loadTemplates()` sends both `category_slug` and `search` simultaneously
|
||||
- `inputValue` is owned by `ScriptLibraryPage` (lifted state). `ScriptLibraryPage` passes it as a prop to `ScriptFilterBar` (which controls the `<Input>`) and to `ScriptTemplateList` (which uses it to choose the correct empty-state variant). `onClearSearch` — also defined in `ScriptLibraryPage` as `() => { setInputValue(''); store.setSearch(''); }` — is passed to `ScriptTemplateList` for the "Clear search" button
|
||||
|
||||
---
|
||||
|
||||
## Empty & Loading States
|
||||
|
||||
| Scenario | Treatment |
|
||||
| -------- | --------- |
|
||||
| Templates loading | 3 skeleton TemplateCard placeholders (`isLoadingTemplates`) |
|
||||
| No templates in category | Empty state icon + "No templates found" |
|
||||
| No search results | "No templates match your search" + "Clear search" button |
|
||||
| Detail fetch in progress | Spinner in `ScriptGeneratorPanel` (`isLoadingDetail`); template list stays visible |
|
||||
| No template selected | Right panel: Terminal icon + "Select a template to get started" |
|
||||
| Generating | Generate button spinner + disabled (`isGenerating`) |
|
||||
| Generate error | Rose-500 inline text below action bar |
|
||||
| Warnings after generate | Amber-400 callout above preview, one line per warning |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (Phase 2)
|
||||
|
||||
- Session-embedded script generation (Script Output Node) — Phase 3
|
||||
- Tag filter UI — deferred (backend capability exists via `?tags=`)
|
||||
- Backend engineer role guard on generate endpoint — deferred (known gap)
|
||||
- Template creation/editing UI — admin-only, deferred
|
||||
- Generation history page — deferred
|
||||
- Admin template management — deferred
|
||||
- Script execution / RMM integration — long-term roadmap
|
||||
@@ -0,0 +1,239 @@
|
||||
# Script Library — Left Pane Takeover Design Spec
|
||||
|
||||
> **Created:** 2026-03-13
|
||||
> **Feature:** Redesign Script Library left pane to have two distinct modes: Browse and Configure
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Script Library left pane gains two distinct modes. In **Browse mode** the user sees the full template list with filter bar and a "Configure →" button on each card. Clicking "Configure →" transitions the entire left pane (including the filter bar) to **Configure mode**, which replaces the list with a full-height view of the selected template's header, parameter form, and action buttons (Generate, Download, Copy). The right pane becomes a **read-only preview** (`ScriptPreview` only — no form or buttons). "Back to library" returns to Browse mode with filter/search state preserved.
|
||||
|
||||
---
|
||||
|
||||
## Structural Change Summary
|
||||
|
||||
This is a cross-column relocation of the Generate/Download/Copy controls:
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Left pane: template list + filter bar | Left pane: Browse mode (list + filter) OR Configure mode (form + actions) |
|
||||
| Right pane: param form + action buttons + preview | Right pane: `ScriptPreview` only (read-only display) |
|
||||
|
||||
`ScriptGeneratorPanel` (which currently owns the right column's form, actions, and preview) is **deleted**. The right pane becomes `ScriptPreview` in isolation. The new `ScriptConfigurePane` component owns the left pane in Configure mode and contains the form + all action buttons.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Make template selection intentional (no accidental click-to-configure)
|
||||
- Give the parameter form more vertical space by using the full left pane height
|
||||
- Keep the output preview always visible on the right
|
||||
- Preserve filter/search state across Browse ↔ Configure transitions
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No changes to `ScriptPreview` internals — it moves to the right pane as-is, including its existing copy overlay button
|
||||
- No changes to the Zustand store shape or actions
|
||||
- No changes to the filter/search debounce logic
|
||||
- No changes to routing
|
||||
|
||||
---
|
||||
|
||||
## New Work (not pre-existing)
|
||||
|
||||
The **Copy button in the action bar** is new — it does not exist in the current `ScriptGeneratorPanel`. It copies `generatedScript` from the store. It is **disabled** when `generatedScript === null` (i.e., before the user has clicked Generate). The draft preview's copy needs are handled by `ScriptPreview`'s existing overlay copy button. No draft-substitution logic needs to be duplicated in `ScriptConfigurePane`.
|
||||
|
||||
---
|
||||
|
||||
## Left Pane — Two Modes
|
||||
|
||||
### Browse Mode
|
||||
|
||||
Rendered when `paneMode === 'browse'`.
|
||||
|
||||
The page header (`h1` "Script Library" + subtitle) stays at the top of `ScriptLibraryPage` above the two-column grid, visible in both pane modes — it is not affected by this change.
|
||||
|
||||
The current `ScriptLibraryPage` renders `ScriptFilterBar` at the page level above the two-column grid. In this redesign, the filter bar moves **inside the left pane column** so it can be hidden in Configure mode. `inputValue`/`setInputValue` remain owned by `ScriptLibraryPage` (not inside the left pane sub-tree) so the search text survives the unmount when the pane switches to Configure mode.
|
||||
|
||||
Layout (top to bottom, fills left pane height):
|
||||
|
||||
1. **Filter bar** (`ScriptFilterBar`) — category pills + search input
|
||||
2. **Template list** (`ScriptTemplateList`) — scrollable, fills remaining height
|
||||
|
||||
**TemplateCard changes:**
|
||||
- Root element changes from `<button>` to `<div>` — the card itself is no longer clickable
|
||||
- Remove active/selected visual state (`bg-primary/10 border-l-[3px]` etc.) — no card is "selected" in browse mode
|
||||
- Add **"Configure →"** button, right-aligned in the bottom row
|
||||
- Style: `bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors`
|
||||
- Uses unicode `→` (not a Lucide icon)
|
||||
- On click: calls `onConfigure(template.id)` prop
|
||||
- The rest of the card (name, description, tags, complexity badge) is non-interactive
|
||||
|
||||
**Updated `TemplateCard` props:**
|
||||
```tsx
|
||||
interface Props {
|
||||
template: ScriptTemplateListItem
|
||||
onConfigure: (id: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
`TemplateCard` also removes its `useScriptGeneratorStore` import entirely — it no longer reads `selectedTemplate` or `selectTemplate` from the store.
|
||||
|
||||
### Configure Mode
|
||||
|
||||
Rendered when `paneMode === 'configure'`.
|
||||
|
||||
The entire left pane (including filter bar) is replaced by the Configure view.
|
||||
|
||||
Layout (top to bottom, full pane height, `overflow-y-auto`):
|
||||
|
||||
1. **Back button**
|
||||
- Label: `← Back to library`
|
||||
- Style: `flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4`
|
||||
- On click: calls `onBack` prop → `ScriptLibraryPage` calls `store.clearOutput()` then `setPaneMode('browse')`
|
||||
- Does NOT clear `selectedTemplate` in the store (no such action exists — `selectTemplate` only accepts a string ID). `selectedTemplate` remains set in the store after returning to Browse mode. The right pane (`ScriptPreview`) continues showing the last preview while browsing — this is intentional.
|
||||
|
||||
2. **Template header** (visible when `isLoadingDetail === false`)
|
||||
- Name: `text-base font-semibold font-heading text-foreground`
|
||||
- Description (if present): `text-sm text-muted-foreground mt-0.5`
|
||||
- Tag row (left-to-right): `ShieldAlert` icon if `requires_elevation`, complexity badge, category name (resolved from `store.categories.find(c => c.id === selectedTemplate.category_id)?.name`), template tags (first 3, overflow as `+N`) — same badge/tag styles as current `TemplateCard`. If no matching category is found, omit the category name chip.
|
||||
- Separator: `border-t border-border mt-3 pt-3`
|
||||
|
||||
3. **`ScriptParameterForm`** — existing component, `canGenerate` prop unchanged
|
||||
|
||||
4. **Warnings callout** — shown above Generate when `generationWarnings.length > 0` (same amber box as current `ScriptGeneratorPanel`)
|
||||
|
||||
5. **Action bar**
|
||||
- **Generate** button: full-width, `bg-gradient-brand`, disabled/loading behavior same as current
|
||||
- **Download .ps1** + **Copy** buttons: side by side below Generate, each `flex-1`
|
||||
- The Copy button copies `store.generatedScript`. It is disabled when `generatedScript === null`. Draft copy is handled by `ScriptPreview`'s existing overlay — two copy entry-points is acceptable.
|
||||
- Error text below if `generateError` is set
|
||||
|
||||
**Loading state:** When `isLoadingDetail === true`, shows a centered `<Loader2 size={28} className="text-primary animate-spin" />` filling the pane instead of the template content.
|
||||
|
||||
---
|
||||
|
||||
## Right Pane
|
||||
|
||||
After the redesign, the right pane contains **only `ScriptPreview`**, wrapped in a `glass-card-static h-full` container.
|
||||
|
||||
Two sub-states:
|
||||
- **No template ever selected** (`selectedTemplate === null`): show empty state — Terminal icon + "Select a template to get started" text. **Important:** `ScriptPreview` returns `null` when `selectedTemplate` is null, so the empty state must be rendered by the right-pane wrapper in `ScriptLibraryPage`, not by `ScriptPreview` itself. Pattern:
|
||||
```tsx
|
||||
{selectedTemplate === null ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="glass-card-static h-full overflow-hidden p-4">
|
||||
<ScriptPreview />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
- **Template selected** (in either pane mode): show `ScriptPreview`
|
||||
|
||||
The right pane is always read-only — no form, no Generate/Download buttons.
|
||||
|
||||
**Layout note:** `ScriptPreview` renders a `<div className="relative">` at its root; the copy overlay button is `position: absolute, top-3, right-3` inside it. The right-pane wrapper must **not** use `overflow-y-auto` — if it did, the absolute copy button would scroll out of view on long scripts. Instead, the wrapper is `overflow-hidden` and `ScriptPreview`'s inner `<pre>` (inside `PowerShellHighlighter`) provides its own `overflow-x-auto` for wide scripts. The right pane itself does not need to scroll vertically — `PowerShellHighlighter` already handles horizontal overflow. No changes to `ScriptPreview` are needed.
|
||||
|
||||
The correct right-pane wrapper pattern:
|
||||
```tsx
|
||||
<div className="glass-card-static h-full overflow-hidden p-4">
|
||||
<ScriptPreview />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Right pane during initial detail load:** When the user clicks "Configure →" for the first time (`selectedTemplate === null`, `isLoadingDetail === true`), the right pane still shows the empty state (Terminal icon). The left pane's configure view shows the loading spinner. This is acceptable — the right pane updating when `isLoadingDetail` resolves is sufficient. No right-pane loading state is needed.
|
||||
|
||||
---
|
||||
|
||||
## State — Pane Mode
|
||||
|
||||
Pane mode is **local React state** in `ScriptLibraryPage`, not the Zustand store.
|
||||
|
||||
```tsx
|
||||
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
|
||||
```
|
||||
|
||||
Transitions:
|
||||
- `'browse' → 'configure'`: `onConfigure(id)` — calls `store.selectTemplate(id)` then `setPaneMode('configure')`
|
||||
- `'configure' → 'browse'`: `onBack()` — calls `store.clearOutput()` then `setPaneMode('browse')`
|
||||
|
||||
**`isLoadingDetail` drives configure pane content, not pane mode.** When the user clicks "Configure →":
|
||||
1. `selectTemplate(id)` is called (sets `isLoadingDetail: true` in store)
|
||||
2. `setPaneMode('configure')` is called immediately — user sees the loading spinner in configure mode
|
||||
|
||||
**`selectTemplate` failure case:** If `selectTemplate` throws (network error), the store resets `isLoadingDetail: false` but leaves `selectedTemplate` unchanged. The pane mode remains `'configure'`.
|
||||
|
||||
- **First-selection failure** (`selectedTemplate === null` before the call): `ScriptConfigurePane` must handle `!isLoadingDetail && !selectedTemplate` — render an error state: "Failed to load template." with a "← Back to library" link.
|
||||
- **Subsequent-selection failure** (`selectedTemplate` is still set from the previous template): the configure pane silently shows the previous template's form with stale data. This is an accepted edge case — network errors mid-session are rare and the user can press Back to recover. No special handling required.
|
||||
|
||||
**`paramValues` and `formErrors` on Back:** `clearOutput()` does not reset `paramValues` or `formErrors`. This is intentional — if the user returns to browse mode and then clicks "Configure →" on the same template again, `selectTemplate` will repopulate `paramValues` from defaults, discarding any edits. If they configure a different template, `selectTemplate` again repopulates from that template's defaults. There is no scenario where stale param values from a prior template persist into a new template's form. No additional cleanup is needed on Back.
|
||||
|
||||
**Filter/search preservation:** `inputValue` remains owned by `ScriptLibraryPage` at the page level. The filter bar unmounts in configure mode and remounts in browse mode with the same `inputValue`. Store's `activeCategoryId` and `searchQuery` are never cleared by pane transitions.
|
||||
|
||||
---
|
||||
|
||||
## Component Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/ScriptLibraryPage.tsx` | Add `paneMode` state; add `usePermissions` import for `canGenerate`; move `ScriptFilterBar` into left pane column; add `onConfigure`/`onBack` callbacks; render `ScriptConfigurePane` or browse content in left pane conditionally; render right pane as `ScriptPreview`-only with empty state |
|
||||
| `frontend/src/components/scripts/TemplateCard.tsx` | Root `<button>` → `<div>`; remove `onClick`/active-state; add `onConfigure: (id: string) => void` prop; add "Configure →" button |
|
||||
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Accept `onConfigure: (id: string) => void` prop; pass to each `TemplateCard`. `inputValue: string` and `onClearSearch: () => void` props are unchanged. |
|
||||
| `frontend/src/components/scripts/ScriptConfigurePane.tsx` | **New** — configure mode layout (back button, template header, `ScriptParameterForm`, warnings, action bar with Generate + Download + Copy) |
|
||||
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | **Delete** — superseded by `ScriptConfigurePane` and right-pane simplification |
|
||||
|
||||
No store changes. No API changes. No routing changes.
|
||||
|
||||
---
|
||||
|
||||
## Visual Spec
|
||||
|
||||
### Page layout (Configure mode active)
|
||||
|
||||
```
|
||||
┌─ left pane (320px) ────────────┬─ right pane (1fr) ──────────────────┐
|
||||
│ ← Back to library │ │
|
||||
│ │ [ScriptPreview — always visible] │
|
||||
│ Restart Windows Service │ │
|
||||
│ Stops and restarts a service │ # Restart Windows Service │
|
||||
│ 🛡 [Beginner] [Services] [win] │ param( │
|
||||
│ ───────────────────────────── │ $ServiceName = "{{service_name}}" │
|
||||
│ Service Name * │ ) [copy overlay] │
|
||||
│ [________________] │ │
|
||||
│ Target Computer │ │
|
||||
│ [localhost______] │ │
|
||||
│ Verify after restart [✓] │ │
|
||||
│ │ │
|
||||
│ [ Generate Script ] │ │
|
||||
│ [ Download .ps1 ] [ Copy ] │ │
|
||||
└────────────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### TemplateCard — Browse mode
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Restart Windows Service 🛡 [Beginner] │
|
||||
│ Stops and restarts a named service │
|
||||
│ 4× used [services] [windows] +1 [Configure →] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### `ScriptConfigurePane` props
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
canGenerate: boolean
|
||||
onBack: () => void
|
||||
}
|
||||
```
|
||||
|
||||
All other data read from Zustand store directly. `ScriptConfigurePane` derives `canGenerate` from props only — it does NOT call `usePermissions` internally. `ScriptLibraryPage` is the sole caller of `usePermissions` for this feature.
|
||||
|
||||
**Download filename:** `${selectedTemplate.slug}.ps1` — consistent with the current `ScriptGeneratorPanel` behavior.
|
||||
Reference in New Issue
Block a user