mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Revamp testing flows UI UX
This commit is contained in:
parent
3ea08895b8
commit
ff15e55c6d
15 changed files with 2097 additions and 1471 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
});
|
||||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. "Frequent buyer""
|
||||
defaultValue={defaultValues.name}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Context"
|
||||
name="context"
|
||||
placeholder="Provide user info and other info to simulate, e.g. "User's name: John Smith. Buying frequency: 10 orders a month. Location: US. Latest order: Pair of Jeans - XL.""
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. "Order cancellation""
|
||||
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. "Role play a user who wants to cancel their recently ordered pair of jeans.""
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. "Frequent buyer cancelling order""
|
||||
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. "The assistant should successfully cancel the user's order and provide next steps for the user to confirm the cancellation""
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>}
|
||||
<Button size="sm" color="danger" onPress={() => setError(null)}>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",
|
||||
<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 || ""
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
as={Link}
|
||||
href={`/projects/${projectId}/test/profiles/${profileId}`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>;
|
||||
</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 };
|
||||
|
|
@ -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} />}
|
||||
</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const isValidDate = (date: any): boolean => {
|
||||
const parsed = new Date(date);
|
||||
return parsed instanceof Date && !isNaN(parsed.getTime());
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue