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,
|
projectId: string,
|
||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
scenarioId: string;
|
scenarioId: string;
|
||||||
profileId: string | null;
|
profileId: string | null;
|
||||||
passCriteria: string;
|
passCriteria: string;
|
||||||
|
|
@ -195,6 +196,7 @@ export async function updateSimulation(
|
||||||
simulationId: string,
|
simulationId: string,
|
||||||
updates: {
|
updates: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
description?: string;
|
||||||
scenarioId?: string;
|
scenarioId?: string;
|
||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
passCriteria?: string;
|
passCriteria?: string;
|
||||||
|
|
@ -268,7 +270,6 @@ export async function deleteProfile(projectId: string, profileId: string): Promi
|
||||||
await testProfilesCollection.deleteOne({
|
await testProfilesCollection.deleteOne({
|
||||||
_id: new ObjectId(profileId),
|
_id: new ObjectId(profileId),
|
||||||
projectId,
|
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(
|
export async function listResults(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
|
@ -510,6 +520,7 @@ export async function createResult(
|
||||||
simulationId: string;
|
simulationId: string;
|
||||||
result: 'pass' | 'fail';
|
result: 'pass' | 'fail';
|
||||||
details: string;
|
details: string;
|
||||||
|
transcript: string;
|
||||||
}
|
}
|
||||||
): Promise<WithStringId<z.infer<typeof TestResult>>> {
|
): Promise<WithStringId<z.infer<typeof TestResult>>> {
|
||||||
await projectAuthCheck(projectId);
|
await projectAuthCheck(projectId);
|
||||||
|
|
@ -544,4 +555,56 @@ export async function updateResult(
|
||||||
$set: updates,
|
$set: updates,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
export const TestSimulation = z.object({
|
||||||
projectId: z.string(),
|
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(),
|
scenarioId: z.string(),
|
||||||
profileId: z.string().nullable(),
|
profileId: z.string().nullable(),
|
||||||
passCriteria: z.string(),
|
passCriteria: z.string(),
|
||||||
createdAt: z.string().datetime(),
|
|
||||||
lastUpdatedAt: z.string().datetime(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TestRun = z.object({
|
export const TestRun = z.object({
|
||||||
|
|
@ -48,5 +49,6 @@ export const TestResult = z.object({
|
||||||
runId: z.string(),
|
runId: z.string(),
|
||||||
simulationId: z.string(),
|
simulationId: z.string(),
|
||||||
result: z.union([z.literal('pass'), z.literal('fail')]),
|
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 { SimulationsApp } from "./simulations_app";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { RunsApp } from "./runs_app";
|
import { RunsApp } from "./runs_app";
|
||||||
|
import { StructuredPanel } from "../../../../lib/components/structured-panel";
|
||||||
|
import { ListItem } from "../../../../lib/components/structured-list";
|
||||||
|
|
||||||
export function App({
|
export function App({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -43,21 +45,18 @@ export function App({
|
||||||
];
|
];
|
||||||
|
|
||||||
return <div className="flex h-full">
|
return <div className="flex h-full">
|
||||||
<div className="w-40 shrink-0 p-2">
|
<StructuredPanel title="TEST" tooltip="Browse and manage your test scenarios and runs">
|
||||||
<ul>
|
<div className="overflow-auto flex flex-col gap-1 justify-start">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<li key={item.label}>
|
<ListItem
|
||||||
<Link
|
key={item.label}
|
||||||
className={`block p-2 rounded-md text-sm ${
|
name={item.label}
|
||||||
pathname.startsWith(item.href)
|
isSelected={pathname.startsWith(item.href)}
|
||||||
? "bg-gray-100 dark:bg-neutral-800"
|
onClick={() => router.push(item.href)}
|
||||||
: "hover:bg-gray-100 dark:hover:bg-neutral-800"
|
/>
|
||||||
}`}
|
|
||||||
href={item.href}>{item.label}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</StructuredPanel>
|
||||||
<div className="grow border-l border-gray-200 dark:border-neutral-800 p-2">
|
<div className="grow border-l border-gray-200 dark:border-neutral-800 p-2">
|
||||||
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
|
{selection === "scenarios" && <ScenariosApp projectId={projectId} slug={innerSlug} />}
|
||||||
{selection === "profiles" && <ProfilesApp 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[] } }) {
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||||
return <App
|
import { ScenariosApp } from "./scenarios_app";
|
||||||
projectId={params.projectId}
|
import { SimulationsApp } from "./simulations_app";
|
||||||
slug={params.slug}
|
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 Link from "next/link";
|
||||||
import { WithStringId } from "@/app/lib/types/types";
|
import { WithStringId } from "@/app/lib/types/types";
|
||||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { createProfile, getProfile, listProfiles, updateProfile, deleteProfile } from "@/app/actions/testing_actions";
|
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 { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PlusIcon, ArrowLeftIcon, StarIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { FormStatusButton } from "@/app/lib/components/form-status-button";
|
|
||||||
import { RelativeTime } from "@primer/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({
|
function EditProfile({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -46,324 +50,96 @@ function EditProfile({
|
||||||
try {
|
try {
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const context = formData.get("context") as string;
|
const context = formData.get("context") as string;
|
||||||
|
const mockTools = formData.get("mockTools") === "on";
|
||||||
const mockPrompt = formData.get("mockPrompt") as string;
|
const mockPrompt = formData.get("mockPrompt") as string;
|
||||||
await updateProfile(projectId, profileId, {
|
|
||||||
name,
|
await updateProfile(projectId, profileId, {
|
||||||
context,
|
name,
|
||||||
|
context,
|
||||||
mockTools,
|
mockTools,
|
||||||
mockPrompt: mockPrompt || undefined
|
mockPrompt: mockTools && mockPrompt ? mockPrompt : undefined
|
||||||
});
|
});
|
||||||
router.push(`/projects/${projectId}/test/profiles/${profileId}`);
|
router.push(`/projects/${projectId}/test/profiles`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(`Unable to update profile: ${error}`);
|
setError(`Unable to update profile: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
return <StructuredPanel
|
||||||
<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>
|
title="EDIT PROFILE"
|
||||||
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
tooltip="Edit an existing test profile"
|
||||||
<Spinner size="sm" />
|
>
|
||||||
Loading...
|
<div className="flex flex-col gap-6 max-w-2xl">
|
||||||
</div>}
|
{loading && (
|
||||||
{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 className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
||||||
{error}
|
<Spinner size="sm" />
|
||||||
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
|
Loading profile...
|
||||||
</div>}
|
</div>
|
||||||
{!loading && profile && (
|
)}
|
||||||
<form ref={formRef} action={handleSubmit} className="flex flex-col gap-2">
|
|
||||||
<Input
|
{error && (
|
||||||
type="text"
|
<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">
|
||||||
name="name"
|
{error}
|
||||||
label="Name"
|
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||||
placeholder="Enter a name for the profile"
|
</div>
|
||||||
defaultValue={profile.name}
|
)}
|
||||||
required
|
|
||||||
/>
|
{!loading && profile && (
|
||||||
<Textarea
|
<ProfileForm
|
||||||
name="context"
|
formRef={formRef}
|
||||||
label="Context"
|
handleSubmit={handleSubmit}
|
||||||
placeholder="Enter the context for this profile"
|
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||||
defaultValue={profile.context}
|
submitButtonText="Update Profile"
|
||||||
required
|
defaultValues={{
|
||||||
/>
|
name: profile.name,
|
||||||
<Switch
|
context: profile.context,
|
||||||
name="mockTools"
|
mockTools: Boolean(profile.mockTools),
|
||||||
isSelected={mockTools}
|
mockPrompt: profile.mockPrompt || ""
|
||||||
onValueChange={(value) => {
|
|
||||||
setMockTools(value);
|
|
||||||
}}
|
}}
|
||||||
className="self-start"
|
/>
|
||||||
>
|
)}
|
||||||
Mock Tools
|
</div>
|
||||||
</Switch>
|
</StructuredPanel>;
|
||||||
{mockTools && <Textarea
|
|
||||||
name="mockPrompt"
|
|
||||||
label="Mock Prompt (Optional)"
|
|
||||||
placeholder="Enter a mock prompt"
|
|
||||||
defaultValue={profile.mockPrompt}
|
|
||||||
/>}
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<FormStatusButton
|
|
||||||
props={{
|
|
||||||
className: "self-start",
|
|
||||||
children: "Update",
|
|
||||||
size: "sm",
|
|
||||||
type: "submit",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
as={Link}
|
|
||||||
href={`/projects/${projectId}/test/profiles/${profileId}`}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewProfile({
|
function NewProfile({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
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,
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [mockTools, setMockTools] = useState(false);
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(formData: FormData) {
|
async function handleSubmit(formData: FormData) {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const context = formData.get("context") as string;
|
const context = formData.get("context") as string;
|
||||||
const mockPrompt = formData.get("mockPrompt") as string;
|
const mockTools = formData.get("mockTools") === "on";
|
||||||
const profile = await createProfile(projectId, {
|
const mockPrompt = mockTools ? (formData.get("mockPrompt") as string) : undefined;
|
||||||
name,
|
|
||||||
context,
|
await createProfile(projectId, {
|
||||||
|
name,
|
||||||
|
context,
|
||||||
mockTools,
|
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) {
|
} catch (error) {
|
||||||
setError(`Unable to create profile: ${error}`);
|
setError(`Unable to create profile: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
return <StructuredPanel
|
||||||
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Profile</h1>
|
title="NEW PROFILE"
|
||||||
<Button
|
tooltip="Create a new test profile"
|
||||||
size="sm"
|
>
|
||||||
className="self-start"
|
<div className="flex flex-col gap-6 max-w-2xl">
|
||||||
as={Link}
|
<ProfileForm
|
||||||
href={`/projects/${projectId}/test/profiles`}
|
formRef={formRef}
|
||||||
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
handleSubmit={handleSubmit}
|
||||||
>
|
onCancel={() => router.push(`/projects/${projectId}/test/profiles`)}
|
||||||
All Profiles
|
submitButtonText="Create Profile"
|
||||||
</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
|
|
||||||
/>
|
/>
|
||||||
<Textarea
|
</div>
|
||||||
name="context"
|
</StructuredPanel>;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileList({
|
function ProfileList({
|
||||||
|
|
@ -379,6 +155,8 @@ function ProfileList({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
|
const [profiles, setProfiles] = useState<WithStringId<z.infer<typeof TestProfile>>[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||||
|
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
@ -412,95 +190,154 @@ function ProfileList({
|
||||||
};
|
};
|
||||||
}, [page, pageSize, error, projectId]);
|
}, [page, pageSize, error, projectId]);
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
const handleSelectionChange = (selection: Selection) => {
|
||||||
<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>
|
if (selection === "all" &&
|
||||||
<Button
|
selectedKeys !== "all" &&
|
||||||
size="sm"
|
(selectedKeys as Set<string>).size > 0) {
|
||||||
onPress={() => router.push(`/projects/${projectId}/test/profiles/new`)}
|
setSelectedKeys(new Set());
|
||||||
className="self-end"
|
setSelectedProfiles([]);
|
||||||
startContent={<PlusIcon className="w-4 h-4" />}
|
} else {
|
||||||
>
|
setSelectedKeys(selection);
|
||||||
New Profile
|
if (selection === "all") {
|
||||||
</Button>
|
setSelectedProfiles(profiles.map(profile => profile._id));
|
||||||
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
} else {
|
||||||
<Spinner size="sm" />
|
setSelectedProfiles(Array.from(selection as Set<string>));
|
||||||
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">
|
};
|
||||||
{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 */}
|
const handleDelete = async (profileId: string) => {
|
||||||
{profiles.map((profile) => (
|
try {
|
||||||
<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">
|
await deleteProfile(projectId, profileId);
|
||||||
<div className="col-span-2 px-4 truncate">
|
// Refresh the profiles list after deletion
|
||||||
<Link
|
const result = await listProfiles(projectId, page, pageSize);
|
||||||
href={`/projects/${projectId}/test/profiles/${profile._id}`}
|
setProfiles(result.profiles);
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
setTotal(result.total);
|
||||||
>
|
} catch (err) {
|
||||||
{profile.name}
|
setError(`Failed to delete profile: ${err}`);
|
||||||
</Link>
|
}
|
||||||
</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>
|
const columns = [
|
||||||
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
|
{
|
||||||
<RelativeTime date={new Date(profile.createdAt)} />
|
key: 'name',
|
||||||
</div>
|
label: 'NAME',
|
||||||
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
|
render: (profile: any) => profile.name
|
||||||
<RelativeTime date={new Date(profile.lastUpdatedAt)} />
|
},
|
||||||
</div>
|
{
|
||||||
</div>
|
key: 'context',
|
||||||
))}
|
label: 'CONTEXT'
|
||||||
</div>}
|
},
|
||||||
{total > 1 && <Pagination
|
{
|
||||||
total={total}
|
key: 'mockTools',
|
||||||
page={page}
|
label: 'MOCK TOOLS',
|
||||||
onChange={(page) => {
|
render: (profile: any) => profile.mockTools ? "Yes" : "No"
|
||||||
router.push(`/projects/${projectId}/test/profiles?page=${page}`);
|
},
|
||||||
}}
|
{
|
||||||
className="self-center"
|
key: 'createdAt',
|
||||||
/>}
|
label: 'CREATED',
|
||||||
</>}
|
render: (profile: any) => profile?.createdAt && isValidDate(profile.createdAt) ?
|
||||||
</div>;
|
<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`)}
|
||||||
|
>
|
||||||
|
New Profile
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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`)}
|
||||||
|
>
|
||||||
|
Create Your First Profile
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
items={profiles}
|
||||||
|
columns={columns}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={(id) => router.push(`/projects/${projectId}/test/profiles/${id}/edit`)}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StructuredPanel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilesApp({
|
export function ProfilesApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
|
||||||
projectId,
|
let selection: "list" | "new" | "edit" = "list";
|
||||||
slug
|
let profileId: string | undefined;
|
||||||
}: {
|
|
||||||
projectId: string,
|
if (slug && slug.length > 0) {
|
||||||
slug: string[]
|
|
||||||
}) {
|
|
||||||
let selection: "list" | "view" | "new" | "edit" = "list";
|
|
||||||
let profileId: string | null = null;
|
|
||||||
if (slug.length > 0) {
|
|
||||||
if (slug[0] === "new") {
|
if (slug[0] === "new") {
|
||||||
selection = "new";
|
selection = "new";
|
||||||
} else if (slug[slug.length - 1] === "edit") {
|
} else if (slug[1] === "edit") {
|
||||||
selection = "edit";
|
selection = "edit";
|
||||||
profileId = slug[0];
|
profileId = slug[0];
|
||||||
} else {
|
} else {
|
||||||
selection = "view";
|
selection = "list";
|
||||||
profileId = slug[0];
|
profileId = slug[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
{selection === "list" && <ProfileList projectId={projectId} />}
|
<div className="h-full">
|
||||||
{selection === "new" && <NewProfile projectId={projectId} />}
|
{selection === "list" && <ProfileList projectId={projectId} />}
|
||||||
{selection === "view" && profileId && <ViewProfile projectId={projectId} profileId={profileId} />}
|
{selection === "new" && <NewProfile projectId={projectId} />}
|
||||||
{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 Link from "next/link";
|
||||||
import { WithStringId } from "@/app/lib/types/types";
|
import { WithStringId } from "@/app/lib/types/types";
|
||||||
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
|
import { TestSimulation, TestRun } from "@/app/lib/types/testing_types";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createRun, getRun, getSimulation, listRuns } from "@/app/actions/testing_actions";
|
import { getRun, getSimulation, listRuns, cancelRun, deleteRun, getSimulationResult, listRunSimulations } from "@/app/actions/testing_actions";
|
||||||
import { Button, Input, Pagination, Spinner, Chip } from "@heroui/react";
|
import { Button, Spinner, Selection } from "@heroui/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ArrowLeftIcon, PlusIcon, WorkflowIcon } from "lucide-react";
|
import { ArrowLeftIcon, PlusIcon, DownloadIcon } from "lucide-react";
|
||||||
import { FormStatusButton } from "@/app/lib/components/form-status-button";
|
|
||||||
import { RelativeTime } from "@primer/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 { Workflow } from "@/app/lib/types/workflow_types";
|
||||||
import { fetchWorkflow } from "@/app/actions/workflow_actions";
|
import { fetchWorkflow } from "@/app/actions/workflow_actions";
|
||||||
|
import { StructuredPanel, ActionButton } from "@/app/lib/components/structured-panel"
|
||||||
function NewRun({
|
import { DataTable } from "./components/table"
|
||||||
projectId,
|
import { isValidDate } from './utils/date';
|
||||||
}: {
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ViewRun({
|
function ViewRun({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -152,49 +23,124 @@ function ViewRun({
|
||||||
projectId: string,
|
projectId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const [run, setRun] = useState<WithStringId<z.infer<typeof TestRun>> | null>(null);
|
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 [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(() => {
|
useEffect(() => {
|
||||||
async function fetchRun() {
|
async function fetchData() {
|
||||||
const run = await getRun(projectId, runId);
|
try {
|
||||||
setRun(run);
|
const run = await getRun(projectId, runId);
|
||||||
if (run) {
|
if (!run) {
|
||||||
|
setError("Run not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRun(run);
|
||||||
|
|
||||||
|
const enrichedSimulations = await listRunSimulations(projectId, run.simulationIds);
|
||||||
|
setSimulations(enrichedSimulations);
|
||||||
|
|
||||||
// Fetch workflow and simulations in parallel
|
// Fetch workflow and simulations in parallel
|
||||||
const [workflowResult, simulationsResult] = await Promise.all([
|
const [workflowResult, simulationsResult] = await Promise.all([
|
||||||
fetchWorkflow(projectId, run.workflowId),
|
fetchWorkflow(projectId, run.workflowId),
|
||||||
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
|
Promise.all(run.simulationIds.map(id => getSimulation(projectId, id)))
|
||||||
]);
|
]);
|
||||||
setWorkflow(workflowResult);
|
setWorkflow(workflowResult);
|
||||||
setSimulations(simulationsResult.filter(s => s !== null));
|
} catch (error) {
|
||||||
|
setError(`Error fetching run: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
fetchRun();
|
fetchData();
|
||||||
}, [runId, projectId]);
|
}, [projectId, runId]);
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-4">
|
const columns = [
|
||||||
<div className="flex items-center justify-between">
|
{
|
||||||
<Button
|
key: 'name',
|
||||||
size="sm"
|
label: 'SIMULATION',
|
||||||
className="self-start"
|
render: (simulation: any) => simulation.name
|
||||||
as={Link}
|
},
|
||||||
href={`/projects/${projectId}/test/runs`}
|
{
|
||||||
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
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
|
All Runs
|
||||||
</Button>
|
</ActionButton>
|
||||||
</div>
|
]}
|
||||||
|
>
|
||||||
{loading && <div className="flex gap-2 items-center">
|
{loading && <div className="flex gap-2 items-center">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>}
|
</div>}
|
||||||
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
|
{!loading && !run && <div className="text-gray-600 text-center">Run not found</div>}
|
||||||
{!loading && run && (
|
{!loading && run && (
|
||||||
<>
|
<div className="flex flex-col gap-6 max-w-4xl">
|
||||||
{/* Workflow and timing information in a grid */}
|
{/* Workflow and timing information in a grid */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{workflow && (
|
{workflow && (
|
||||||
|
|
@ -236,31 +182,22 @@ function ViewRun({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simulations List */}
|
{/* Simulations List */}
|
||||||
<div className="mt-4">
|
<div>
|
||||||
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
|
<h2 className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-2">Simulations</h2>
|
||||||
<div className="space-y-2">
|
<DataTable
|
||||||
{simulations.map(sim => (
|
items={simulations}
|
||||||
<div key={sim._id} className="border dark:border-neutral-800 rounded-lg p-3">
|
columns={columns}
|
||||||
<Link
|
projectId={projectId}
|
||||||
href={`/projects/${projectId}/test/simulations/${sim._id}`}
|
onDownload={handleDownload}
|
||||||
className="text-blue-600 hover:underline"
|
selectionMode="none"
|
||||||
>
|
/>
|
||||||
{sim.name}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>;
|
</StructuredPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunList({
|
function RunsList({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string,
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const page = parseInt(searchParams.get("page") || "1");
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
|
@ -270,6 +207,54 @@ function RunList({
|
||||||
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
|
const [runs, setRuns] = useState<WithStringId<z.infer<typeof TestRun>>[]>([]);
|
||||||
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
|
const [workflowMap, setWorkflowMap] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
|
||||||
const [total, setTotal] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
@ -333,101 +318,130 @@ function RunList({
|
||||||
};
|
};
|
||||||
}, [runs, error, projectId]);
|
}, [runs, error, projectId]);
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-4">
|
const columns = [
|
||||||
<div className="flex items-center justify-between">
|
{
|
||||||
<h1 className="text-xl font-semibold text-gray-800 dark:text-neutral-200">Test Runs</h1>
|
key: 'name',
|
||||||
<Button
|
label: 'NAME',
|
||||||
size="sm"
|
render: (run: any) => run.name
|
||||||
onPress={() => router.push(`/projects/${projectId}/test/runs/new`)}
|
},
|
||||||
startContent={<PlusIcon className="w-4 h-4" />}
|
{
|
||||||
>
|
key: 'status',
|
||||||
New Run
|
label: 'STATUS',
|
||||||
</Button>
|
render: (run: any) => (
|
||||||
</div>
|
<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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
{loading && <div className="flex gap-2 items-center">
|
return (
|
||||||
<Spinner size="sm" />
|
<StructuredPanel
|
||||||
Loading...
|
title="TEST RUNS"
|
||||||
</div>}
|
tooltip="View and manage your test runs"
|
||||||
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
|
>
|
||||||
{error}
|
<div className="flex flex-col gap-6 max-w-4xl">
|
||||||
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
{/* Header Section */}
|
||||||
</div>}
|
<div className="flex items-center justify-between">
|
||||||
{!loading && !error && <>
|
<div className="flex flex-col gap-1">
|
||||||
{runs.length === 0 && <div className="text-gray-600 dark:text-neutral-400 text-center">No test runs found</div>}
|
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">Test Runs</h1>
|
||||||
{runs.length > 0 && <div className="space-y-4">
|
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||||
{runs.map((run) => (
|
View and monitor your workflow test runs
|
||||||
<div key={run._id} className="border dark:border-neutral-800 rounded-lg shadow-sm">
|
</p>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{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}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
))}
|
<Button
|
||||||
</div>}
|
size="sm"
|
||||||
{total > 1 && <Pagination
|
color="primary"
|
||||||
total={total}
|
startContent={<PlusIcon size={16} />}
|
||||||
page={page}
|
onPress={() => router.push(`/projects/${projectId}/test/simulations`)}
|
||||||
onChange={(page) => {
|
>
|
||||||
router.push(`/projects/${projectId}/test/runs?page=${page}`);
|
New Run
|
||||||
}}
|
</Button>
|
||||||
className="self-center"
|
</div>
|
||||||
/>}
|
|
||||||
</>}
|
{/* Error Display */}
|
||||||
</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={() => setError(null)}>Retry</Button>
|
||||||
|
</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`)}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</StructuredPanel>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for status styling
|
// Helper functions for status styling
|
||||||
function getStatusClass(status: string) {
|
function getStatusStyles(status: string): string {
|
||||||
const baseClass = "px-2 py-1 rounded text-xs uppercase font-medium";
|
const styles = {
|
||||||
switch (status) {
|
pending: "bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300",
|
||||||
case 'completed':
|
running: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
return `${baseClass} bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400`;
|
completed: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||||
case 'failed':
|
cancelled: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||||
case 'error':
|
failed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||||
return `${baseClass} bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-400`;
|
error: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||||
case 'cancelled':
|
};
|
||||||
return `${baseClass} bg-gray-100 dark:bg-neutral-800 text-gray-800 dark:text-neutral-400`;
|
return styles[status as keyof typeof styles] || styles.pending;
|
||||||
case 'running':
|
}
|
||||||
case 'pending':
|
|
||||||
default:
|
function getStatusDotStyles(status: string): string {
|
||||||
return `${baseClass} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400`;
|
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({
|
export function RunsApp({
|
||||||
|
|
@ -437,20 +451,15 @@ export function RunsApp({
|
||||||
projectId: string,
|
projectId: string,
|
||||||
slug: string[]
|
slug: string[]
|
||||||
}) {
|
}) {
|
||||||
let selection: "list" | "view" | "new" = "list";
|
let selection: "list" | "view" = "list";
|
||||||
let runId: string | null = null;
|
let runId: string | null = null;
|
||||||
if (slug.length > 0) {
|
if (slug.length > 0) {
|
||||||
if (slug[0] === "new") {
|
selection = "view";
|
||||||
selection = "new";
|
runId = slug[0];
|
||||||
} else {
|
|
||||||
selection = "view";
|
|
||||||
runId = slug[0];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
{selection === "list" && <RunList projectId={projectId} />}
|
{selection === "list" && <RunsList projectId={projectId} />}
|
||||||
{selection === "new" && <NewRun projectId={projectId} />}
|
|
||||||
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
|
{selection === "view" && runId && <ViewRun projectId={projectId} runId={runId} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WithStringId } from "@/app/lib/types/types";
|
import { WithStringId } from "@/app/lib/types/types";
|
||||||
import { TestScenario } from "@/app/lib/types/testing_types";
|
import { TestScenario } from "@/app/lib/types/testing_types";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { createScenario, getScenario, listScenarios, updateScenario, deleteScenario } from "@/app/actions/testing_actions";
|
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 { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ArrowLeftIcon, PlusIcon } from "lucide-react";
|
import { ArrowLeftIcon, PlusIcon, } from "lucide-react";
|
||||||
import { FormStatusButton } from "@/app/lib/components/form-status-button";
|
|
||||||
import { RelativeTime } from "@primer/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({
|
function EditScenario({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -44,60 +50,45 @@ function EditScenario({
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const description = formData.get("description") as string;
|
const description = formData.get("description") as string;
|
||||||
await updateScenario(projectId, scenarioId, { name, description });
|
await updateScenario(projectId, scenarioId, { name, description });
|
||||||
router.push(`/projects/${projectId}/test/scenarios/${scenarioId}`);
|
router.push(`/projects/${projectId}/test/scenarios`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(`Unable to update scenario: ${error}`);
|
setError(`Unable to update scenario: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
return <StructuredPanel
|
||||||
<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>
|
title="EDIT SCENARIO"
|
||||||
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
tooltip="Edit an existing test scenario"
|
||||||
<Spinner size="sm" />
|
>
|
||||||
Loading...
|
<div className="flex flex-col gap-6 max-w-2xl">
|
||||||
</div>}
|
{loading && (
|
||||||
{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 className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
||||||
{error}
|
<Spinner size="sm" />
|
||||||
<Button size="sm" color="danger" onPress={() => formRef.current?.requestSubmit()}>Retry</Button>
|
Loading scenario...
|
||||||
</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",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
as={Link}
|
|
||||||
href={`/projects/${projectId}/test/scenarios/${scenarioId}`}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
)}
|
|
||||||
</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={() => setError(null)}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && scenario && (
|
||||||
|
<ScenarioForm
|
||||||
|
formRef={formRef}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||||
|
submitButtonText="Update Scenario"
|
||||||
|
defaultValues={{
|
||||||
|
name: scenario.name,
|
||||||
|
description: scenario.description
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StructuredPanel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewScenario({
|
function ViewScenario({
|
||||||
|
|
@ -131,120 +122,98 @@ function ViewScenario({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
return (
|
||||||
<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>
|
<StructuredPanel
|
||||||
<Button
|
title="VIEW SCENARIO"
|
||||||
size="sm"
|
tooltip="View scenario details"
|
||||||
className="self-start"
|
actions={[
|
||||||
as={Link}
|
<ActionButton
|
||||||
href={`/projects/${projectId}/test/scenarios`}
|
key="back"
|
||||||
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
icon={<ArrowLeftIcon size={16} />}
|
||||||
|
onClick={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||||
|
>
|
||||||
|
All Scenarios
|
||||||
|
</ActionButton>
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
All Scenarios
|
<ItemView
|
||||||
</Button>
|
items={[
|
||||||
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
{ label: "Name", value: scenario?.name },
|
||||||
<Spinner size="sm" />
|
{ label: "Description", value: scenario?.description },
|
||||||
Loading...
|
{
|
||||||
</div>}
|
label: "Created",
|
||||||
{!loading && !scenario && <div className="text-gray-600 dark:text-neutral-400 text-center">Scenario not found</div>}
|
value: scenario?.createdAt && isValidDate(scenario.createdAt)
|
||||||
{!loading && scenario && (
|
? <RelativeTime date={new Date(scenario.createdAt)} />
|
||||||
<>
|
: 'Invalid date'
|
||||||
<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>
|
label: "Last Updated",
|
||||||
<div className="flex-[2] dark:text-neutral-200">{scenario.name}</div>
|
value: scenario?.lastUpdatedAt && isValidDate(scenario.lastUpdatedAt)
|
||||||
</div>
|
? <RelativeTime date={new Date(scenario.lastUpdatedAt)} />
|
||||||
<div className="flex border-b border-gray-200 dark:border-neutral-800 py-2">
|
: 'Invalid date'
|
||||||
<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>
|
actions={
|
||||||
<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>
|
<Button size="sm" variant="flat" onPress={() => router.push(`/projects/${projectId}/test/scenarios/${scenarioId}/edit`)}>Edit</Button>
|
||||||
<div className="flex-[2] dark:text-neutral-300"><RelativeTime date={new Date(scenario.createdAt)} /></div>
|
<Button size="sm" color="danger" variant="flat" onPress={() => setIsDeleteModalOpen(true)}>Delete</Button>
|
||||||
</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>
|
<Modal
|
||||||
</div>
|
isOpen={isDeleteModalOpen}
|
||||||
</div>
|
onOpenChange={setIsDeleteModalOpen}
|
||||||
<div className="flex gap-2 mt-4">
|
size="sm"
|
||||||
<Button
|
>
|
||||||
size="sm"
|
<ModalContent>
|
||||||
as={Link}
|
{(onClose) => (
|
||||||
href={`/projects/${projectId}/test/scenarios/${scenarioId}/edit`}
|
<>
|
||||||
>
|
<ModalHeader>Confirm Deletion</ModalHeader>
|
||||||
Edit
|
<ModalBody>
|
||||||
</Button>
|
Are you sure you want to delete this scenario?
|
||||||
<Button
|
</ModalBody>
|
||||||
size="sm"
|
<ModalFooter>
|
||||||
color="danger"
|
<Button size="sm" variant="flat" onPress={onClose}>
|
||||||
variant="flat"
|
Cancel
|
||||||
onPress={() => setIsDeleteModalOpen(true)}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Delete
|
size="sm"
|
||||||
</Button>
|
color="danger"
|
||||||
</div>
|
onPress={() => {
|
||||||
|
handleDelete();
|
||||||
<Modal
|
onClose();
|
||||||
isOpen={isDeleteModalOpen}
|
}}
|
||||||
onOpenChange={setIsDeleteModalOpen}
|
>
|
||||||
size="sm"
|
Delete
|
||||||
>
|
</Button>
|
||||||
<ModalContent>
|
</ModalFooter>
|
||||||
{(onClose) => (
|
</>
|
||||||
<>
|
)}
|
||||||
<ModalHeader>Confirm Deletion</ModalHeader>
|
</ModalContent>
|
||||||
<ModalBody>
|
</Modal>
|
||||||
Are you sure you want to delete this scenario?
|
<Modal
|
||||||
</ModalBody>
|
isOpen={deleteError !== null}
|
||||||
<ModalFooter>
|
onOpenChange={() => setDeleteError(null)}
|
||||||
<Button size="sm" variant="flat" onPress={onClose}>
|
size="sm"
|
||||||
Cancel
|
>
|
||||||
</Button>
|
<ModalContent>
|
||||||
<Button
|
{(onClose) => (
|
||||||
size="sm"
|
<>
|
||||||
color="danger"
|
<ModalHeader>Error</ModalHeader>
|
||||||
onPress={() => {
|
<ModalBody>
|
||||||
handleDelete();
|
{deleteError}
|
||||||
onClose();
|
</ModalBody>
|
||||||
}}
|
<ModalFooter>
|
||||||
>
|
<Button size="sm" onPress={onClose}>
|
||||||
Delete
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</StructuredPanel>
|
||||||
<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 NewScenario({
|
function NewScenario({
|
||||||
|
|
@ -258,63 +227,36 @@ function NewScenario({
|
||||||
|
|
||||||
async function handleSubmit(formData: FormData) {
|
async function handleSubmit(formData: FormData) {
|
||||||
setError(null);
|
setError(null);
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const description = formData.get("description") as string;
|
|
||||||
try {
|
try {
|
||||||
const scenario = await createScenario(projectId, { name, description });
|
const name = formData.get("name") as string;
|
||||||
router.push(`/projects/${projectId}/test/scenarios/${scenario._id}`);
|
const description = formData.get("description") as string;
|
||||||
|
await createScenario(projectId, { name, description });
|
||||||
|
router.push(`/projects/${projectId}/test/scenarios`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(`Unable to create scenario: ${error}`);
|
setError(`Unable to create scenario: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
return <StructuredPanel
|
||||||
<h1 className="text-medium font-bold text-gray-800 pb-2 border-b border-gray-200">New Scenario</h1>
|
title="NEW SCENARIO"
|
||||||
<Button
|
tooltip="Create a new test scenario"
|
||||||
size="sm"
|
>
|
||||||
className="self-start"
|
<div className="flex flex-col gap-6 max-w-2xl">
|
||||||
as={Link}
|
{error && (
|
||||||
href={`/projects/${projectId}/test/scenarios`}
|
<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">
|
||||||
startContent={<ArrowLeftIcon className="w-4 h-4" />}
|
{error}
|
||||||
>
|
<Button size="sm" color="danger" onPress={() => setError(null)}>Retry</Button>
|
||||||
All Scenarios
|
</div>
|
||||||
</Button>
|
)}
|
||||||
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
|
|
||||||
{error}
|
<ScenarioForm
|
||||||
<Button
|
formRef={formRef}
|
||||||
size="sm"
|
handleSubmit={handleSubmit}
|
||||||
color="danger"
|
onCancel={() => router.push(`/projects/${projectId}/test/scenarios`)}
|
||||||
onPress={() => {
|
submitButtonText="Create Scenario"
|
||||||
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
|
|
||||||
/>
|
/>
|
||||||
<Textarea
|
</div>
|
||||||
name="description"
|
</StructuredPanel>;
|
||||||
label="Description"
|
|
||||||
placeholder="Enter a description for the scenario"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormStatusButton
|
|
||||||
props={{
|
|
||||||
className: "self-start",
|
|
||||||
children: "Create",
|
|
||||||
size: "sm",
|
|
||||||
type: "submit",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScenarioList({
|
function ScenarioList({
|
||||||
|
|
@ -330,6 +272,8 @@ function ScenarioList({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
|
const [scenarios, setScenarios] = useState<WithStringId<z.infer<typeof TestScenario>>[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<string>());
|
||||||
|
const [selectedScenarios, setSelectedScenarios] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
@ -363,93 +307,140 @@ function ScenarioList({
|
||||||
};
|
};
|
||||||
}, [page, pageSize, error, projectId]);
|
}, [page, pageSize, error, projectId]);
|
||||||
|
|
||||||
return <div className="h-full flex flex-col gap-2">
|
const handleSelectionChange = (selection: Selection) => {
|
||||||
<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>
|
if (selection === "all" &&
|
||||||
<Button
|
selectedKeys !== "all" &&
|
||||||
size="sm"
|
(selectedKeys as Set<string>).size > 0) {
|
||||||
onPress={() => router.push(`/projects/${projectId}/test/scenarios/new`)}
|
setSelectedKeys(new Set());
|
||||||
className="self-end"
|
setSelectedScenarios([]);
|
||||||
startContent={<PlusIcon className="w-4 h-4" />}
|
} else {
|
||||||
>
|
setSelectedKeys(selection);
|
||||||
New Scenario
|
if (selection === "all") {
|
||||||
</Button>
|
setSelectedScenarios(scenarios.map(scenario => scenario._id));
|
||||||
{loading && <div className="flex gap-2 items-center text-gray-600 dark:text-neutral-400">
|
} else {
|
||||||
<Spinner size="sm" />
|
setSelectedScenarios(Array.from(selection as Set<string>));
|
||||||
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">
|
};
|
||||||
{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 */}
|
const handleDelete = async (scenarioId: string) => {
|
||||||
{scenarios.map((scenario) => (
|
try {
|
||||||
<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">
|
await deleteScenario(projectId, scenarioId);
|
||||||
<div className="col-span-2 px-4 truncate">
|
// Refresh the scenarios list after deletion
|
||||||
<Link
|
const result = await listScenarios(projectId, page, pageSize);
|
||||||
href={`/projects/${projectId}/test/scenarios/${scenario._id}`}
|
setScenarios(result.scenarios);
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
setTotal(result.total);
|
||||||
>
|
} catch (err) {
|
||||||
{scenario.name}
|
setError(`Failed to delete scenario: ${err}`);
|
||||||
</Link>
|
}
|
||||||
</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">
|
const columns = [
|
||||||
<RelativeTime date={new Date(scenario.createdAt)} />
|
{
|
||||||
</div>
|
key: 'name',
|
||||||
<div className="col-span-1 px-4 text-gray-600 dark:text-neutral-400 truncate">
|
label: 'NAME',
|
||||||
<RelativeTime date={new Date(scenario.lastUpdatedAt)} />
|
render: (scenario: any) => scenario.name
|
||||||
</div>
|
},
|
||||||
</div>
|
{
|
||||||
))}
|
key: 'description',
|
||||||
</div>}
|
label: 'DESCRIPTION'
|
||||||
{total > 1 && <Pagination
|
},
|
||||||
total={total}
|
{
|
||||||
page={page}
|
key: 'createdAt',
|
||||||
onChange={(page) => {
|
label: 'CREATED',
|
||||||
router.push(`/projects/${projectId}/test/scenarios?page=${page}`);
|
render: (scenario: any) => isValidDate(scenario.createdAt) ?
|
||||||
}}
|
<RelativeTime date={new Date(scenario.createdAt)} /> :
|
||||||
className="self-center"
|
'Invalid date'
|
||||||
/>}
|
}
|
||||||
</>}
|
];
|
||||||
</div>;
|
|
||||||
|
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`)}
|
||||||
|
>
|
||||||
|
New Scenario
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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`)}
|
||||||
|
>
|
||||||
|
Create Your First Scenario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
items={scenarios}
|
||||||
|
columns={columns}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
onSelectionChange={setSelectedKeys}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={(id) => router.push(`/projects/${projectId}/test/scenarios/${id}/edit`)}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StructuredPanel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScenariosApp({
|
export function ScenariosApp({ projectId, slug }: { projectId: string; slug?: string[] }) {
|
||||||
projectId,
|
let selection: "list" | "new" | "edit" = "list";
|
||||||
slug
|
let scenarioId: string | undefined;
|
||||||
}: {
|
|
||||||
projectId: string,
|
if (slug && slug.length > 0) {
|
||||||
slug: string[]
|
|
||||||
}) {
|
|
||||||
let selection: "list" | "view" | "new" | "edit" = "list";
|
|
||||||
let scenarioId: string | null = null;
|
|
||||||
if (slug.length > 0) {
|
|
||||||
if (slug[0] === "new") {
|
if (slug[0] === "new") {
|
||||||
selection = "new";
|
selection = "new";
|
||||||
} else if (slug[slug.length - 1] === "edit") {
|
} else if (slug[1] === "edit") {
|
||||||
selection = "edit";
|
selection = "edit";
|
||||||
scenarioId = slug[0];
|
scenarioId = slug[0];
|
||||||
} else {
|
} else {
|
||||||
selection = "view";
|
selection = "list";
|
||||||
scenarioId = slug[0];
|
scenarioId = slug[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
{selection === "list" && <ScenarioList projectId={projectId} />}
|
<div className="h-full">
|
||||||
{selection === "new" && <NewScenario projectId={projectId} />}
|
{selection === "list" && <ScenarioList projectId={projectId} />}
|
||||||
{selection === "view" && scenarioId && <ViewScenario projectId={projectId} scenarioId={scenarioId} />}
|
{selection === "new" && <NewScenario projectId={projectId} />}
|
||||||
{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