The run detail column is now a separate outlet with event and task routes

This commit is contained in:
Matt Aitken
2023-06-08 15:09:08 +01:00
parent ea426965de
commit 8d2dec28e8
11 changed files with 368 additions and 156 deletions
+12
View File
@@ -0,0 +1,12 @@
import { useLocation, useNavigation } from "@remix-run/react";
export function usePathName(preemptive = true) {
const navigation = useNavigation();
const location = useLocation();
if (!preemptive || navigation.state === "idle" || !navigation.location) {
return location.pathname;
}
return navigation.location.pathname;
}
@@ -0,0 +1,44 @@
import {
DisplayElementSchema,
StyleSchema,
} from "@/../../packages/internal/src";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
type DetailsProps = {
id: string;
userId: string;
};
export type DetailedEvent = NonNullable<
Awaited<ReturnType<EventDetailsPresenter["call"]>>
>;
export class EventDetailsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ id, userId }: DetailsProps) {
const event = await this.#prismaClient.eventRecord.findFirst({
select: {
id: true,
name: true,
payload: true,
timestamp: true,
deliveredAt: true,
},
where: {
id,
},
});
if (!event) {
return undefined;
}
return event;
}
}
@@ -27,42 +27,31 @@ const ElementsSchema = z.array(DisplayElementSchema);
const taskSelect = {
id: true,
displayKey: true,
name: true,
icon: true,
status: true,
delayUntil: true,
description: true,
elements: true,
error: true,
startedAt: true,
completedAt: true,
style: true,
parentId: true,
runConnection: {
select: {
id: true,
key: true,
apiConnection: {
select: {
metadata: true,
connectionType: true,
client: {
select: {
title: true,
slug: true,
description: true,
scopes: true,
integrationIdentifier: true,
integrationAuthMethod: true,
title: true,
},
},
},
},
},
},
name: true,
icon: true,
status: true,
delayUntil: true,
noop: true,
description: true,
elements: true,
params: true,
output: true,
error: true,
startedAt: true,
completedAt: true,
style: true,
parentId: true,
} as const;
export class RunPresenter {
@@ -99,7 +88,6 @@ export class RunPresenter {
return {
...t,
connection: t.runConnection,
params: t.params as Record<string, any>,
elements:
t.elements == null
? []
@@ -246,15 +234,4 @@ export class RunPresenter {
},
});
}
// #recursivelyEnrichTask(
// task: QueryTask
// ): EnrichedTask & { subtasks: EnrichedTask[] } {
// const enrichedTask = this.enrichTask(task);
// let subtasks: (EnrichedTask & { subtasks: EnrichedTask[] })[] = [];
// if (task.children) {
// subtasks = task.children.map((t) => this.#recursivelyEnrichTask(t));
// }
// return { ...enrichedTask, subtasks };
// }
}
@@ -0,0 +1,86 @@
import {
DisplayElementSchema,
StyleSchema,
} from "@/../../packages/internal/src";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
type DetailsProps = {
id: string;
userId: string;
};
export type DetailedTask = NonNullable<
Awaited<ReturnType<TaskDetailsPresenter["call"]>>
>;
export class TaskDetailsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ id, userId }: DetailsProps) {
const task = await this.#prismaClient.task.findFirst({
select: {
id: true,
displayKey: true,
runConnection: {
select: {
id: true,
key: true,
apiConnection: {
select: {
metadata: true,
connectionType: true,
client: {
select: {
title: true,
slug: true,
description: true,
scopes: true,
integrationIdentifier: true,
integrationAuthMethod: true,
},
},
},
},
},
},
name: true,
icon: true,
status: true,
delayUntil: true,
noop: true,
description: true,
elements: true,
params: true,
output: true,
error: true,
startedAt: true,
completedAt: true,
style: true,
parentId: true,
},
where: {
id,
},
});
if (!task) {
return undefined;
}
return {
...task,
connection: task.runConnection,
params: task.params as Record<string, any>,
elements:
task.elements == null
? []
: z.array(DisplayElementSchema).parse(task.elements),
style: task.style ? StyleSchema.parse(task.style) : undefined,
};
}
}
@@ -0,0 +1,5 @@
import { Paragraph } from "~/components/primitives/Paragraph";
export default function Page() {
<Paragraph>Nothing selected</Paragraph>;
}
@@ -0,0 +1,76 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Header3 } from "~/components/primitives/Headers";
import { requireUserId } from "~/services/session.server";
import { formatDateTime } from "~/utils";
import {
RunPanel,
RunPanelBody,
RunPanelHeader,
RunPanelIconElement,
RunPanelIconSection,
} from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/RunCard";
import { EventDetailsPresenter } from "~/presenters/EventDetailsPresenter.server";
import { useJob } from "~/hooks/useJob";
export const loader = async ({ request, params }: LoaderArgs) => {
const userId = await requireUserId(request);
const { jobParam, runParam, eventParam } = params;
invariant(jobParam, "jobParam not found");
invariant(runParam, "runParam not found");
invariant(eventParam, "eventParam not found");
const presenter = new EventDetailsPresenter();
const event = await presenter.call({
userId,
id: eventParam,
});
if (!event) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
event,
});
};
export default function Page() {
const { event } = useTypedLoaderData<typeof loader>();
const job = useJob();
const { id, name, payload, timestamp, deliveredAt } = event;
return (
<RunPanel selected={false}>
<RunPanelHeader icon={job.event.icon} title={job.event.title} />
<RunPanelBody>
<div className="mb-4 border-b border-slate-800 pb-4">
<RunPanelIconSection>
<RunPanelIconElement
icon="calendar"
label="Created"
value={formatDateTime(timestamp, "long")}
/>
{deliveredAt && (
<RunPanelIconElement
icon="flag"
label="Finished at"
value={formatDateTime(deliveredAt, "long")}
/>
)}
<RunPanelIconElement icon="id" label="Event name" value={name} />
</RunPanelIconSection>
</div>
<div className="mt-4 flex flex-col gap-2">
<Header3>Payload</Header3>
<CodeBlock code={JSON.stringify(payload, null, 2)} />
</div>
</RunPanelBody>
</RunPanel>
);
}
@@ -1,7 +1,11 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Event, Task } from "~/presenters/RunPresenter.server";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatDateTime, formatDuration } from "~/utils";
import { cn } from "~/utils/cn";
import {
@@ -12,43 +16,48 @@ import {
RunPanelIconElement,
RunPanelIconSection,
RunPanelIconTitle,
} from "./RunCard";
import { TaskStatusIcon } from "./TaskStatus";
} from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/RunCard";
import { TaskStatusIcon } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/TaskStatus";
type Trigger = Event & { icon: string; title: string };
export const loader = async ({ request, params }: LoaderArgs) => {
const userId = await requireUserId(request);
const { jobParam, runParam, taskParam } = params;
invariant(jobParam, "jobParam not found");
invariant(runParam, "runParam not found");
invariant(taskParam, "selectedParam not found");
type DetailProps =
| {
type: "task";
task: Task;
}
| {
type: "trigger";
trigger: Trigger;
};
const presenter = new TaskDetailsPresenter();
const task = await presenter.call({
userId,
id: taskParam,
});
export function Detail(props: DetailProps) {
switch (props.type) {
case "task":
return <TaskDetail {...props.task} />;
case "trigger":
return <EventDetail {...props.trigger} />;
default:
return <></>;
if (!task) {
throw new Response(null, {
status: 404,
});
}
}
export function TaskDetail({
name,
icon,
startedAt,
completedAt,
status,
delayUntil,
params,
elements,
output,
}: Task) {
return typedjson({
task,
});
};
export default function Page() {
const { task } = useTypedLoaderData<typeof loader>();
const {
name,
icon,
startedAt,
completedAt,
status,
delayUntil,
params,
elements,
output,
} = task;
return (
<RunPanel selected={false}>
<RunPanelHeader
@@ -124,42 +133,3 @@ export function TaskDetail({
</RunPanel>
);
}
export function EventDetail({
icon,
title,
id,
name,
payload,
timestamp,
deliveredAt,
}: Trigger) {
return (
<RunPanel selected={false}>
<RunPanelHeader icon={icon} title={title} />
<RunPanelBody>
<div className="mb-4 border-b border-slate-800 pb-4">
<RunPanelIconSection>
<RunPanelIconElement
icon="calendar"
label="Created"
value={formatDateTime(timestamp, "long")}
/>
{deliveredAt && (
<RunPanelIconElement
icon="flag"
label="Finished at"
value={formatDateTime(deliveredAt, "long")}
/>
)}
<RunPanelIconElement icon="id" label="Event name" value={name} />
</RunPanelIconSection>
</div>
<div className="mt-4 flex flex-col gap-2">
<Header3>Payload</Header3>
<CodeBlock code={JSON.stringify(payload, null, 2)} />
</div>
</RunPanelBody>
</RunPanel>
);
}
@@ -22,14 +22,15 @@ import { AnimatePresence, motion } from "framer-motion";
type TaskCardProps = Task & {
selectedId?: string;
setSelectedId: (id: string) => void;
selectedTask: (id: string) => void;
isLast: boolean;
depth: number;
};
//todo add links to elements
export function TaskCard({
selectedId,
setSelectedId,
selectedTask,
isLast,
depth,
id,
@@ -53,7 +54,7 @@ export function TaskCard({
<div style={{ marginLeft: `${depth}rem` }}>
<RunPanel
selected={isSelected}
onClick={() => setSelectedId(id)}
onClick={() => selectedTask(id)}
styleName={style?.style}
>
<RunPanelHeader
@@ -158,7 +159,7 @@ export function TaskCard({
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 100 }}>
<TaskCard
selectedId={selectedId}
setSelectedId={setSelectedId}
selectedTask={selectedTask}
isLast={index === subtasks.length - 1}
depth={depth + 1}
{...subtask}
@@ -1,7 +1,13 @@
import { BoltIcon } from "@heroicons/react/24/solid";
import {
Outlet,
useLocation,
useNavigate,
useNavigation,
} from "@remix-run/react";
import { LoaderArgs } from "@remix-run/server-runtime";
import { Fragment, useMemo, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useCallback, useMemo, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { environmentTitle } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -21,30 +27,35 @@ import { RunStatusIcon, runStatusTitle } from "~/components/runs/RunStatuses";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunPresenter, Task } from "~/presenters/RunPresenter.server";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatDateTime, formatDuration } from "~/utils";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { jobPath } from "~/utils/pathBuilder";
import { Detail } from "./DetailView";
import {
RunPanel,
RunPanelBody,
RunPanelDescription,
RunPanelElements,
RunPanelHeader,
RunPanelIconElement,
RunPanelIconSection,
RunPanelIconTitle,
TaskSeparator,
} from "./RunCard";
import { TaskStatusIcon } from "./TaskStatus";
import { TaskCard } from "./TaskCard";
import { taskPath, eventPath } from "~/utils/pathBuilder";
import { usePathName } from "~/hooks/usePathName";
import { Callout } from "~/components/primitives/Callout";
export const loader = async ({ request, params }: LoaderArgs) => {
const userId = await requireUserId(request);
const { jobParam, runParam } = params;
const {
organizationSlug,
projectParam,
jobParam,
runParam,
eventParam,
taskParam,
} = params;
invariant(organizationSlug, "organizationSlug not found");
invariant(projectParam, "projectParam not found");
invariant(jobParam, "jobParam not found");
invariant(runParam, "runParam not found");
@@ -60,6 +71,18 @@ export const loader = async ({ request, params }: LoaderArgs) => {
});
}
if (!eventParam && !taskParam) {
return redirect(
eventPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ id: jobParam },
{ id: runParam },
run.event.id
)
);
}
return typedjson({
run,
});
@@ -71,39 +94,37 @@ export const handle: Handle = {
},
};
const taskPattern = /\/tasks\/(.*)/;
const eventPattern = /\/events\/(.*)/;
export default function Page() {
const { run } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const job = useJob();
const [selectedId, setSelectedId] = useState<string | undefined>(
run.event.id
);
const navigate = useNavigate();
const flatTasks = useMemo(() => {
const tasks: Task[] = [];
const queue: Task[] = [...run.tasks];
while (queue.length > 0) {
const task = queue.shift();
if (!task) continue;
tasks.push(task);
if (task.subtasks) {
queue.push(...task.subtasks);
}
const selectedTask = useCallback((id: string) => {
navigate(taskPath(organization, project, job, run, id));
}, []);
const selectedEvent = useCallback((id: string) => {
navigate(eventPath(organization, project, job, run, id));
}, []);
const pathName = usePathName();
const selectedId = useMemo(() => {
const taskMatch = pathName.match(taskPattern);
const taskId = taskMatch ? taskMatch[1] : undefined;
if (taskId) {
return taskId;
}
return tasks;
}, [run]);
const selectedItem = useMemo(() => {
if (!selectedId) return undefined;
if (selectedId === run.event.id)
return {
type: "trigger" as const,
trigger: { ...run.event, icon: job.event.icon, title: job.event.title },
};
const task = flatTasks.find((task) => task.id === selectedId);
if (task) return { type: "task" as const, task };
}, [selectedId, run]);
const eventMatch = pathName.match(eventPattern);
const eventId = eventMatch ? eventMatch[1] : undefined;
return eventId;
}, [pathName]);
return (
<PageContainer>
@@ -179,7 +200,7 @@ export default function Page() {
<Header2 className="mb-2">Trigger</Header2>
<RunPanel
selected={run.event.id === selectedId}
onClick={() => setSelectedId(run.event.id)}
onClick={() => selectedEvent(run.event.id)}
>
<RunPanelHeader
icon={<BoltIcon className="h-5 w-5 text-orange-500" />}
@@ -222,7 +243,7 @@ export default function Page() {
<TaskCard
key={task.id}
selectedId={selectedId}
setSelectedId={setSelectedId}
selectedTask={selectedTask}
isLast={isLast}
depth={0}
{...task}
@@ -234,14 +255,10 @@ export default function Page() {
{/* Detail view */}
<div className="overflow-y-auto py-4 pr-4">
<Header2 className="mb-2">Detail</Header2>
{!selectedItem ? (
<RunPanel selected={false} className="h-full">
<Paragraph variant="base" className="p-4">
Nothing selected
</Paragraph>
</RunPanel>
{selectedId ? (
<Outlet />
) : (
<Detail {...selectedItem} />
<Callout variant="info">Select a task or trigger</Callout>
)}
</div>
</div>
+22
View File
@@ -152,6 +152,28 @@ export function runParam(run: RunForPath) {
return run.id;
}
// Task
export function taskPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
run: RunForPath,
taskId: string
) {
return `${runPath(organization, project, job, run)}/tasks/${taskId}`;
}
// Event
export function eventPath(
organization: OrgForPath,
project: ProjectForPath,
job: JobForPath,
run: RunForPath,
eventId: string
) {
return `${runPath(organization, project, job, run)}/events/${eventId}`;
}
// Docs
const docsRoot = "https://docs.trigger.dev";
@@ -6,6 +6,8 @@ export const DisplayElementSchema = z.object({
url: z.string().optional(),
});
export const DisplayElementsSchema = z.array(DisplayElementSchema);
export type DisplayElement = z.infer<typeof DisplayElementSchema>;
export const StyleSchema = z.object({