Merge pull request #33 from johndoe6345789/codex/refactor-playwrightdesigner-into-subcomponents

Refactor PlaywrightDesigner: split into subcomponents and externalize UI copy
This commit is contained in:
2026-01-18 00:27:27 +00:00
committed by GitHub
5 changed files with 384 additions and 242 deletions

View File

@@ -2,15 +2,12 @@
import { useState } from 'react'
import { PlaywrightTest, PlaywrightStep } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Trash, Play, Sparkle } from '@phosphor-icons/react'
import { Play, Plus } from '@phosphor-icons/react'
import { toast } from 'sonner'
import copy from '@/data/playwright-designer.json'
import { TestList } from '@/components/playwright-designer/TestList'
import { TestEditor } from '@/components/playwright-designer/TestEditor'
interface PlaywrightDesignerProps {
tests: PlaywrightTest[]
@@ -24,7 +21,7 @@ export function PlaywrightDesigner({ tests, onTestsChange }: PlaywrightDesignerP
const handleAddTest = () => {
const newTest: PlaywrightTest = {
id: `test-${Date.now()}`,
name: 'New Test',
name: copy.defaults.newTestName,
description: '',
pageUrl: '/',
steps: []
@@ -34,17 +31,15 @@ export function PlaywrightDesigner({ tests, onTestsChange }: PlaywrightDesignerP
}
const handleDeleteTest = (testId: string) => {
onTestsChange(tests.filter(t => t.id !== testId))
const remaining = tests.filter(test => test.id !== testId)
onTestsChange(remaining)
if (selectedTestId === testId) {
const remaining = tests.filter(t => t.id !== testId)
setSelectedTestId(remaining[0]?.id || null)
}
}
const handleUpdateTest = (testId: string, updates: Partial<PlaywrightTest>) => {
onTestsChange(
tests.map(t => t.id === testId ? { ...t, ...updates } : t)
)
onTestsChange(tests.map(test => (test.id === testId ? { ...test, ...updates } : test)))
}
const handleAddStep = () => {
@@ -63,270 +58,66 @@ export function PlaywrightDesigner({ tests, onTestsChange }: PlaywrightDesignerP
const handleUpdateStep = (stepId: string, updates: Partial<PlaywrightStep>) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.map(s => s.id === stepId ? { ...s, ...updates } : s)
steps: selectedTest.steps.map(step => (step.id === stepId ? { ...step, ...updates } : step))
})
}
const handleDeleteStep = (stepId: string) => {
if (!selectedTest) return
handleUpdateTest(selectedTest.id, {
steps: selectedTest.steps.filter(s => s.id !== stepId)
steps: selectedTest.steps.filter(step => step.id !== stepId)
})
}
const handleGenerateWithAI = async () => {
const description = prompt('Describe the E2E test you want to generate:')
const description = prompt(copy.prompts.describeTest)
if (!description) return
try {
toast.info('Generating test with AI...')
const promptText = `You are a Playwright test generator. Create an E2E test based on: "${description}"
Return a valid JSON object with a single property "test":
{
"test": {
"id": "unique-id",
"name": "Test Name",
"description": "What this test does",
"pageUrl": "/path",
"steps": [
{
"id": "step-id",
"action": "navigate" | "click" | "fill" | "expect" | "wait" | "select" | "check" | "uncheck",
"selector": "css selector or text",
"value": "value for fill/select actions",
"assertion": "expected value for expect action",
"timeout": 5000
}
]
}
}
Create a complete test flow with appropriate selectors and assertions.`
toast.info(copy.messages.generating)
const promptText = copy.prompts.template.replace('{description}', description)
const response = await window.spark.llm(promptText, 'gpt-4o', true)
const parsed = JSON.parse(response)
onTestsChange([...tests, parsed.test])
setSelectedTestId(parsed.test.id)
toast.success('Test generated successfully!')
toast.success(copy.messages.generated)
} catch (error) {
console.error(error)
toast.error('Failed to generate test')
toast.error(copy.messages.failed)
}
}
return (
<div className="h-full flex">
<div className="w-80 border-r border-border bg-card">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-sm">E2E Tests</h2>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={handleGenerateWithAI}>
<Sparkle size={14} weight="duotone" />
</Button>
<Button size="sm" onClick={handleAddTest}>
<Plus size={14} />
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="p-2 space-y-1">
{tests.map(test => (
<div
key={test.id}
className={`p-3 rounded-md cursor-pointer flex items-start justify-between group ${
selectedTestId === test.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
onClick={() => setSelectedTestId(test.id)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{test.name}</div>
<div className="text-xs text-muted-foreground truncate">{test.pageUrl}</div>
<div className="text-xs text-muted-foreground">{test.steps.length} steps</div>
</div>
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
handleDeleteTest(test.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{tests.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No tests yet. Click + to create one.
</div>
)}
</div>
</ScrollArea>
</div>
<TestList
tests={tests}
selectedTestId={selectedTestId}
onSelect={setSelectedTestId}
onAddTest={handleAddTest}
onDeleteTest={handleDeleteTest}
onGenerateWithAI={handleGenerateWithAI}
/>
<div className="flex-1 p-6">
{selectedTest ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Test Configuration</h2>
<Button variant="outline">
<Play size={16} className="mr-2" weight="fill" />
Run Test
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Test Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-name">Test Name</Label>
<Input
id="test-name"
value={selectedTest.name}
onChange={e => handleUpdateTest(selectedTest.id, { name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-description">Description</Label>
<Textarea
id="test-description"
value={selectedTest.description}
onChange={e => handleUpdateTest(selectedTest.id, { description: e.target.value })}
placeholder="What does this test verify?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-url">Page URL</Label>
<Input
id="test-url"
value={selectedTest.pageUrl}
onChange={e => handleUpdateTest(selectedTest.id, { pageUrl: e.target.value })}
placeholder="/login"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Test Steps</CardTitle>
<CardDescription>Define the actions for this test</CardDescription>
</div>
<Button size="sm" onClick={handleAddStep}>
<Plus size={14} className="mr-1" />
Add Step
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<div className="space-y-4">
{selectedTest.steps.map((step, index) => (
<Card key={step.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Step {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteStep(step.id)}
>
<Trash size={14} />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Action</Label>
<Select
value={step.action}
onValueChange={(value: any) => handleUpdateStep(step.id, { action: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="navigate">Navigate</SelectItem>
<SelectItem value="click">Click</SelectItem>
<SelectItem value="fill">Fill</SelectItem>
<SelectItem value="expect">Expect</SelectItem>
<SelectItem value="wait">Wait</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="uncheck">Uncheck</SelectItem>
</SelectContent>
</Select>
</div>
{step.action !== 'navigate' && step.action !== 'wait' && (
<div className="space-y-2">
<Label>Selector</Label>
<Input
value={step.selector || ''}
onChange={e => handleUpdateStep(step.id, { selector: e.target.value })}
placeholder="button, #login, [data-testid='submit']"
/>
</div>
)}
{(step.action === 'fill' || step.action === 'select') && (
<div className="space-y-2 col-span-2">
<Label>Value</Label>
<Input
value={step.value || ''}
onChange={e => handleUpdateStep(step.id, { value: e.target.value })}
placeholder="Text to enter"
/>
</div>
)}
{step.action === 'expect' && (
<div className="space-y-2 col-span-2">
<Label>Assertion</Label>
<Input
value={step.assertion || ''}
onChange={e => handleUpdateStep(step.id, { assertion: e.target.value })}
placeholder="toBeVisible(), toHaveText('...')"
/>
</div>
)}
{step.action === 'wait' && (
<div className="space-y-2">
<Label>Timeout (ms)</Label>
<Input
type="number"
value={step.timeout || 1000}
onChange={e => handleUpdateStep(step.id, { timeout: parseInt(e.target.value) })}
/>
</div>
)}
</div>
</CardContent>
</Card>
))}
{selectedTest.steps.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No steps yet. Click "Add Step" to create test actions.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
<TestEditor
test={selectedTest}
onAddStep={handleAddStep}
onUpdateTest={updates => handleUpdateTest(selectedTest.id, updates)}
onUpdateStep={handleUpdateStep}
onDeleteStep={handleDeleteStep}
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Play size={48} className="mx-auto mb-4 text-muted-foreground" />
<p className="text-lg font-medium mb-2">No test selected</p>
<p className="text-lg font-medium mb-2">{copy.emptyStates.noTestSelectedTitle}</p>
<p className="text-sm text-muted-foreground mb-4">
Create or select a test to configure
{copy.emptyStates.noTestSelectedBody}
</p>
<Button onClick={handleAddTest}>
<Plus size={16} className="mr-2" />
Create Test
{copy.buttons.createTest}
</Button>
</div>
</div>

View File

@@ -0,0 +1,103 @@
import { PlaywrightStep } from '@/types/project'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Trash } from '@phosphor-icons/react'
import copy from '@/data/playwright-designer.json'
interface StepEditorProps {
step: PlaywrightStep
index: number
onUpdate: (stepId: string, updates: Partial<PlaywrightStep>) => void
onDelete: (stepId: string) => void
}
const actionOptions = Object.entries(copy.actions)
export function StepEditor({ step, index, onUpdate, onDelete }: StepEditorProps) {
const showSelector = step.action !== 'navigate' && step.action !== 'wait'
const showValue = step.action === 'fill' || step.action === 'select'
const showAssertion = step.action === 'expect'
const showTimeout = step.action === 'wait'
return (
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
{copy.labels.stepPrefix} {index + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(step.id)}
>
<Trash size={14} />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{copy.fields.action}</Label>
<Select
value={step.action}
onValueChange={value => onUpdate(step.id, { action: value as PlaywrightStep['action'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{actionOptions.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showSelector && (
<div className="space-y-2">
<Label>{copy.fields.selector}</Label>
<Input
value={step.selector || ''}
onChange={e => onUpdate(step.id, { selector: e.target.value })}
placeholder={copy.placeholders.selector}
/>
</div>
)}
{showValue && (
<div className="space-y-2 col-span-2">
<Label>{copy.fields.value}</Label>
<Input
value={step.value || ''}
onChange={e => onUpdate(step.id, { value: e.target.value })}
placeholder={copy.placeholders.value}
/>
</div>
)}
{showAssertion && (
<div className="space-y-2 col-span-2">
<Label>{copy.fields.assertion}</Label>
<Input
value={step.assertion || ''}
onChange={e => onUpdate(step.id, { assertion: e.target.value })}
placeholder={copy.placeholders.assertion}
/>
</div>
)}
{showTimeout && (
<div className="space-y-2">
<Label>{copy.fields.timeout}</Label>
<Input
type="number"
value={step.timeout ?? 1000}
onChange={e => onUpdate(step.id, { timeout: Number.parseInt(e.target.value, 10) })}
/>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,107 @@
import { PlaywrightStep, PlaywrightTest } from '@/types/project'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Plus, Play } from '@phosphor-icons/react'
import copy from '@/data/playwright-designer.json'
import { StepEditor } from './StepEditor'
interface TestEditorProps {
test: PlaywrightTest
onAddStep: () => void
onUpdateTest: (updates: Partial<PlaywrightTest>) => void
onUpdateStep: (stepId: string, updates: Partial<PlaywrightStep>) => void
onDeleteStep: (stepId: string) => void
}
export function TestEditor({
test,
onAddStep,
onUpdateTest,
onUpdateStep,
onDeleteStep
}: TestEditorProps) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">{copy.headers.testConfiguration}</h2>
<Button variant="outline">
<Play size={16} className="mr-2" weight="fill" />
{copy.buttons.runTest}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>{copy.headers.testDetails}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-name">{copy.fields.testName}</Label>
<Input
id="test-name"
value={test.name}
onChange={e => onUpdateTest({ name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-description">{copy.fields.description}</Label>
<Textarea
id="test-description"
value={test.description}
onChange={e => onUpdateTest({ description: e.target.value })}
placeholder={copy.placeholders.description}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-url">{copy.fields.pageUrl}</Label>
<Input
id="test-url"
value={test.pageUrl}
onChange={e => onUpdateTest({ pageUrl: e.target.value })}
placeholder={copy.placeholders.pageUrl}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{copy.headers.testSteps}</CardTitle>
<CardDescription>{copy.descriptions.testSteps}</CardDescription>
</div>
<Button size="sm" onClick={onAddStep}>
<Plus size={14} className="mr-1" />
{copy.buttons.addStep}
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<div className="space-y-4">
{test.steps.map((step, index) => (
<StepEditor
key={step.id}
step={step}
index={index}
onUpdate={onUpdateStep}
onDelete={onDeleteStep}
/>
))}
{test.steps.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
{copy.emptyStates.noSteps}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { PlaywrightTest } from '@/types/project'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Plus, Sparkle, Trash } from '@phosphor-icons/react'
import copy from '@/data/playwright-designer.json'
interface TestListProps {
tests: PlaywrightTest[]
selectedTestId: string | null
onSelect: (testId: string) => void
onAddTest: () => void
onDeleteTest: (testId: string) => void
onGenerateWithAI: () => void
}
export function TestList({
tests,
selectedTestId,
onSelect,
onAddTest,
onDeleteTest,
onGenerateWithAI
}: TestListProps) {
return (
<div className="w-80 border-r border-border bg-card">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-sm">{copy.headers.tests}</h2>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={onGenerateWithAI}>
<Sparkle size={14} weight="duotone" />
</Button>
<Button size="sm" onClick={onAddTest}>
<Plus size={14} />
</Button>
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="p-2 space-y-1">
{tests.map(test => (
<div
key={test.id}
className={`p-3 rounded-md cursor-pointer flex items-start justify-between group ${
selectedTestId === test.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
}`}
onClick={() => onSelect(test.id)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{test.name}</div>
<div className="text-xs text-muted-foreground truncate">{test.pageUrl}</div>
<div className="text-xs text-muted-foreground">
{test.steps.length} {copy.labels.steps}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="opacity-0 group-hover:opacity-100"
onClick={event => {
event.stopPropagation()
onDeleteTest(test.id)
}}
>
<Trash size={14} />
</Button>
</div>
))}
{tests.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
{copy.emptyStates.noTests}
</div>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,65 @@
{
"actions": {
"navigate": "Navigate",
"click": "Click",
"fill": "Fill",
"expect": "Expect",
"wait": "Wait",
"select": "Select",
"check": "Check",
"uncheck": "Uncheck"
},
"buttons": {
"addStep": "Add Step",
"createTest": "Create Test",
"runTest": "Run Test"
},
"defaults": {
"newTestName": "New Test"
},
"descriptions": {
"testSteps": "Define the actions for this test"
},
"emptyStates": {
"noSteps": "No steps yet. Click \"Add Step\" to create test actions.",
"noTestSelectedBody": "Create or select a test to configure",
"noTestSelectedTitle": "No test selected",
"noTests": "No tests yet. Click + to create one."
},
"fields": {
"action": "Action",
"assertion": "Assertion",
"description": "Description",
"pageUrl": "Page URL",
"selector": "Selector",
"testName": "Test Name",
"timeout": "Timeout (ms)",
"value": "Value"
},
"headers": {
"testConfiguration": "Test Configuration",
"testDetails": "Test Details",
"testSteps": "Test Steps",
"tests": "E2E Tests"
},
"labels": {
"steps": "steps",
"stepPrefix": "Step"
},
"messages": {
"generated": "Test generated successfully!",
"generating": "Generating test with AI...",
"failed": "Failed to generate test"
},
"placeholders": {
"assertion": "toBeVisible(), toHaveText('...')",
"description": "What does this test verify?",
"pageUrl": "/login",
"selector": "button, #login, [data-testid='submit']",
"value": "Text to enter"
},
"prompts": {
"describeTest": "Describe the E2E test you want to generate:",
"template": "You are a Playwright test generator. Create an E2E test based on: \"{description}\"\n\nReturn a valid JSON object with a single property \"test\":\n{\n \"test\": {\n \"id\": \"unique-id\",\n \"name\": \"Test Name\",\n \"description\": \"What this test does\",\n \"pageUrl\": \"/path\",\n \"steps\": [\n {\n \"id\": \"step-id\",\n \"action\": \"navigate\" | \"click\" | \"fill\" | \"expect\" | \"wait\" | \"select\" | \"check\" | \"uncheck\",\n \"selector\": \"css selector or text\",\n \"value\": \"value for fill/select actions\",\n \"assertion\": \"expected value for expect action\",\n \"timeout\": 5000\n }\n ]\n }\n}\n\nCreate a complete test flow with appropriate selectors and assertions."
}
}