diff --git a/apps/webapp/app/hooks/usePathName.ts b/apps/webapp/app/hooks/usePathName.ts new file mode 100644 index 000000000..629d736e7 --- /dev/null +++ b/apps/webapp/app/hooks/usePathName.ts @@ -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; +} diff --git a/apps/webapp/app/presenters/EventDetailsPresenter.server.ts b/apps/webapp/app/presenters/EventDetailsPresenter.server.ts new file mode 100644 index 000000000..4c14ab2ac --- /dev/null +++ b/apps/webapp/app/presenters/EventDetailsPresenter.server.ts @@ -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> +>; + +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; + } +} diff --git a/apps/webapp/app/presenters/RunPresenter.server.ts b/apps/webapp/app/presenters/RunPresenter.server.ts index e2fee9d31..b0429786f 100644 --- a/apps/webapp/app/presenters/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/RunPresenter.server.ts @@ -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, 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 }; - // } } diff --git a/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts new file mode 100644 index 000000000..7f62a4561 --- /dev/null +++ b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts @@ -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> +>; + +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, + elements: + task.elements == null + ? [] + : z.array(DisplayElementSchema).parse(task.elements), + style: task.style ? StyleSchema.parse(task.style) : undefined, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam._index/route.tsx new file mode 100644 index 000000000..c36ff5bcc --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam._index/route.tsx @@ -0,0 +1,5 @@ +import { Paragraph } from "~/components/primitives/Paragraph"; + +export default function Page() { + Nothing selected; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.events.$eventParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.events.$eventParam/route.tsx new file mode 100644 index 000000000..1e05f860b --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.events.$eventParam/route.tsx @@ -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(); + const job = useJob(); + + const { id, name, payload, timestamp, deliveredAt } = event; + + return ( + + + +
+ + + {deliveredAt && ( + + )} + + +
+
+ Payload + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/DetailView.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx similarity index 60% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/DetailView.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx index 4bebe3349..aecd09147 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/DetailView.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx @@ -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 ; - case "trigger": - return ; - 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(); + + const { + name, + icon, + startedAt, + completedAt, + status, + delayUntil, + params, + elements, + output, + } = task; + return ( ); } - -export function EventDetail({ - icon, - title, - id, - name, - payload, - timestamp, - deliveredAt, -}: Trigger) { - return ( - - - -
- - - {deliveredAt && ( - - )} - - -
-
- Payload - -
-
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/TaskCard.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/TaskCard.tsx index 2c9ba0df9..584c405b1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/TaskCard.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/TaskCard.tsx @@ -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({
setSelectedId(id)} + onClick={() => selectedTask(id)} styleName={style?.style} > { 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(); const organization = useOrganization(); const project = useProject(); const job = useJob(); - const [selectedId, setSelectedId] = useState( - 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 ( @@ -179,7 +200,7 @@ export default function Page() { Trigger setSelectedId(run.event.id)} + onClick={() => selectedEvent(run.event.id)} > } @@ -222,7 +243,7 @@ export default function Page() { Detail - {!selectedItem ? ( - - - Nothing selected - - + {selectedId ? ( + ) : ( - + Select a task or trigger )}
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index aca4eacbe..3f1009148 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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"; diff --git a/packages/internal/src/schemas/elements.ts b/packages/internal/src/schemas/elements.ts index a112170b1..d702e3d0e 100644 --- a/packages/internal/src/schemas/elements.ts +++ b/packages/internal/src/schemas/elements.ts @@ -6,6 +6,8 @@ export const DisplayElementSchema = z.object({ url: z.string().optional(), }); +export const DisplayElementsSchema = z.array(DisplayElementSchema); + export type DisplayElement = z.infer; export const StyleSchema = z.object({