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,
});
};