225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import { BookOpenIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
|
import { type MetaFunction, Outlet, useParams } from "@remix-run/react";
|
|
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import { TestHasNoTasks } from "~/components/BlankStatePanels";
|
|
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
|
import { LinkButton } from "~/components/primitives/Buttons";
|
|
import { Callout } from "~/components/primitives/Callout";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "~/components/primitives/Resizable";
|
|
import {
|
|
Table,
|
|
TableBlankRow,
|
|
TableBody,
|
|
TableCell,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
} from "~/components/primitives/Table";
|
|
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
|
import { useLinkStatus } from "~/hooks/useLinkStatus";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import { type TaskListItem, TestPresenter } from "~/presenters/v3/TestPresenter.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { cn } from "~/utils/cn";
|
|
import { docsPath, EnvironmentParamSchema, v3TestTaskPath } from "~/utils/pathBuilder";
|
|
|
|
export const meta: MetaFunction = () => {
|
|
return [
|
|
{
|
|
title: `Test | Trigger.dev`,
|
|
},
|
|
];
|
|
};
|
|
|
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
throw new Response(undefined, {
|
|
status: 404,
|
|
statusText: "Project not found",
|
|
});
|
|
}
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) {
|
|
throw new Response(undefined, {
|
|
status: 404,
|
|
statusText: "Environment not found",
|
|
});
|
|
}
|
|
|
|
const presenter = new TestPresenter();
|
|
const result = await presenter.call({
|
|
userId,
|
|
projectId: project.id,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
});
|
|
|
|
return typedjson(result);
|
|
};
|
|
|
|
export default function Page() {
|
|
const { tasks } = useTypedLoaderData<typeof loader>();
|
|
const { taskParam } = useParams();
|
|
|
|
return (
|
|
<PageContainer>
|
|
<NavBar>
|
|
<PageTitle title="Test" />
|
|
<PageAccessories>
|
|
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/run-tests")}>
|
|
Test docs
|
|
</LinkButton>
|
|
</PageAccessories>
|
|
</NavBar>
|
|
<PageBody scrollable={false}>
|
|
{tasks.length === 0 ? (
|
|
<MainCenteredContainer className="max-w-md">
|
|
<TestHasNoTasks />
|
|
</MainCenteredContainer>
|
|
) : taskParam ? (
|
|
// Task selected via URL → skip the picker; the child route owns the page.
|
|
<Outlet key={taskParam} />
|
|
) : (
|
|
// No task in URL: show the picker as a fallback so users landing on
|
|
// /test (e.g. from the runs blank state with multiple task filters)
|
|
// can still choose one.
|
|
<div className={cn("grid h-full max-h-full grid-cols-1")}>
|
|
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
|
<ResizablePanel id="test-selector" min="200px" default="20%">
|
|
<TaskSelector tasks={tasks} activeTaskIdentifier={taskParam} />
|
|
</ResizablePanel>
|
|
<ResizableHandle id="test-handle" />
|
|
<ResizablePanel id="test-main" min="225px">
|
|
<Outlet key={taskParam} />
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
)}
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function TaskSelector({
|
|
tasks,
|
|
activeTaskIdentifier,
|
|
}: {
|
|
tasks: TaskListItem[];
|
|
activeTaskIdentifier?: string;
|
|
}) {
|
|
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
|
|
items: tasks,
|
|
keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"],
|
|
});
|
|
const hasTaskInEnvironment = activeTaskIdentifier
|
|
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"grid h-full max-h-full overflow-hidden",
|
|
hasTaskInEnvironment === false ? "grid-rows-[auto_auto_1fr]" : "grid-rows-[auto_1fr]"
|
|
)}
|
|
>
|
|
<div className="p-2">
|
|
<Input
|
|
placeholder="Search tasks"
|
|
variant="secondary-small"
|
|
icon={MagnifyingGlassIcon}
|
|
iconClassName="text-text-bright"
|
|
fullWidth={true}
|
|
value={filterText}
|
|
autoFocus
|
|
onChange={(e) => setFilterText(e.target.value)}
|
|
/>
|
|
</div>
|
|
{hasTaskInEnvironment === false && (
|
|
<div className="px-2 pb-2">
|
|
<Callout variant="warning" className="text-sm text-yellow-300">
|
|
There is no "{activeTaskIdentifier}" task in the selected environment.
|
|
</Callout>
|
|
</div>
|
|
)}
|
|
<Table>
|
|
<TableHeader className="hidden">
|
|
<TableRow>
|
|
<TableHeaderCell className="w-[1%]" />
|
|
<TableHeaderCell className="pl-0">Task</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredItems.length > 0 ? (
|
|
filteredItems.map((t) => <TaskRow key={t.friendlyId} task={t} />)
|
|
) : (
|
|
<TableBlankRow colSpan={2}>
|
|
<Paragraph spacing variant="small">
|
|
No tasks match "{filterText}"
|
|
</Paragraph>
|
|
</TableBlankRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskRow({ task }: { task: TaskListItem }) {
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
|
|
const path = v3TestTaskPath(organization, project, environment, task);
|
|
const { isActive, isPending } = useLinkStatus(path);
|
|
|
|
return (
|
|
<TableRow
|
|
key={task.taskIdentifier}
|
|
className={cn((isActive || isPending) && "bg-indigo-500/10")}
|
|
>
|
|
<TableCell
|
|
to={path}
|
|
actionClassName="pl-2.5 pr-2 py-1"
|
|
className={cn("w-[1%]", (isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
|
>
|
|
<RadioButtonCircle checked={isActive || isPending} />
|
|
</TableCell>
|
|
<TableCell
|
|
to={path}
|
|
isTabbableCell
|
|
actionClassName="pl-0 pr-2"
|
|
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<TaskTriggerSourceIcon source={task.triggerSource} />
|
|
<Paragraph
|
|
variant="extra-small"
|
|
className="text-text-dimmed group-hover/table-row:text-text-bright"
|
|
>
|
|
{task.taskIdentifier}
|
|
</Paragraph>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|