Skip to content

Commit af557b7

Browse files
committed
Limit project count and display a rich error toast (with title and button now)
1 parent ce5fe1e commit af557b7

File tree

6 files changed

+164
-22
lines changed

6 files changed

+164
-22
lines changed

apps/webapp/app/components/primitives/Toast.tsx

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
1+
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
22
import { CheckCircleIcon } from "@heroicons/react/24/solid";
33
import { Toaster, toast } from "sonner";
4-
54
import { useTypedLoaderData } from "remix-typedjson";
6-
import { loader } from "~/root";
5+
import { type loader } from "~/root";
76
import { useEffect } from "react";
87
import { Paragraph } from "./Paragraph";
98
import { cn } from "~/utils/cn";
9+
import { type ToastMessageAction } from "~/models/message.server";
10+
import { Header2, Header3 } from "./Headers";
11+
import { Button, LinkButton } from "./Buttons";
12+
import { Feedback } from "../Feedback";
13+
import assertNever from "assert-never";
14+
import { assertExhaustive } from "@trigger.dev/core";
1015

1116
const defaultToastDuration = 5000;
1217
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -19,9 +24,22 @@ export function Toast() {
1924
}
2025
const { message, type, options } = toastMessage;
2126

22-
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
23-
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
24-
});
27+
const ephemeral = options.action ? false : options.ephemeral;
28+
29+
toast.custom(
30+
(t) => (
31+
<ToastUI
32+
variant={type}
33+
message={message}
34+
t={t as string}
35+
title={options.title}
36+
action={options.action}
37+
/>
38+
),
39+
{
40+
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
41+
}
42+
);
2543
}, [toastMessage]);
2644

2745
return <Toaster />;
@@ -32,11 +50,15 @@ export function ToastUI({
3250
message,
3351
t,
3452
toastWidth = 356, // Default width, matches what sonner provides by default
53+
title,
54+
action,
3555
}: {
3656
variant: "error" | "success";
3757
message: string;
3858
t: string;
3959
toastWidth?: string | number;
60+
title?: string;
61+
action?: ToastMessageAction;
4062
}) {
4163
return (
4264
<div
@@ -51,11 +73,17 @@ export function ToastUI({
5173
>
5274
<div className="flex w-full items-start gap-2 rounded-lg p-3">
5375
{variant === "success" ? (
54-
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
76+
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
5577
) : (
56-
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
78+
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
5779
)}
58-
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
80+
<div className="flex flex-col">
81+
{title && <Header2 className="pt-1">{title}</Header2>}
82+
<Paragraph variant="small/dimmed" className="py-1">
83+
{message}
84+
</Paragraph>
85+
<Action action={action} toastId={t} />
86+
</div>
5987
<button
6088
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
6189
onClick={() => toast.dismiss(t)}
@@ -66,3 +94,38 @@ export function ToastUI({
6694
</div>
6795
);
6896
}
97+
98+
function Action({ action, toastId }: { action?: ToastMessageAction; toastId: string }) {
99+
if (!action) return null;
100+
101+
switch (action.action.type) {
102+
case "link": {
103+
return (
104+
<LinkButton variant={action.variant ?? "secondary/small"} to={action.action.path}>
105+
{action.label}
106+
</LinkButton>
107+
);
108+
}
109+
case "help": {
110+
return (
111+
<Feedback
112+
button={
113+
<Button
114+
variant={action.variant ?? "secondary/small"}
115+
LeadingIcon={EnvelopeIcon}
116+
onClick={(e) => {
117+
e.preventDefault();
118+
toast.dismiss(toastId);
119+
}}
120+
>
121+
{action.label}
122+
</Button>
123+
}
124+
/>
125+
);
126+
}
127+
default: {
128+
return null;
129+
}
130+
}
131+
}

apps/webapp/app/models/message.server.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
1-
import { json, Session } from "@remix-run/node";
2-
import { createCookieSessionStorage } from "@remix-run/node";
1+
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
32
import { redirect, typedjson } from "remix-typedjson";
3+
import { ButtonVariant } from "~/components/primitives/Buttons";
44
import { env } from "~/env.server";
5+
import { type FeedbackType } from "~/routes/resources.feedback";
56

67
export type ToastMessage = {
78
message: string;
89
type: "success" | "error";
910
options: Required<ToastMessageOptions>;
1011
};
1112

13+
export type ToastMessageAction = {
14+
label: string;
15+
variant?: ButtonVariant;
16+
action:
17+
| {
18+
type: "link";
19+
path: string;
20+
}
21+
| {
22+
type: "help";
23+
feedbackType: FeedbackType;
24+
};
25+
};
26+
1227
export type ToastMessageOptions = {
28+
title?: string;
1329
/** Ephemeral means it disappears after a delay, defaults to true */
1430
ephemeral?: boolean;
31+
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
32+
action?: ToastMessageAction;
1533
};
1634

1735
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
@@ -36,6 +54,7 @@ export function setSuccessMessage(
3654
message,
3755
type: "success",
3856
options: {
57+
...options,
3958
ephemeral: options?.ephemeral ?? true,
4059
},
4160
} as ToastMessage);
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
4665
message,
4766
type: "error",
4867
options: {
68+
...options,
4969
ephemeral: options?.ephemeral ?? true,
5070
},
5171
} as ToastMessage);

apps/webapp/app/models/project.server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,26 @@ type Options = {
1616
version: "v2" | "v3";
1717
};
1818

19+
export class ExceededProjectLimitError extends Error {
20+
constructor(message: string) {
21+
super(message);
22+
this.name = "ExceededProjectLimitError";
23+
}
24+
}
25+
1926
export async function createProject(
2027
{ organizationSlug, name, userId, version }: Options,
2128
attemptCount = 0
2229
): Promise<Project & { organization: Organization }> {
2330
//check the user has permissions to do this
2431
const organization = await prisma.organization.findFirst({
32+
select: {
33+
id: true,
34+
slug: true,
35+
v3Enabled: true,
36+
maximumConcurrencyLimit: true,
37+
maximumProjectCount: true,
38+
},
2539
where: {
2640
slug: organizationSlug,
2741
members: { some: { userId } },
@@ -40,6 +54,19 @@ export async function createProject(
4054
}
4155
}
4256

57+
const projectCount = await prisma.project.count({
58+
where: {
59+
organizationId: organization.id,
60+
deletedAt: null,
61+
},
62+
});
63+
64+
if (projectCount >= organization.maximumProjectCount) {
65+
throw new ExceededProjectLimitError(
66+
`Organization ${organization.slug} has reached the maximum number of projects (${organization.maximumProjectCount}). You can request more by contacting help in the bottom-left.`
67+
);
68+
}
69+
4370
//ensure the slug is globally unique
4471
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
4572
const projectWithSameSlug = await prisma.project.findFirst({

apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import { Label } from "~/components/primitives/Label";
2121
import { ButtonSpinner } from "~/components/primitives/Spinner";
2222
import { prisma } from "~/db.server";
2323
import { featuresForRequest } from "~/features.server";
24-
import { redirectWithSuccessMessage } from "~/models/message.server";
25-
import { createProject } from "~/models/project.server";
24+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
25+
import { createProject, ExceededProjectLimitError } from "~/models/project.server";
2626
import { requireUserId } from "~/services/session.server";
2727
import {
28+
newProjectPath,
2829
OrganizationParamsSchema,
2930
organizationPath,
3031
selectPlanPath,
@@ -114,8 +115,29 @@ export const action: ActionFunction = async ({ request, params }) => {
114115
request,
115116
`${submission.value.projectName} created`
116117
);
117-
} catch (error: any) {
118-
return json({ errors: { body: error.message } }, { status: 400 });
118+
} catch (error) {
119+
if (error instanceof ExceededProjectLimitError) {
120+
return redirectWithErrorMessage(
121+
newProjectPath({ slug: organizationSlug }),
122+
request,
123+
error.message,
124+
{
125+
title: "Failed to create project",
126+
action: {
127+
label: "Request more projects",
128+
variant: "secondary/small",
129+
action: { type: "help", feedbackType: "help" },
130+
},
131+
}
132+
);
133+
}
134+
135+
return redirectWithErrorMessage(
136+
newProjectPath({ slug: organizationSlug }),
137+
request,
138+
error instanceof Error ? error.message : "Something went wrong",
139+
{ ephemeral: false }
140+
);
119141
}
120142
};
121143

apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CreateProjectRequestBody,
55
GetProjectResponseBody,
66
GetProjectsResponseBody,
7+
tryCatch,
78
} from "@trigger.dev/core/v3";
89
import { z } from "zod";
910
import { prisma } from "~/db.server";
@@ -99,12 +100,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
99100
return json({ error: "Invalid request body" }, { status: 400 });
100101
}
101102

102-
const project = await createProject({
103-
organizationSlug: organization.slug,
104-
name: parsedBody.data.name,
105-
userId: authenticationResult.userId,
106-
version: "v3",
107-
});
103+
const [error, project] = await tryCatch(
104+
createProject({
105+
organizationSlug: organization.slug,
106+
name: parsedBody.data.name,
107+
userId: authenticationResult.userId,
108+
version: "v3",
109+
})
110+
);
111+
112+
if (error) {
113+
return json({ error: error.message }, { status: 400 });
114+
}
108115

109116
const result: GetProjectResponseBody = {
110117
id: project.id,

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,10 @@ export async function getEntitlement(
521521
}
522522
}
523523

524-
export async function projectCreated(organization: Organization, project: Project) {
524+
export async function projectCreated(
525+
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
526+
project: Project
527+
) {
525528
if (!isCloud()) {
526529
await createEnvironment({ organization, project, type: "STAGING" });
527530
await createEnvironment({

0 commit comments

Comments
 (0)