Merge pull request #185 from SSHari/patch-573

feat: 🎸 move get project jobs to lower level in app hierarchy
This commit is contained in:
Eric Allam
2023-07-19 22:22:37 +01:00
committed by GitHub
13 changed files with 288 additions and 206 deletions
@@ -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";
@@ -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[] }) {
>
<PopoverSectionHeader title="Jobs" />
<div className="flex flex-col gap-1 p-1">
{project.jobs.map((job) => {
{projectJobs.map((job) => {
const isSelected = job.id === currentJob?.id;
return (
<Link
+1 -1
View File
@@ -1,4 +1,4 @@
import { ProjectJob } from "./useProject";
import { ProjectJob } from "./useJobs";
import { useTextFilter } from "./useTextFilter";
export function useFilterJobs(jobs: ProjectJob[]) {
+3 -9
View File
@@ -1,10 +1,6 @@
import {
UseDataFunctionReturn,
useTypedRouteLoaderData,
} from "remix-typedjson";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
import { useOptionalProject } from "./useProject";
import { useChanged } from "./useChanged";
import { RouteMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
@@ -14,18 +10,16 @@ export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
export const jobMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
export function useOptionalJob(matches?: RouteMatch[]) {
const project = useOptionalProject(matches);
const routeMatch = useTypedMatchesData<typeof loader>({
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[]) {
+26
View File
@@ -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<typeof loader>({
id: jobsMatchId,
matches,
});
return routeMatch?.projectJobs;
}
export function useJobs(matches?: RouteMatch[]) {
const jobs = useOptionalJobs(matches);
invariant(jobs, "Jobs must be defined");
return jobs;
}
-1
View File
@@ -6,7 +6,6 @@ import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
export type ProjectJob = MatchedProject["jobs"][number];
export const projectMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam";
@@ -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),
};
}
}
@@ -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);
}
}
@@ -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) => ({
@@ -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<typeof loader>();
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() {
<PageInfoProperty
icon={"job"}
label={"Active Jobs"}
value={project.jobs.length}
value={jobs.length}
/>
</PageInfoGroup>
</PageInfoRow>
@@ -80,7 +105,7 @@ export default function Page() {
"rgb(217 70 239)",
]}
/> */}
<Help defaultOpen={project.jobs.length === 0}>
<Help defaultOpen={jobs.length === 0}>
{(open) => (
<div
className={cn(
@@ -89,10 +114,8 @@ export default function Page() {
)}
>
<div>
{project.jobs.length > 0 &&
project.jobs.some(
(j) => j.hasIntegrationsRequiringAction
) && (
{jobs.length > 0 &&
jobs.some((j) => j.hasIntegrationsRequiringAction) && (
<Callout
variant="error"
to={projectIntegrationsPath(organization, project)}
@@ -103,7 +126,7 @@ export default function Page() {
</Callout>
)}
<div className="mb-2 flex items-center justify-between gap-x-2">
{project.jobs.length === 0 ? (
{jobs.length === 0 ? (
<Header2>Jobs</Header2>
) : (
<Input
@@ -117,7 +140,7 @@ export default function Page() {
)}
<HelpTrigger title="How do I setup my Project?" />
</div>
{project.jobs.length === 0 ? (
{jobs.length === 0 ? (
<div
className={
"flex w-full justify-center gap-x-4 rounded-md border border-dashed border-indigo-800 px-5 py-8"
@@ -134,7 +157,7 @@ export default function Page() {
noResultsText={`No Jobs match ${filterText}. Try a different search
query.`}
/>
{project.jobs.length === 1 ? (
{jobs.length === 1 ? (
<Callout
variant="docs"
to={docsPath("documentation/quickstart#your-first-job")}
@@ -125,7 +125,6 @@ export default function Page() {
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<PageContainer>
<PageHeader>
@@ -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<typeof loader>();
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 (
<Help defaultOpen={projectJobs.length === 0}>
<Help defaultOpen={jobs.length === 0}>
{(open) => (
<div
className={cn(
@@ -69,7 +60,7 @@ export default function Page() {
>
<div className="grow">
<div className="mb-2 flex items-center justify-between gap-x-2">
{projectJobs.length === 0 ? (
{jobs.length === 0 ? (
<Header2>Jobs using this integration will appear here</Header2>
) : (
<Input
@@ -83,7 +74,7 @@ export default function Page() {
)}
<HelpTrigger title="How do I use this integration?" />
</div>
{projectJobs.length === 0 ? (
{jobs.length === 0 ? (
<>
<JobSkeleton />
</>
@@ -91,7 +82,7 @@ export default function Page() {
<JobsTable
jobs={filteredItems}
noResultsText={
projectJobs.length === 0
jobs.length === 0
? `No Jobs are currently using "${client.title}"`
: `No Jobs found for "${filterText}"`
}
@@ -24,6 +24,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { projectMatchId, useProject } from "~/hooks/useProject";
import { useOptionalRun } from "~/hooks/useRun";
import { findJobByParams } from "~/models/job.server";
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
@@ -40,12 +41,17 @@ export const loader = async ({ request, params }: LoaderArgs) => {
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,
});
};