Revamp testing flows UI UX

This commit is contained in:
akhisud3195 2025-03-11 12:55:24 +05:30 committed by ramnique
parent 3ea08895b8
commit ff15e55c6d
15 changed files with 2097 additions and 1471 deletions

View file

@ -170,6 +170,7 @@ export async function createSimulation(
projectId: string,
data: {
name: string;
description?: string;
scenarioId: string;
profileId: string | null;
passCriteria: string;
@ -195,6 +196,7 @@ export async function updateSimulation(
simulationId: string,
updates: {
name?: string;
description?: string;
scenarioId?: string;
profileId?: string | null;
passCriteria?: string;
@ -268,7 +270,6 @@ export async function deleteProfile(projectId: string, profileId: string): Promi
await testProfilesCollection.deleteOne({
_id: new ObjectId(profileId),
projectId,
default: false,
});
}
@ -449,6 +450,15 @@ export async function updateRun(
);
}
export async function cancelRun(projectId: string, runId: string): Promise<void> {
await projectAuthCheck(projectId);
await testRunsCollection.updateOne(
{ _id: new ObjectId(runId), projectId },
{ $set: { status: 'cancelled' } }
);
}
export async function listResults(
projectId: string,
runId: string,
@ -510,6 +520,7 @@ export async function createResult(
simulationId: string;
result: 'pass' | 'fail';
details: string;
transcript: string;
}
): Promise<WithStringId<z.infer<typeof TestResult>>> {
await projectAuthCheck(projectId);
@ -545,3 +556,55 @@ export async function updateResult(
}
);
}
export async function getSimulationResult(
projectId: string,
runId: string,
simulationId: string
): Promise<WithStringId<z.infer<typeof TestResult>> | null> {
await projectAuthCheck(projectId);
const result = await testResultsCollection.findOne({
projectId,
runId,
simulationId
});
if (!result) {
return null;
}
const { _id, ...rest } = result;
return {
...rest,
_id: _id.toString(),
};
}
export async function listRunSimulations(
projectId: string,
simulationIds: string[]
): Promise<WithStringId<z.infer<typeof TestSimulation>>[]> {
await projectAuthCheck(projectId);
const simulations = await testSimulationsCollection
.find({
_id: { $in: simulationIds.map(id => new ObjectId(id)) },
projectId
})
.toArray();
// Fetch associated scenario and profile names
const enrichedSimulations = await Promise.all(simulations.map(async (simulation) => {
const scenario = simulation.scenarioId ? await testScenariosCollection.findOne({ _id: new ObjectId(simulation.scenarioId) }) : null;
const profile = simulation.profileId ? await testProfilesCollection.findOne({ _id: new ObjectId(simulation.profileId) }) : null;
return {
...simulation,
_id: simulation._id.toString(),
scenarioName: scenario?.name || 'Unknown',
profileName: profile?.name || 'None',
};
}));
return enrichedSimulations;
}

View file

@ -20,12 +20,13 @@ export const TestProfile = z.object({
export const TestSimulation = z.object({
projectId: z.string(),
name: z.string().min(1, "Name cannot be empty"),
name: z.string(),
description: z.string().optional().nullable(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
scenarioId: z.string(),
profileId: z.string().nullable(),
passCriteria: z.string(),
createdAt: z.string().datetime(),
lastUpdatedAt: z.string().datetime(),
});
export const TestRun = z.object({
@ -48,5 +49,6 @@ export const TestResult = z.object({
runId: z.string(),
simulationId: z.string(),
result: z.union([z.literal('pass'), z.literal('fail')]),
details: z.string()
details: z.string(),
transcript: z.string()
});

View file

@ -6,6 +6,8 @@ import { ProfilesApp } from "./profiles_app";
import { SimulationsApp } from "./simulations_app";
import { usePathname } from "next/navigation";
import { RunsApp } from "./runs_app";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function App({
projectId,
@ -43,21 +45,18 @@ export function App({
];
return <div className="flex h-full">
<div className="w-40 shrink-0 p-2">
<ul>
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<li key={item.label}>
<Link
className={`block p-2 rounded-md text-sm ${
pathname.startsWith(item.href)
? "bg-gray-100 dark:bg-neutral-800"
: "hover:bg-gray-100 dark:hover:bg-neutral-800"
}`}
href={item.href}>{item.label}</Link>
</li>
<ListItem
key={item.label}
name={item.label}
isSelected={pathname.startsWith(item.href)}
onClick={() => router.push(item.href)}
/>
))}
</ul>
</div>
</StructuredPanel>
<div className="grow border-l border-gray-200 dark:border-neutral-800 p-2">
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
{selection === "profiles" && <ProfilesApp projectId={projectId} slug={innerSlug} />}

View file

@ -0,0 +1,38 @@
// First, let's create a reusable component for item views
export function ItemView({
items,
actions
}: {
items: { label: string; value: string | React.ReactNode }[];
actions: React.ReactNode;
}) {
return (
<div className="max-w-3xl">
{/* Content */}
<div className="bg-white dark:bg-neutral-950 rounded-lg border border-gray-200 dark:border-neutral-800 overflow-hidden">
<div className="divide-y divide-gray-100 dark:divide-neutral-800">
{items.map((item, index) => (
<div
key={index}
className="px-6 py-4 flex flex-col gap-1"
>
<dt className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-neutral-400">
{item.label}
</dt>
<dd className="text-sm text-gray-900 dark:text-white">
{item.value || "—"}
</dd>
</div>
))}
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-neutral-900 border-t border-gray-200 dark:border-neutral-800">
<div className="flex gap-2">
{actions}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea, Switch } from "@heroui/react"
import { useRef, useState } from "react";
interface ProfileFormProps {
defaultValues?: {
name?: string;
context?: string;
mockTools?: boolean;
mockPrompt?: string;
};
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
}
export function ProfileForm({
defaultValues = {},
formRef,
handleSubmit,
onCancel,
submitButtonText,
}: ProfileFormProps) {
const [mockTools, setMockTools] = useState(Boolean(defaultValues.mockTools));
const [showMockPrompt, setShowMockPrompt] = useState(Boolean(defaultValues.mockTools));
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
label="Name"
name="name"
placeholder="Provide a name to describe the user's profile to simulate, e.g. &quot;Frequent buyer&quot;"
defaultValue={defaultValues.name}
isRequired
/>
<Textarea
label="Context"
name="context"
placeholder="Provide user info and other info to simulate, e.g. &quot;User's name: John Smith. Buying frequency: 10 orders a month. Location: US. Latest order: Pair of Jeans - XL.&quot;"
defaultValue={defaultValues.context}
isRequired
/>
<Switch
isSelected={mockTools}
onValueChange={(checked) => {
setMockTools(checked);
setShowMockPrompt(checked);
}}
name="mockTools"
value="on"
>
Mock Tools
</Switch>
{showMockPrompt && (
<div className="rounded-lg border border-gray-200 dark:border-neutral-800 p-4">
<div className="text-sm font-medium mb-2">Mock Prompt (Optional)</div>
<Textarea
name="mockPrompt"
placeholder="Enter a mock prompt"
defaultValue={defaultValues.mockPrompt}
/>
</div>
)}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,68 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
interface ScenarioFormProps {
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
};
}
export function ScenarioForm({
formRef,
handleSubmit,
onCancel,
submitButtonText,
defaultValues = {},
}: ScenarioFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
<Input
type="text"
name="name"
label="Name"
placeholder="Provide a name for this scenario, e.g. &quot;Order cancellation&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label="Description"
placeholder="Describe the scenario that should be simulated, e.g. &quot;Role play a user who wants to cancel their recently ordered pair of jeans.&quot;"
defaultValue={defaultValues.description}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,190 @@
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { Button, Input, Textarea } from "@heroui/react";
import { TestProfile, TestScenario } from "@/app/lib/types/testing_types";
import { WithStringId } from "@/app/lib/types/types";
import { ScenarioSelector } from "@/app/lib/components/selectors/scenario-selector";
import { ProfileSelector } from "@/app/lib/components/selectors/profile-selector";
import { z } from "zod";
interface SimulationFormProps {
formRef: React.RefObject<HTMLFormElement>;
handleSubmit: (formData: FormData) => Promise<void>;
scenario: WithStringId<z.infer<typeof TestScenario>> | null;
setScenario: (scenario: WithStringId<z.infer<typeof TestScenario>> | null) => void;
profile: WithStringId<z.infer<typeof TestProfile>> | null;
setProfile: (profile: WithStringId<z.infer<typeof TestProfile>> | null) => void;
isScenarioModalOpen: boolean;
setIsScenarioModalOpen: (isOpen: boolean) => void;
isProfileModalOpen: boolean;
setIsProfileModalOpen: (isOpen: boolean) => void;
projectId: string;
submitButtonText: string;
defaultValues?: {
name?: string;
description?: string;
passCriteria?: string;
};
onCancel: () => void;
}
export function SimulationForm({
formRef,
handleSubmit,
scenario,
setScenario,
profile,
setProfile,
isScenarioModalOpen,
setIsScenarioModalOpen,
isProfileModalOpen,
setIsProfileModalOpen,
projectId,
submitButtonText,
defaultValues = {},
onCancel,
}: SimulationFormProps) {
return (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4 p-4 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-sm font-medium">Basic Information</h2>
<Input
type="text"
name="name"
label={<span>Name</span>}
placeholder="Enter a name for the simulation, e.g. &quot;Frequent buyer cancelling order&quot;"
defaultValue={defaultValues.name}
isRequired
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
<Textarea
name="description"
label={<span>Description</span>}
placeholder="Enter an optional description for the simulation, just to help you remember what it's for"
defaultValue={defaultValues.description}
classNames={{
input: "bg-white dark:bg-neutral-900",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800"
}}
/>
</div>
{/* Test Configuration */}
<div className="flex flex-col gap-6 p-6 bg-white dark:bg-neutral-900 rounded-lg border border-gray-200 dark:border-neutral-800">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Test Configuration</h2>
<div className="flex flex-col gap-6">
{/* Scenario Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Scenario <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{scenario ? (
<span className="text-blue-600 dark:text-blue-400">{scenario.name}</span>
) : (
<span className="text-red-500">No scenario selected</span>
)}
</div>
<Button
size="sm"
onPress={() => setIsScenarioModalOpen(true)}
type="button"
>
{scenario ? "Change" : "Select"} Scenario
</Button>
</div>
</div>
{/* Profile Selection */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Profile <span className="text-gray-500 dark:text-neutral-400">(optional)</span>
</label>
<div className="flex items-center gap-1.5 min-h-[2rem]">
<div className="flex-1 text-sm text-gray-600 dark:text-neutral-400">
{profile ? (
<span className="text-blue-600 dark:text-blue-400">{profile.name}</span>
) : (
"No profile selected"
)}
</div>
<div className="flex gap-2">
{profile && (
<Button size="sm" variant="bordered" onClick={() => setProfile(null)}>
Remove
</Button>
)}
<Button
size="sm"
onPress={() => setIsProfileModalOpen(true)}
type="button"
>
{profile ? "Change" : "Select"} Profile
</Button>
</div>
</div>
</div>
{/* Pass Criteria */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Pass Criteria <span className="text-red-500">*</span>
</label>
<Textarea
name="passCriteria"
placeholder="Define the criteria for this test to pass, e.g. &quot;The assistant should successfully cancel the user's order and provide next steps for the user to confirm the cancellation&quot;"
defaultValue={defaultValues.passCriteria}
isRequired
minRows={3}
classNames={{
base: "w-full",
input: "bg-white dark:bg-neutral-900 resize-none",
inputWrapper: "bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:border-gray-300 dark:hover:border-neutral-700 transition-colors"
}}
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<FormStatusButton
props={{
children: submitButtonText,
size: "md",
color: "primary",
type: "submit",
isDisabled: !scenario,
className: "font-medium"
}}
/>
<Button
size="md"
variant="flat"
onPress={onCancel}
className="font-medium"
>
Cancel
</Button>
</div>
<ScenarioSelector
projectId={projectId}
isOpen={isScenarioModalOpen}
onOpenChange={setIsScenarioModalOpen}
onSelect={setScenario}
/>
<ProfileSelector
projectId={projectId}
isOpen={isProfileModalOpen}
onOpenChange={setIsProfileModalOpen}
onSelect={setProfile}
/>
</form>
);
}

View file

@ -0,0 +1,321 @@
import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Selection } from "@heroui/react";
import { Button } from "@heroui/react";
import { PencilIcon, TrashIcon, EyeIcon, DownloadIcon } from "lucide-react";
import Link from "next/link";
import { ReactNode, useState } from "react";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
// Helper function to safely parse dates
const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};
interface Column {
key: string;
label: string;
render?: (item: any) => ReactNode;
}
interface DataTableProps {
items: any[];
columns: Column[];
selectedKeys?: Selection;
onSelectionChange?: (keys: Selection) => void;
projectId: string;
onDelete?: (id: string) => Promise<void>;
onEdit?: (id: string) => void;
onView?: (id: string) => void;
onDownload?: (id: string) => void;
selectionMode?: "multiple" | "none";
}
export function DataTable({
items,
columns,
selectedKeys,
onSelectionChange,
projectId,
onDelete,
onEdit,
onView,
onDownload,
selectionMode = "multiple",
}: DataTableProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [isDeleteAllModalOpen, setIsDeleteAllModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDeleteClick = (id: string) => {
setItemToDelete(id);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete || !onDelete) return;
try {
await onDelete(itemToDelete);
setIsDeleteModalOpen(false);
setItemToDelete(null);
} catch (error) {
setDeleteError(`Failed to delete: ${error}`);
}
};
const handleDeleteAll = async () => {
if (!onDelete) return;
try {
// Delete all items sequentially
for (const item of items) {
await onDelete(item._id);
}
setIsDeleteAllModalOpen(false);
// Selection will be cleared automatically when items refresh
} catch (error) {
setDeleteError(`Failed to delete items: ${error}`);
}
};
const isAllSelected = selectedKeys === "all";
const renderCells = (item: any) => {
const cells = columns.map(column => (
<TableCell key={column.key}>
{column.render ? column.render(item) :
// Handle date fields specially
(column.key.toLowerCase().includes('date') ||
column.key === 'createdAt' ||
column.key === 'lastUpdatedAt') && isValidDate(item[column.key]) ?
new Date(item[column.key]).toLocaleString() :
item[column.key]
}
</TableCell>
));
// Only add actions column if there are any actions
const hasActions = onDelete || onEdit || onView || onDownload;
if (hasActions) {
cells.push(
<TableCell key="actions">
<div className="flex items-center gap-0.5">
{onView && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onView(item._id)}
aria-label="View item"
>
<EyeIcon size={16} />
</Button>
)}
{onEdit && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onEdit(item._id)}
aria-label="Edit item"
>
<PencilIcon size={16} />
</Button>
)}
{onDownload && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onDownload(item._id)}
aria-label="Download results"
>
<DownloadIcon size={16} />
</Button>
)}
{onDelete && (
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
onPress={() => handleDeleteClick(item._id)}
aria-label="Delete item"
>
<TrashIcon size={16} />
</Button>
)}
</div>
</TableCell>
);
}
return cells;
};
return (
<>
<div className="flex flex-col gap-4">
{/* Only show Delete All button when selection is enabled and items are selected */}
{selectionMode === "multiple" && selectedKeys === "all" && items.length > 0 && (
<div className="flex justify-start">
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteAllModalOpen(true)}
startContent={<TrashIcon size={16} />}
>
Delete All ({items.length})
</Button>
</div>
)}
<Table
selectedKeys={selectionMode === "multiple" ? selectedKeys : undefined}
onSelectionChange={selectionMode === "multiple" ? onSelectionChange : undefined}
aria-label="Data table"
classNames={{
base: "max-h-[400px] overflow-auto",
table: "min-w-full",
}}
selectionMode={selectionMode}
>
<TableHeader columns={[
...columns.map(column => ({
key: column.key,
label: column.label
})),
...((onDelete || onEdit || onView || onDownload) ? [{
key: 'actions',
label: 'ACTIONS',
render: (item: any) => (
<div className="flex items-center gap-0.5">
<Button
isIconOnly
size="sm"
variant="light"
>
<PencilIcon size={16} />
</Button>
<Button
isIconOnly
size="sm"
variant="light"
color="danger"
>
<TrashIcon size={16} />
</Button>
</div>
),
}] : [])
]}>
{(column) => (
<TableColumn key={column.key}>{column.label}</TableColumn>
)}
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item._id}>
{renderCells(item)}
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Single Delete Confirmation Modal */}
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setItemToDelete(null);
}}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this item?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteConfirm();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Delete All Confirmation Modal */}
<Modal
isOpen={isDeleteAllModalOpen}
onOpenChange={setIsDeleteAllModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Delete All</ModalHeader>
<ModalBody>
Are you sure you want to delete all {items.length} items? This action cannot be undone.
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDeleteAll();
onClose();
}}
>
Delete All
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* Error Modal */}
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}

View file

@ -1,8 +1,43 @@
import { App } from "./app";
'use client';
export default function Page({ params }: { params: { projectId: string, slug?: string[] } }) {
return <App
projectId={params.projectId}
slug={params.slug}
/>;
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ScenariosApp } from "./scenarios_app";
import { SimulationsApp } from "./simulations_app";
import { ProfilesApp } from "./profiles_app";
import { RunsApp } from "./runs_app";
import { TestingMenu } from "./testing_menu";
export default function TestPage({ params }: { params: { projectId: string; slug?: string[] } }) {
const { projectId, slug = [] } = params;
let app: "scenarios" | "simulations" | "profiles" | "runs" = "runs";
if (slug[0] === "scenarios") {
app = "scenarios";
} else if (slug[0] === "simulations") {
app = "simulations";
} else if (slug[0] === "profiles") {
app = "profiles";
} else if (slug[0] === "runs") {
app = "runs";
}
return (
<div className="h-full flex flex-col">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={15} minSize={10}>
<div className="h-full border-r border-gray-200 dark:border-neutral-800">
<TestingMenu projectId={projectId} app={app} />
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={85}>
{app === "scenarios" && <ScenariosApp projectId={projectId} slug={slug.slice(1)} />}
{app === "simulations" && <SimulationsApp projectId={projectId} slug={slug.slice(1)} />}
{app === "profiles" && <ProfilesApp projectId={projectId} slug={slug.slice(1)} />}
{app === "runs" && <RunsApp projectId={projectId} slug={slug.slice(1)} />}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View file

@ -1,15 +1,19 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestProfile } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Switch, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Tooltip } from "@heroui/react";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { PlusIcon, ArrowLeftIcon, StarIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { PlusIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { getProjectConfig } from "@/app/actions/project_actions";
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ProfileForm } from "./components/profile-form";
function EditProfile({
projectId,
@ -46,324 +50,96 @@ function EditProfile({
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = formData.get("mockPrompt") as string;
await updateProfile(projectId, profileId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
mockPrompt: mockTools && mockPrompt ? mockPrompt : undefined
});
router.push(`/projects/${projectId}/test/profiles/${profileId}`);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to update profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">Edit Profile</h1>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
return <StructuredPanel
title="EDIT PROFILE"
tooltip="Edit an existing test profile"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 dark:bg-red-900/20 p-2 rounded-md text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
Loading profile...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
{!loading && profile && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
defaultValue={profile.name}
required
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
defaultValue={profile.context}
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
defaultValue={profile.mockPrompt}
/>}
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}`}
>
Cancel
</Button>
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
</form>
)}
</div>;
{!loading && profile && (
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Update Profile"
defaultValues={{
name: profile.name,
context: profile.context,
mockTools: Boolean(profile.mockTools),
mockPrompt: profile.mockPrompt || ""
}}
/>
)}
</div>
</StructuredPanel>;
}
function ViewProfile({
projectId,
profileId,
}: {
projectId: string,
profileId: string,
}) {
const router = useRouter();
const [profile, setProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
const [loading, setLoading] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
async function fetchProfile() {
const profile = await getProfile(projectId, profileId);
setProfile(profile);
setLoading(false);
}
fetchProfile();
}, [projectId, profileId]);
async function handleDelete() {
try {
await deleteProfile(projectId, profileId);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setDeleteError(`Failed to delete profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">View Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Profiles
</Button>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !profile && <div className="text-gray-600 dark:text-neutral-400 text-center">Profile not found</div>}
{!loading && profile && (
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Name</div>
<div className="flex-[2] dark:text-neutral-200">{profile.name}</div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Context</div>
<div className="flex-[2] whitespace-pre-wrap dark:text-neutral-200">{profile.context}</div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Mock Tools</div>
<div className="flex-[2] dark:text-neutral-200">{profile.mockTools ? "Yes" : "No"}</div>
</div>
{profile.mockPrompt && (
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Mock Prompt</div>
<div className="flex-[2] whitespace-pre-wrap dark:text-neutral-200">{profile.mockPrompt}</div>
</div>
)}
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Created</div>
<div className="flex-[2] dark:text-neutral-300"><RelativeTime date={new Date(profile.createdAt)} /></div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Last Updated</div>
<div className="flex-[2] dark:text-neutral-300"><RelativeTime date={new Date(profile.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/profiles/${profileId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Confirm Deletion</ModalHeader>
<ModalBody>
Are you sure you want to delete this profile?
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
<Button
size="sm"
color="danger"
onPress={() => {
handleDelete();
onClose();
}}
>
Delete
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Error</ModalHeader>
<ModalBody>
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</div>;
}
function NewProfile({
projectId,
}: {
projectId: string,
}) {
function NewProfile({ projectId }: { projectId: string }) {
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [mockTools, setMockTools] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const context = formData.get("context") as string;
const mockPrompt = formData.get("mockPrompt") as string;
const profile = await createProfile(projectId, {
const mockTools = formData.get("mockTools") === "on";
const mockPrompt = mockTools ? (formData.get("mockPrompt") as string) : undefined;
await createProfile(projectId, {
name,
context,
mockTools,
mockPrompt: mockPrompt || undefined
mockPrompt // This will be undefined if mockTools is false
});
router.push(`/projects/${projectId}/test/profiles/${profile._id}`);
router.push(`/projects/${projectId}/test/profiles`);
} catch (error) {
setError(`Unable to create profile: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Profile</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/profiles`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
return <StructuredPanel
title="NEW PROFILE"
tooltip="Create a new test profile"
>
All Profiles
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the profile"
required
<div className="flex flex-col gap-6 max-w-2xl">
<ProfileForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
submitButtonText="Create Profile"
/>
<Textarea
name="context"
label="Context"
placeholder="Enter the context for this profile"
required
/>
<Switch
name="mockTools"
isSelected={mockTools}
onValueChange={(value) => {
setMockTools(value);
}}
className="self-start"
>
Mock Tools
</Switch>
{mockTools && <Textarea
name="mockPrompt"
label="Mock Prompt (Optional)"
placeholder="Enter a mock prompt"
/>}
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
</div>
</StructuredPanel>;
}
function ProfileList({
@ -379,6 +155,8 @@ function ProfileList({
const [error, setError] = useState<string | null>(null);
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
@ -412,95 +190,154 @@ function ProfileList({
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">Profiles</h1>
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedProfiles([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedProfiles(profiles.map(profile => profile._id));
} else {
setSelectedProfiles(Array.from(selection as Set<string>));
}
}
};
const handleDelete = async (profileId: string) => {
try {
await deleteProfile(projectId, profileId);
// Refresh the profiles list after deletion
const result = await listProfiles(projectId, page, pageSize);
setProfiles(result.profiles);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete profile: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (profile: any) => profile.name
},
{
key: 'context',
label: 'CONTEXT'
},
{
key: 'mockTools',
label: 'MOCK TOOLS',
render: (profile: any) => profile.mockTools ? "Yes" : "No"
},
{
key: 'createdAt',
label: 'CREATED',
render: (profile: any) => profile?.createdAt && isValidDate(profile.createdAt) ?
<RelativeTime date={new Date(profile.createdAt)} /> :
'Invalid date'
},
{
key: 'lastUpdatedAt',
label: 'LAST UPDATED',
render: (profile: any) => profile?.lastUpdatedAt && isValidDate(profile.lastUpdatedAt) ?
<RelativeTime date={new Date(profile.lastUpdatedAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="PROFILES"
tooltip="View and manage your test profiles"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Profiles</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test profiles for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Profile
</Button>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 dark:bg-red-900/20 p-2 rounded-md text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{profiles.length === 0 && <div className="text-gray-600 dark:text-neutral-400 text-center">No profiles found</div>}
{profiles.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-8 py-2 bg-gray-100 dark:bg-neutral-800 font-semibold text-sm">
<div className="col-span-2 px-4 dark:text-neutral-300">Name</div>
<div className="col-span-3 px-4 dark:text-neutral-300">Context</div>
<div className="col-span-1 px-4 dark:text-neutral-300">Mock Tools</div>
<div className="col-span-1 px-4 dark:text-neutral-300">Created</div>
<div className="col-span-1 px-4 dark:text-neutral-300">Updated</div>
</div>
)}
{/* Rows */}
{profiles.map((profile) => (
<div key={profile._id} className="grid grid-cols-8 py-2 border-b border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-800 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/profiles/${profile._id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
{/* Profiles Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400 mb-4">No profiles created yet</p>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
>
{profile.name}
</Link>
Create Your First Profile
</Button>
</div>
<div className="col-span-3 px-4 truncate dark:text-neutral-300">{profile.context}</div>
<div className="col-span-1 px-4 dark:text-neutral-300">{profile.mockTools ? "Yes" : "No"}</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
<RelativeTime date={new Date(profile.createdAt)} />
) : (
<DataTable
items={profiles}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/profiles/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
<RelativeTime date={new Date(profile.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/profiles?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
</StructuredPanel>;
}
export function ProfilesApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let profileId: string | null = null;
if (slug.length > 0) {
export function ProfilesApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let profileId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
} else if (slug[1] === "edit") {
selection = "edit";
profileId = slug[0];
} else {
selection = "view";
selection = "list";
profileId = slug[0];
}
}
return <>
return (
<div className="h-full">
{selection === "list" && <ProfileList projectId={projectId} />}
{selection === "new" && <NewProfile projectId={projectId} />}
{selection === "view" && profileId && <ViewProfile projectId={projectId} profileId={profileId} />}
{selection === "edit" && profileId && <EditProfile projectId={projectId} profileId={profileId} />}
</>;
{selection === "edit" && profileId && (
<EditProfile projectId={projectId} profileId={profileId} />
)}
</div>
);
}
export { NewProfile, EditProfile };

View file

@ -1,149 +1,20 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createRun, getRun, getSimulation, listRuns } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Chip } from "@heroui/react";
import { useEffect, useState } from "react";
import { getRun, getSimulation, listRuns, cancelRun, deleteRun, getSimulationResult, listRunSimulations } from "@/app/actions/testing_actions";
import { Button, Spinner, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon, WorkflowIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { ArrowLeftIcon, PlusIcon, DownloadIcon } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { SimulationSelector } from "@/app/lib/components/selectors/simulation-selector";
import { WorkflowSelector } from "@/app/lib/components/selectors/workflow-selector";
import { Workflow } from "@/app/lib/types/workflow_types";
import { fetchWorkflow } from "@/app/actions/workflow_actions";
function NewRun({
projectId,
}: {
projectId: string,
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const [selectedSimulations, setSelectedSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [isSimulationSelectorOpen, setIsSimulationSelectorOpen] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [isWorkflowSelectorOpen, setIsWorkflowSelectorOpen] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
const simulationIds = selectedSimulations.map(sim => sim._id);
if (!selectedWorkflow) {
setError("Please select a workflow");
return;
}
if (simulationIds.length === 0) {
setError("Please select at least one simulation");
return;
}
try {
const run = await createRun(projectId, {
workflowId: selectedWorkflow._id,
simulationIds
});
router.push(`/projects/${projectId}/test/runs/${run._id}`);
} catch (error) {
setError(`Unable to create run: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Run</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
>
All Runs
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onPress={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Workflow</label>
<div className="flex items-center gap-2">
{selectedWorkflow ? (
<div className="text-sm text-blue-600">{selectedWorkflow.name}</div>
) : (
<div className="text-sm text-gray-500">No workflow selected</div>
)}
<Button
size="sm"
onPress={() => setIsWorkflowSelectorOpen(true)}
type="button"
>
{selectedWorkflow ? "Change" : "Select"} Workflow
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
onPress={() => setIsSimulationSelectorOpen(true)}
type="button"
className="self-start"
>
Select Simulations
</Button>
{selectedSimulations.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSimulations.map((sim) => (
<Chip
key={sim._id}
onClose={() => setSelectedSimulations(prev => prev.filter(s => s._id !== sim._id))}
variant="flat"
className="py-1"
>
{sim.name}
</Chip>
))}
</div>
)}
</div>
<FormStatusButton
props={{
className: "self-start",
children: "Create Run",
size: "sm",
type: "submit",
isDisabled: !selectedWorkflow || selectedSimulations.length === 0,
}}
/>
</form>
<SimulationSelector
projectId={projectId}
isOpen={isSimulationSelectorOpen}
onOpenChange={setIsSimulationSelectorOpen}
onSelect={setSelectedSimulations}
initialSelected={selectedSimulations}
/>
<WorkflowSelector
projectId={projectId}
isOpen={isWorkflowSelectorOpen}
onOpenChange={setIsWorkflowSelectorOpen}
onSelect={setSelectedWorkflow}
/>
</div>;
}
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel"
import { DataTable } from "./components/table"
import { isValidDate } from './utils/date';
function ViewRun({
projectId,
@ -152,49 +23,124 @@ function ViewRun({
projectId: string,
runId: string,
}) {
const router = useRouter();
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
const [loading, setLoading] = useState(true);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
const [simulations, setSimulations] = useState<WithStringId<z.infer<typeof TestSimulation>>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
useEffect(() => {
async function fetchRun() {
async function fetchData() {
try {
const run = await getRun(projectId, runId);
if (!run) {
setError("Run not found");
return;
}
setRun(run);
if (run) {
const enrichedSimulations = await listRunSimulations(projectId, run.simulationIds);
setSimulations(enrichedSimulations);
// Fetch workflow and simulations in parallel
const [workflowResult, simulationsResult] = await Promise.all([
fetchWorkflow(projectId, run.workflowId),
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
]);
setWorkflow(workflowResult);
setSimulations(simulationsResult.filter(s => s !== null));
}
} catch (error) {
setError(`Error fetching run: ${error}`);
} finally {
setLoading(false);
}
fetchRun();
}, [runId, projectId]);
}
fetchData();
}, [projectId, runId]);
return <div className="h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/runs`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
const columns = [
{
key: 'name',
label: 'SIMULATION',
render: (simulation: any) => simulation.name
},
{
key: 'scenarioId',
label: 'SCENARIO',
render: (simulation: any) => simulation.scenarioName
},
{
key: 'profileId',
label: 'PROFILE',
render: (simulation: any) => simulation.profileName
}
];
const handleDownload = async (simulationId: string) => {
try {
const result = await getSimulationResult(projectId, runId, simulationId);
if (!result) {
console.error("No result found for simulation");
return;
}
// Get simulation name from simulations array
const simulation = simulations.find(s => s._id === simulationId);
if (!simulation) {
console.error("Simulation not found");
return;
}
// Create a safe filename
const safeName = `${run?.name}_${simulation.name}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
// Create the JSON content
const content = {
run: run?.name,
simulation: simulation.name,
result: result.result,
details: result.details,
transcript: result.transcript
};
// Create and trigger download
const blob = new Blob([JSON.stringify(content, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${safeName}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download result:", error);
}
};
return <StructuredPanel
title="VIEW RUN"
tooltip="View details of this test run"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/runs`)}
>
All Runs
</Button>
</div>
</ActionButton>
]}
>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
{!loading && run && (
<>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4">
{workflow && (
@ -236,31 +182,22 @@ function ViewRun({
</div>
{/* Simulations List */}
<div className="mt-4">
<div>
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
<div className="space-y-2">
{simulations.map(sim => (
<div key={sim._id} className="border dark:border-neutral-800 rounded-lg p-3">
<Link
href={`/projects/${projectId}/test/simulations/${sim._id}`}
className="text-blue-600 hover:underline"
>
{sim.name}
</Link>
</div>
))}
<DataTable
items={simulations}
columns={columns}
projectId={projectId}
onDownload={handleDownload}
selectionMode="none"
/>
</div>
</div>
</>
)}
</div>;
</StructuredPanel>
}
function RunList({
projectId,
}: {
projectId: string,
}) {
function RunsList({ projectId }: { projectId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
@ -270,6 +207,54 @@ function RunList({
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedRuns, setSelectedRuns] = useState<string[]>([]);
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedRuns([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedRuns(runs.map(run => run._id));
} else {
setSelectedRuns(Array.from(selection as Set<string>));
}
}
};
const handleCancel = async (runId: string) => {
try {
await cancelRun(projectId, runId);
// Update the run status locally after successful cancellation
setRuns(runs.map(run => {
if (run._id === runId) {
return {
...run,
status: 'cancelled'
};
}
return run;
}));
} catch (err) {
setError(`Failed to cancel run: ${err}`);
}
};
const handleDelete = async (runId: string) => {
try {
await deleteRun(projectId, runId);
// Refresh the runs list after deletion
const updatedRuns = await listRuns(projectId, page, pageSize);
setRuns(updatedRuns.runs);
setTotal(updatedRuns.total);
} catch (err) {
setError(`Failed to delete run: ${err}`);
}
};
useEffect(() => {
let ignore = false;
@ -333,101 +318,130 @@ function RunList({
};
}, [runs, error, projectId]);
return <div className="h-full flex flex-col gap-4">
const columns = [
{
key: 'name',
label: 'NAME',
render: (run: any) => run.name
},
{
key: 'status',
label: 'STATUS',
render: (run: any) => (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusStyles(run.status)}`}>
<div className={`w-1.5 h-1.5 rounded-full ${getStatusDotStyles(run.status)}`} />
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</div>
)
},
{
key: 'results',
label: 'RESULTS',
render: (run: any) => (
<div className="flex items-center gap-2">
<span className="text-green-600 dark:text-green-400">{run.passCount || 0} passed</span>
<span className="text-red-600 dark:text-red-400">{run.failCount || 0} failed</span>
</div>
)
},
{
key: 'createdAt',
label: 'STARTED',
render: (run: any) => isValidDate(run.startedAt) ?
<RelativeTime date={new Date(run.startedAt)} /> :
'Invalid date'
}
];
return (
<StructuredPanel
title="TEST RUNS"
tooltip="View and manage your test runs"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">Test Runs</h1>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Test Runs</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
View and monitor your workflow test runs
</p>
</div>
<Button
size="sm"
onPress={() => router.push(`/projects/${projectId}/test/runs/new`)}
startContent={<PlusIcon className="w-4 h-4" />}
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
>
New Run
</Button>
</div>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{runs.length === 0 && <div className="text-gray-600 dark:text-neutral-400 text-center">No test runs found</div>}
{runs.length > 0 && <div className="space-y-4">
{runs.map((run) => (
<div key={run._id} className="border dark:border-neutral-800 rounded-lg shadow-sm">
<div className="p-4 flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800">
<div className="flex items-center space-x-4">
<Link
href={`/projects/${projectId}/test/runs/${run._id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
</div>
)}
{/* Runs Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading test runs...
</div>
) : runs.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400 mb-4">No test runs created yet</p>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
>
{run.name}
</Link>
{workflowMap[run.workflowId] && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-neutral-400">
<WorkflowIcon className="w-4 h-4 shrink-0" />
{workflowMap[run.workflowId].name}
Create Your First Test Run
</Button>
</div>
) : (
<DataTable
items={runs}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
onDelete={handleDelete}
onView={(id) => router.push(`/projects/${projectId}/test/runs/${id}`)}
projectId={projectId}
/>
)}
</div>
<div className="flex items-center gap-4">
<span className={getStatusClass(run.status)}>
{run.status}
</span>
<div className="text-sm text-gray-600 dark:text-neutral-400">
<RelativeTime date={new Date(run.startedAt)} />
</div>
</div>
</div>
{run.aggregateResults && (
<div className="border-t dark:border-neutral-800 px-4 py-2 bg-gray-50 dark:bg-neutral-900/50">
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-gray-600 dark:text-neutral-400">
Total: {run.aggregateResults.total}
</div>
<div className="text-green-600 dark:text-green-400">
Passed: {run.aggregateResults.passCount}
</div>
<div className="text-red-600 dark:text-red-400">
Failed: {run.aggregateResults.failCount}
</div>
</div>
</div>
)}
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/runs?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
</StructuredPanel>
);
}
// Helper function for status styling
function getStatusClass(status: string) {
const baseClass = "px-2 py-1 rounded text-xs uppercase font-medium";
switch (status) {
case 'completed':
return `${baseClass} bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400`;
case 'failed':
case 'error':
return `${baseClass} bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-400`;
case 'cancelled':
return `${baseClass} bg-gray-100 dark:bg-neutral-800 text-gray-800 dark:text-neutral-400`;
case 'running':
case 'pending':
default:
return `${baseClass} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400`;
}
// Helper functions for status styling
function getStatusStyles(status: string): string {
const styles = {
pending: "bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300",
running: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
completed: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
cancelled: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
error: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
};
return styles[status as keyof typeof styles] || styles.pending;
}
function getStatusDotStyles(status: string): string {
const styles = {
pending: "bg-gray-500 dark:bg-neutral-400",
running: "bg-blue-500 dark:bg-blue-400",
completed: "bg-green-500 dark:bg-green-400",
cancelled: "bg-yellow-500 dark:bg-yellow-400",
failed: "bg-red-500 dark:bg-red-400",
error: "bg-red-500 dark:bg-red-400"
};
return styles[status as keyof typeof styles] || styles.pending;
}
export function RunsApp({
@ -437,20 +451,15 @@ export function RunsApp({
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" = "list";
let selection: "list" | "view" = "list";
let runId: string | null = null;
if (slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else {
selection = "view";
runId = slug[0];
}
}
return <>
{selection === "list" && <RunList projectId={projectId} />}
{selection === "new" && <NewRun projectId={projectId} />}
{selection === "list" && <RunsList projectId={projectId} />}
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
</>;
}

View file

@ -1,14 +1,20 @@
"use client";
import Link from "next/link";
import { WithStringId } from "@/app/lib/types/types";
import { TestScenario } from "@/app/lib/types/testing_types";
import { useEffect, useState, useRef } from "react";
import { createScenario, getScenario, listScenarios, updateScenario, deleteScenario } from "@/app/actions/testing_actions";
import { Button, Input, Pagination, Spinner, Textarea, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Selection } from "@heroui/react";
import { useRouter, useSearchParams } from "next/navigation";
import { z } from "zod";
import { ArrowLeftIcon, PlusIcon } from "lucide-react";
import { FormStatusButton } from "@/app/lib/components/form-status-button";
import { ArrowLeftIcon, PlusIcon, } from "lucide-react";
import { RelativeTime } from "@primer/react"
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel";
import { DataTable } from "./components/table";
import { isValidDate } from './utils/date';
import { ItemView } from "./components/item-view"
import { ScenarioForm } from "./components/scenario-form";
function EditScenario({
projectId,
@ -44,60 +50,45 @@ function EditScenario({
const name = formData.get("name") as string;
const description = formData.get("description") as string;
await updateScenario(projectId, scenarioId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenarioId}`);
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to update scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">Edit Scenario</h1>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
return <StructuredPanel
title="EDIT SCENARIO"
tooltip="Edit an existing test scenario"
>
<div className="flex flex-col gap-6 max-w-2xl">
{loading && (
<div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 dark:bg-red-900/20 p-2 rounded-md text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
Loading scenario...
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
</div>}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
{!loading && scenario && (
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
defaultValue={scenario.name}
required
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
defaultValue={scenario.description}
required
/>
<div className="flex gap-2 items-center">
<FormStatusButton
props={{
className: "self-start",
children: "Update",
size: "sm",
type: "submit",
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Update Scenario"
defaultValues={{
name: scenario.name,
description: scenario.description
}}
/>
<Button
size="sm"
variant="flat"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}`}
>
Cancel
</Button>
</div>
</form>
)}
</div>;
</div>
</StructuredPanel>;
}
function ViewScenario({
@ -131,60 +122,44 @@ function ViewScenario({
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">View Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
return (
<StructuredPanel
title="VIEW SCENARIO"
tooltip="View scenario details"
actions={[
<ActionButton
key="back"
icon={<ArrowLeftIcon size={16} />}
onClick={() => router.push(`/projects/${projectId}/test/scenarios`)}
>
All Scenarios
</Button>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{!loading && !scenario && <div className="text-gray-600 dark:text-neutral-400 text-center">Scenario not found</div>}
{!loading && scenario && (
</ActionButton>
]}
>
<ItemView
items={[
{ label: "Name", value: scenario?.name },
{ label: "Description", value: scenario?.description },
{
label: "Created",
value: scenario?.createdAt && isValidDate(scenario.createdAt)
? <RelativeTime date={new Date(scenario.createdAt)} />
: 'Invalid date'
},
{
label: "Last Updated",
value: scenario?.lastUpdatedAt && isValidDate(scenario.lastUpdatedAt)
? <RelativeTime date={new Date(scenario.lastUpdatedAt)} />
: 'Invalid date'
}
]}
actions={
<>
<div className="flex flex-col gap-1 text-sm">
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Name</div>
<div className="flex-[2] dark:text-neutral-200">{scenario.name}</div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Description</div>
<div className="flex-[2] dark:text-neutral-200">{scenario.description}</div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Created</div>
<div className="flex-[2] dark:text-neutral-300"><RelativeTime date={new Date(scenario.createdAt)} /></div>
</div>
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
<div className="flex-[1] font-medium text-gray-600 dark:text-neutral-400">Last Updated</div>
<div className="flex-[2] dark:text-neutral-300"><RelativeTime date={new Date(scenario.lastUpdatedAt)} /></div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
as={Link}
href={`/projects/${projectId}/test/scenarios/${scenarioId}/edit`}
>
Edit
</Button>
<Button
size="sm"
color="danger"
variant="flat"
onPress={() => setIsDeleteModalOpen(true)}
>
Delete
</Button>
</div>
<Button size="sm" variant="flat" onPress={() => router.push(`/projects/${projectId}/test/scenarios/${scenarioId}/edit`)}>Edit</Button>
<Button size="sm" color="danger" variant="flat" onPress={() => setIsDeleteModalOpen(true)}>Delete</Button>
</>
}
/>
<Modal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
@ -216,7 +191,6 @@ function ViewScenario({
)}
</ModalContent>
</Modal>
<Modal
isOpen={deleteError !== null}
onOpenChange={() => setDeleteError(null)}
@ -230,11 +204,7 @@ function ViewScenario({
{deleteError}
</ModalBody>
<ModalFooter>
<Button
size="sm"
color="primary"
onPress={onClose}
>
<Button size="sm" onPress={onClose}>
Close
</Button>
</ModalFooter>
@ -242,9 +212,8 @@ function ViewScenario({
)}
</ModalContent>
</Modal>
</>
)}
</div>;
</StructuredPanel>
);
}
function NewScenario({
@ -258,63 +227,36 @@ function NewScenario({
async function handleSubmit(formData: FormData) {
setError(null);
try {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
try {
const scenario = await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios/${scenario._id}`);
await createScenario(projectId, { name, description });
router.push(`/projects/${projectId}/test/scenarios`);
} catch (error) {
setError(`Unable to create scenario: ${error}`);
}
}
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Scenario</h1>
<Button
size="sm"
className="self-start"
as={Link}
href={`/projects/${projectId}/test/scenarios`}
startContent={<ArrowLeftIcon className="w-4 h-4" />}
return <StructuredPanel
title="NEW SCENARIO"
tooltip="Create a new test scenario"
>
All Scenarios
</Button>
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
<div className="flex flex-col gap-6 max-w-2xl">
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button
size="sm"
color="danger"
onPress={() => {
formRef.current?.requestSubmit();
}}
>
Retry
</Button>
</div>}
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
name="name"
label="Name"
placeholder="Enter a name for the scenario"
required
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>
)}
<ScenarioForm
formRef={formRef}
handleSubmit={handleSubmit}
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
submitButtonText="Create Scenario"
/>
<Textarea
name="description"
label="Description"
placeholder="Enter a description for the scenario"
required
/>
<FormStatusButton
props={{
className: "self-start",
children: "Create",
size: "sm",
type: "submit",
}}
/>
</form>
</div>;
</div>
</StructuredPanel>;
}
function ScenarioList({
@ -330,6 +272,8 @@ function ScenarioList({
const [error, setError] = useState<string | null>(null);
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
const [total, setTotal] = useState(0);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
useEffect(() => {
let ignore = false;
@ -363,93 +307,140 @@ function ScenarioList({
};
}, [page, pageSize, error, projectId]);
return <div className="h-full flex flex-col gap-2">
<h1 className="text-medium font-bold text-gray-800 dark:text-neutral-200 pb-2 border-b border-gray-200 dark:border-neutral-800">Scenarios</h1>
const handleSelectionChange = (selection: Selection) => {
if (selection === "all" &&
selectedKeys !== "all" &&
(selectedKeys as Set<string>).size > 0) {
setSelectedKeys(new Set());
setSelectedScenarios([]);
} else {
setSelectedKeys(selection);
if (selection === "all") {
setSelectedScenarios(scenarios.map(scenario => scenario._id));
} else {
setSelectedScenarios(Array.from(selection as Set<string>));
}
}
};
const handleDelete = async (scenarioId: string) => {
try {
await deleteScenario(projectId, scenarioId);
// Refresh the scenarios list after deletion
const result = await listScenarios(projectId, page, pageSize);
setScenarios(result.scenarios);
setTotal(result.total);
} catch (err) {
setError(`Failed to delete scenario: ${err}`);
}
};
const columns = [
{
key: 'name',
label: 'NAME',
render: (scenario: any) => scenario.name
},
{
key: 'description',
label: 'DESCRIPTION'
},
{
key: 'createdAt',
label: 'CREATED',
render: (scenario: any) => isValidDate(scenario.createdAt) ?
<RelativeTime date={new Date(scenario.createdAt)} /> :
'Invalid date'
}
];
return <StructuredPanel
title="SCENARIOS"
tooltip="View and manage your test scenarios"
>
<div className="flex flex-col gap-6 max-w-4xl">
{/* Header Section */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Scenarios</h1>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Create and manage test scenarios for your simulations
</p>
</div>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
className="self-end"
startContent={<PlusIcon className="w-4 h-4" />}
>
New Scenario
</Button>
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading...
</div>}
{error && <div className="bg-red-100 dark:bg-red-900/20 p-2 rounded-md text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
</div>
{/* Error Display */}
{error && (
<div className="bg-red-100 dark:bg-red-900/20 p-4 rounded-lg text-red-800 dark:text-red-400 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
</div>}
{!loading && !error && <>
{scenarios.length === 0 && <div className="text-gray-600 dark:text-neutral-400 text-center">No scenarios found</div>}
{scenarios.length > 0 && <div className="flex flex-col w-full">
{/* Header */}
<div className="grid grid-cols-7 py-2 bg-gray-100 dark:bg-neutral-800 font-semibold text-sm">
<div className="col-span-2 px-4 dark:text-neutral-300">Name</div>
<div className="col-span-3 px-4 dark:text-neutral-300">Description</div>
<div className="col-span-1 px-4 dark:text-neutral-300">Created</div>
<div className="col-span-1 px-4 dark:text-neutral-300">Updated</div>
</div>
)}
{/* Rows */}
{scenarios.map((scenario) => (
<div key={scenario._id} className="grid grid-cols-7 py-2 border-b border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-800 text-sm">
<div className="col-span-2 px-4 truncate">
<Link
href={`/projects/${projectId}/test/scenarios/${scenario._id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
{/* Scenarios Table */}
{loading ? (
<div className="flex gap-2 items-center justify-center p-8 text-gray-600 dark:text-neutral-400">
<Spinner size="sm" />
Loading scenarios...
</div>
) : scenarios.length === 0 ? (
<div className="text-center p-8 bg-gray-50 dark:bg-neutral-900 rounded-lg border border-dashed border-gray-200 dark:border-neutral-800">
<p className="text-gray-600 dark:text-neutral-400 mb-4">No scenarios created yet</p>
<Button
size="sm"
color="primary"
startContent={<PlusIcon size={16} />}
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
>
{scenario.name}
</Link>
Create Your First Scenario
</Button>
</div>
<div className="col-span-3 px-4 truncate dark:text-neutral-300">{scenario.description}</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
<RelativeTime date={new Date(scenario.createdAt)} />
) : (
<DataTable
items={scenarios}
columns={columns}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onDelete={handleDelete}
onEdit={(id) => router.push(`/projects/${projectId}/test/scenarios/${id}/edit`)}
projectId={projectId}
/>
)}
</div>
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
<RelativeTime date={new Date(scenario.lastUpdatedAt)} />
</div>
</div>
))}
</div>}
{total > 1 && <Pagination
total={total}
page={page}
onChange={(page) => {
router.push(`/projects/${projectId}/test/scenarios?page=${page}`);
}}
className="self-center"
/>}
</>}
</div>;
</StructuredPanel>;
}
export function ScenariosApp({
projectId,
slug
}: {
projectId: string,
slug: string[]
}) {
let selection: "list" | "view" | "new" | "edit" = "list";
let scenarioId: string | null = null;
if (slug.length > 0) {
export function ScenariosApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
let selection: "list" | "new" | "edit" = "list";
let scenarioId: string | undefined;
if (slug && slug.length > 0) {
if (slug[0] === "new") {
selection = "new";
} else if (slug[slug.length - 1] === "edit") {
} else if (slug[1] === "edit") {
selection = "edit";
scenarioId = slug[0];
} else {
selection = "view";
selection = "list";
scenarioId = slug[0];
}
}
return <>
return (
<div className="h-full">
{selection === "list" && <ScenarioList projectId={projectId} />}
{selection === "new" && <NewScenario projectId={projectId} />}
{selection === "view" && scenarioId && <ViewScenario projectId={projectId} scenarioId={scenarioId} />}
{selection === "edit" && scenarioId && <EditScenario projectId={projectId} scenarioId={scenarioId} />}
</>;
{selection === "edit" && scenarioId && (
<EditScenario projectId={projectId} scenarioId={scenarioId} />
)}
</div>
);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useRouter } from "next/navigation";
import { StructuredPanel } from "../../../../lib/components/structured-panel";
import { ListItem } from "../../../../lib/components/structured-list";
export function TestingMenu({
projectId,
app,
}: {
projectId: string;
app: "scenarios" | "simulations" | "profiles" | "runs";
}) {
const router = useRouter();
const menuItems = [
{
label: "Scenarios",
href: `/projects/${projectId}/test/scenarios`,
isSelected: app === "scenarios"
},
{
label: "Profiles",
href: `/projects/${projectId}/test/profiles`,
isSelected: app === "profiles"
},
{
label: "Simulations",
href: `/projects/${projectId}/test/simulations`,
isSelected: app === "simulations"
},
{
label: "Test Runs",
href: `/projects/${projectId}/test/runs`,
isSelected: app === "runs"
},
];
return (
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
<div className="overflow-auto flex flex-col gap-1 justify-start">
{menuItems.map((item) => (
<ListItem
key={item.label}
name={item.label}
isSelected={item.isSelected}
onClick={() => router.push(item.href)}
/>
))}
</div>
</StructuredPanel>
);
}

View file

@ -0,0 +1,4 @@
export const isValidDate = (date: any): boolean => {
const parsed = new Date(date);
return parsed instanceof Date && !isNaN(parsed.getTime());
};