diff --git a/apps/webapp/app/components/jobs/JobsTable.tsx b/apps/webapp/app/components/jobs/JobsTable.tsx index 954eb017f..7e2eb0612 100644 --- a/apps/webapp/app/components/jobs/JobsTable.tsx +++ b/apps/webapp/app/components/jobs/JobsTable.tsx @@ -15,7 +15,8 @@ import { } from "../primitives/Table"; import { SimpleTooltip } from "../primitives/Tooltip"; import { runStatusTitle } from "../runs/RunStatuses"; -import { ProjectJob, useProject } from "~/hooks/useProject"; +import { ProjectJob } from "~/hooks/useJobs"; +import { useProject } from "~/hooks/useProject"; import { useOrganization } from "~/hooks/useOrganizations"; import { JobRunStatus } from "~/models/job.server"; import { cn } from "~/utils/cn"; diff --git a/apps/webapp/app/components/navigation/JobsMenu.tsx b/apps/webapp/app/components/navigation/JobsMenu.tsx index 46afe77ce..48c56d811 100644 --- a/apps/webapp/app/components/navigation/JobsMenu.tsx +++ b/apps/webapp/app/components/navigation/JobsMenu.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useJob } from "~/hooks/useJob"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useJobs } from "~/hooks/useJobs"; import { cn } from "~/utils/cn"; import { jobPath } from "~/utils/pathBuilder"; import { LabelValueStack } from "../primitives/LabelValueStack"; @@ -18,6 +19,7 @@ export function JobsMenu({ matches }: { matches: RouteMatch[] }) { const [isOpen, setIsOpen] = useState(false); const organization = useOrganization(matches); const project = useProject(matches); + const projectJobs = useJobs(matches); const currentJob = useJob(matches); return ( @@ -33,7 +35,7 @@ export function JobsMenu({ matches }: { matches: RouteMatch[] }) { >
- {project.jobs.map((job) => { + {projectJobs.map((job) => { const isSelected = job.id === currentJob?.id; return ( ["job"]; export const jobMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam"; export function useOptionalJob(matches?: RouteMatch[]) { - const project = useOptionalProject(matches); const routeMatch = useTypedMatchesData({ id: jobMatchId, matches, }); - if (!project || !routeMatch || !routeMatch.job) { + if (!routeMatch || !routeMatch.job) { return undefined; } - //get the job from the list on the project - return project.jobs.find((j) => j.id === routeMatch.job.id); + return routeMatch.projectJobs.find((j) => j.id === routeMatch.job.id); } export function useJob(matches?: RouteMatch[]) { diff --git a/apps/webapp/app/hooks/useJobs.tsx b/apps/webapp/app/hooks/useJobs.tsx new file mode 100644 index 000000000..103f423c5 --- /dev/null +++ b/apps/webapp/app/hooks/useJobs.tsx @@ -0,0 +1,26 @@ +import { UseDataFunctionReturn } from "remix-typedjson"; +import invariant from "tiny-invariant"; +import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route"; +import { RouteMatch } from "@remix-run/react"; +import { useTypedMatchesData } from "./useTypedMatchData"; + +export type ProjectJob = UseDataFunctionReturn< + typeof loader +>["projectJobs"][number]; + +export const jobsMatchId = + "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam"; +export function useOptionalJobs(matches?: RouteMatch[]) { + const routeMatch = useTypedMatchesData({ + id: jobsMatchId, + matches, + }); + + return routeMatch?.projectJobs; +} + +export function useJobs(matches?: RouteMatch[]) { + const jobs = useOptionalJobs(matches); + invariant(jobs, "Jobs must be defined"); + return jobs; +} diff --git a/apps/webapp/app/hooks/useProject.tsx b/apps/webapp/app/hooks/useProject.tsx index 06fcf42af..79551311f 100644 --- a/apps/webapp/app/hooks/useProject.tsx +++ b/apps/webapp/app/hooks/useProject.tsx @@ -6,7 +6,6 @@ import { useChanged } from "./useChanged"; import { useTypedMatchesData } from "./useTypedMatchData"; export type MatchedProject = UseDataFunctionReturn["project"]; -export type ProjectJob = MatchedProject["jobs"][number]; export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam"; diff --git a/apps/webapp/app/presenters/IntegrationClientJobsPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientJobsPresenter.server.ts deleted file mode 100644 index 79e682b4b..000000000 --- a/apps/webapp/app/presenters/IntegrationClientJobsPresenter.server.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { User } from "@trigger.dev/database"; -import { PrismaClient, prisma } from "~/db.server"; -import { Organization } from "~/models/organization.server"; -import { Project } from "~/models/project.server"; - -//Only get the job ids, otherwise we're just fetching data that's already been fetched -export class IntegrationClientJobsPresenter { - #prismaClient: PrismaClient; - - constructor(prismaClient: PrismaClient = prisma) { - this.#prismaClient = prismaClient; - } - - public async call({ - userId, - organizationSlug, - projectSlug, - clientSlug, - }: { - userId: User["id"]; - organizationSlug: Organization["slug"]; - projectSlug: Project["slug"]; - clientSlug: string; - }) { - const jobs = await this.#prismaClient.job.findMany({ - select: { - id: true, - }, - where: { - internal: false, - organization: { - slug: organizationSlug, - members: { - some: { - userId, - }, - }, - }, - project: { - slug: projectSlug, - }, - integrations: { - some: { - integration: { - slug: clientSlug, - }, - }, - }, - }, - orderBy: [{ title: "asc" }], - }); - - return { - jobs: jobs.map((j) => j), - }; - } -} diff --git a/apps/webapp/app/presenters/JobListPresenter.server.ts b/apps/webapp/app/presenters/JobListPresenter.server.ts new file mode 100644 index 000000000..f9b283d89 --- /dev/null +++ b/apps/webapp/app/presenters/JobListPresenter.server.ts @@ -0,0 +1,193 @@ +import { + DisplayProperty, + DisplayPropertySchema, + EventSpecificationSchema, +} from "@trigger.dev/internal"; +import { PrismaClient, Prisma, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; +import { z } from "zod"; + +export class JobListPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + integrationSlug, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug?: Organization["slug"]; + integrationSlug?: string; + }) { + const orgWhere: Prisma.JobWhereInput["organization"] = organizationSlug + ? { slug: organizationSlug, members: { some: { userId } } } + : { members: { some: { userId } } }; + + const integrationsWhere: Prisma.JobWhereInput["integrations"] = + integrationSlug + ? { some: { integration: { slug: integrationSlug } } } + : {}; + + const jobs = await this.#prismaClient.job.findMany({ + select: { + id: true, + slug: true, + title: true, + aliases: { + select: { + version: { + select: { + version: true, + eventSpecification: true, + properties: true, + runs: { + select: { + createdAt: true, + status: true, + }, + take: 1, + orderBy: [{ createdAt: "desc" }], + }, + integrations: { + select: { + key: true, + integration: { + select: { + slug: true, + definition: true, + setupStatus: true, + }, + }, + }, + }, + }, + }, + environment: { + select: { + type: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + where: { + name: "latest", + }, + }, + dynamicTriggers: { + select: { + type: true, + }, + }, + }, + where: { + internal: false, + organization: orgWhere, + project: { + slug: projectSlug, + }, + integrations: integrationsWhere, + }, + orderBy: [{ title: "asc" }], + }); + + return jobs + .map((job) => { + //the best alias to select: + // 1. Logged-in user dev + // 2. Prod + // 3. Any other user's dev + const sortedAliases = job.aliases.sort((a, b) => { + if ( + a.environment.type === "DEVELOPMENT" && + a.environment.orgMember?.userId === userId + ) { + return -1; + } + + if ( + b.environment.type === "DEVELOPMENT" && + b.environment.orgMember?.userId === userId + ) { + return 1; + } + + if (a.environment.type === "PRODUCTION") { + return -1; + } + + if (b.environment.type === "PRODUCTION") { + return 1; + } + + return 0; + }); + + const alias = sortedAliases.at(0); + + if (!alias) { + throw new Error( + `No aliases found for job ${job.id}, this should never happen.` + ); + } + + const eventSpecification = EventSpecificationSchema.parse( + alias.version.eventSpecification + ); + + const lastRun = + alias.version.runs[0] != null ? alias.version.runs[0] : undefined; + + const integrations = alias.version.integrations.map((integration) => ({ + key: integration.key, + title: integration.integration.slug, + icon: integration.integration.definition.id, + setupStatus: integration.integration.setupStatus, + })); + + let properties: DisplayProperty[] = []; + + if (eventSpecification.properties) { + properties = [...properties, ...eventSpecification.properties]; + } + + if (alias.version.properties) { + const versionProperties = z + .array(DisplayPropertySchema) + .parse(alias.version.properties); + properties = [...properties, ...versionProperties]; + } + + return { + id: job.id, + slug: job.slug, + title: job.title, + version: alias.version.version, + dynamic: job.dynamicTriggers.length > 0, + event: { + title: eventSpecification.title, + icon: eventSpecification.icon, + source: eventSpecification.source, + }, + integrations, + hasIntegrationsRequiringAction: integrations.some( + (i) => i.setupStatus === "MISSING_FIELDS" + ), + lastRun, + properties, + }; + }) + .filter(Boolean); + } +} diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index 41cbf98ca..93877d363 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -1,13 +1,6 @@ -import { - DisplayProperty, - DisplayPropertySchema, - EventSpecificationSchema, - IntegrationMetadataSchema, -} from "@trigger.dev/internal"; import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; -import { z } from "zod"; export class ProjectPresenter { #prismaClient: PrismaClient; @@ -140,95 +133,6 @@ export class ProjectPresenter { organizationId: project.organizationId, createdAt: project.createdAt, updatedAt: project.updatedAt, - jobs: project.jobs - .map((job) => { - //the best alias to select: - // 1. Logged-in user dev - // 2. Prod - // 3. Any other user's dev - const sortedAliases = job.aliases.sort((a, b) => { - if ( - a.environment.type === "DEVELOPMENT" && - a.environment.orgMember?.userId === userId - ) { - return -1; - } - - if ( - b.environment.type === "DEVELOPMENT" && - b.environment.orgMember?.userId === userId - ) { - return 1; - } - - if (a.environment.type === "PRODUCTION") { - return -1; - } - - if (b.environment.type === "PRODUCTION") { - return 1; - } - - return 0; - }); - - const alias = sortedAliases.at(0); - - if (!alias) { - throw new Error( - `No aliases found for job ${job.id}, this should never happen.` - ); - } - - const eventSpecification = EventSpecificationSchema.parse( - alias.version.eventSpecification - ); - - const lastRun = - alias.version.runs[0] != null ? alias.version.runs[0] : undefined; - - const integrations = alias.version.integrations.map( - (integration) => ({ - key: integration.key, - title: integration.integration.slug, - icon: integration.integration.definition.id, - setupStatus: integration.integration.setupStatus, - }) - ); - - let properties: DisplayProperty[] = []; - - if (eventSpecification.properties) { - properties = [...properties, ...eventSpecification.properties]; - } - - if (alias.version.properties) { - const versionProperties = z - .array(DisplayPropertySchema) - .parse(alias.version.properties); - properties = [...properties, ...versionProperties]; - } - - return { - id: job.id, - slug: job.slug, - title: job.title, - version: alias.version.version, - dynamic: job.dynamicTriggers.length > 0, - event: { - title: eventSpecification.title, - icon: eventSpecification.icon, - source: eventSpecification.source, - }, - integrations, - hasIntegrationsRequiringAction: integrations.some( - (i) => i.setupStatus === "MISSING_FIELDS" - ), - lastRun, - properties, - }; - }) - .filter(Boolean), hasInactiveExternalTriggers: project._count.sources > 0, hasUnconfiguredIntegrations: project.organization._count.integrations > 0, environments: project.environments.map((environment) => ({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx index 29b4f69df..84ed76b2b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx @@ -1,3 +1,5 @@ +import { LoaderArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import Confetti from "react-confetti"; import { HowToSetupYourProject } from "~/components/helpContent/HelpContentText"; import { JobsTable } from "~/components/jobs/JobsTable"; @@ -25,8 +27,32 @@ import { docsPath, projectIntegrationsPath, trimTrailingSlash, + ProjectParamSchema, } from "~/utils/pathBuilder"; import { BreadcrumbLink } from "~/components/navigation/NavBar"; +import { requireUserId } from "~/services/session.server"; +import { JobListPresenter } from "~/presenters/JobListPresenter.server"; + +export const loader = async ({ request, params }: LoaderArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + try { + const presenter = new JobListPresenter(); + const jobs = await presenter.call({ userId, projectSlug: projectParam }); + + return typedjson({ + jobs, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: + "Something went wrong, if this problem persists please contact support.", + }); + } +}; export const handle: Handle = { breadcrumb: (match) => ( @@ -38,10 +64,9 @@ export const handle: Handle = { export default function Page() { const organization = useOrganization(); const project = useProject(); + const { jobs } = useTypedLoaderData(); - const { filterText, setFilterText, filteredItems } = useFilterJobs( - project.jobs - ); + const { filterText, setFilterText, filteredItems } = useFilterJobs(jobs); const { width, height } = useWindowSize(); @@ -56,7 +81,7 @@ export default function Page() { @@ -80,7 +105,7 @@ export default function Page() { "rgb(217 70 239)", ]} /> */} - + {(open) => (
- {project.jobs.length > 0 && - project.jobs.some( - (j) => j.hasIntegrationsRequiringAction - ) && ( + {jobs.length > 0 && + jobs.some((j) => j.hasIntegrationsRequiringAction) && ( )}
- {project.jobs.length === 0 ? ( + {jobs.length === 0 ? ( Jobs ) : (
- {project.jobs.length === 0 ? ( + {jobs.length === 0 ? (
- {project.jobs.length === 1 ? ( + {jobs.length === 1 ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam._index/route.tsx index 76a8b6f9a..a6c81e5e5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam._index/route.tsx @@ -1,4 +1,3 @@ -import { useMatches } from "@remix-run/react"; import { LoaderArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { HowToUseThisIntegration } from "~/components/helpContent/HelpContentText"; @@ -11,16 +10,13 @@ import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help"; import { Input } from "~/components/primitives/Input"; import { useFilterJobs } from "~/hooks/useFilterJobs"; import { useIntegrationClient } from "~/hooks/useIntegrationClient"; -import { useProject } from "~/hooks/useProject"; -import { useTypedMatchData } from "~/hooks/useTypedMatchData"; -import { IntegrationClientJobsPresenter } from "~/presenters/IntegrationClientJobsPresenter.server"; +import { JobListPresenter } from "~/presenters/JobListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { Handle } from "~/utils/handle"; import { IntegrationClientParamSchema, docsIntegrationPath, - docsRoot, trimTrailingSlash, } from "~/utils/pathBuilder"; @@ -29,12 +25,13 @@ export const loader = async ({ request, params }: LoaderArgs) => { const { organizationSlug, projectParam, clientParam } = IntegrationClientParamSchema.parse(params); - const presenter = new IntegrationClientJobsPresenter(); - const { jobs } = await presenter.call({ - userId: userId, - organizationSlug, + const jobsPresenter = new JobListPresenter(); + + const jobs = await jobsPresenter.call({ + userId, projectSlug: projectParam, - clientSlug: clientParam, + organizationSlug, + integrationSlug: clientParam, }); return typedjson({ jobs }); @@ -49,17 +46,11 @@ export const handle: Handle = { export default function Page() { const { jobs } = useTypedLoaderData(); const client = useIntegrationClient(); - const project = useProject(); - const projectJobs = project.jobs.filter((job) => - jobs.map((j) => j.id).includes(job.id) - ); - - const { filterText, setFilterText, filteredItems } = - useFilterJobs(projectJobs); + const { filterText, setFilterText, filteredItems } = useFilterJobs(jobs); return ( - + {(open) => (
- {projectJobs.length === 0 ? ( + {jobs.length === 0 ? ( Jobs using this integration will appear here ) : (
- {projectJobs.length === 0 ? ( + {jobs.length === 0 ? ( <> @@ -91,7 +82,7 @@ export default function Page() { { const { jobParam, projectParam, organizationSlug } = JobParamsSchema.parse(params); - const job = await findJobByParams({ - userId, - slug: jobParam, - projectSlug: projectParam, - organizationSlug, - }); + const jobsPresenter = new JobListPresenter(); + + const [job, projectJobs] = await Promise.all([ + findJobByParams({ + userId, + slug: jobParam, + projectSlug: projectParam, + organizationSlug, + }), + jobsPresenter.call({ userId, projectSlug: projectParam }), + ]); if (job === null) { throw new Response("Not Found", { @@ -59,6 +65,7 @@ export const loader = async ({ request, params }: LoaderArgs) => { return typedjson({ job, + projectJobs, }); };