chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { CommandLineIcon, FolderIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import type { Prisma } from "@trigger.dev/database";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { TechnologyPicker } from "~/components/onboarding/TechnologyPicker";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject, ExceededProjectLimitError } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
newProjectPath,
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
selectPlanPath,
|
||||
v3ProjectPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const WORKING_ON_OTHER = "Other/not sure yet";
|
||||
const GOALS_OTHER = "Other/not sure yet";
|
||||
|
||||
const workingOnOptions = [
|
||||
"AI agent",
|
||||
"Media processing pipeline",
|
||||
"Media generation with AI",
|
||||
"Event-driven workflow",
|
||||
"Realtime streaming",
|
||||
"Internal tool or background job",
|
||||
WORKING_ON_OTHER,
|
||||
] as const;
|
||||
|
||||
const goalOptions = [
|
||||
"Ship a production workflow",
|
||||
"Prototype or explore",
|
||||
"Migrate an existing system",
|
||||
"Learn how Trigger works",
|
||||
"Evaluate against alternatives",
|
||||
GOALS_OTHER,
|
||||
] as const;
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function MultiSelectField({
|
||||
value,
|
||||
setValue,
|
||||
items,
|
||||
icon,
|
||||
}: {
|
||||
value: string[];
|
||||
setValue: (value: string[]) => void;
|
||||
items: string[];
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select<string[], string>
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
placeholder="Select some options"
|
||||
variant="secondary/small"
|
||||
dropdownIcon
|
||||
icon={icon}
|
||||
items={items}
|
||||
className="h-8 min-w-0 border-0 bg-background-hover pl-2 text-sm text-text-dimmed ring-border-bright transition hover:bg-secondary hover:text-text-dimmed hover:ring-1"
|
||||
text={(v) =>
|
||||
v.length === 0 ? undefined : (
|
||||
<span className="flex min-w-0 items-center text-text-bright">
|
||||
<span className="truncate">{v.slice(0, 2).join(", ")}</span>
|
||||
{v.length > 2 && <span className="ml-1 flex-none">+{v.length - 2} more</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item} value={item} checkPosition="left">
|
||||
<span className="text-text-bright">{item}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isActivated: true,
|
||||
v2Enabled: true,
|
||||
hasRequestedV3: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (isManagedCloud && !organization.isActivated) {
|
||||
return redirect(selectPlanPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const message = url.searchParams.get("message");
|
||||
|
||||
return typedjson({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
title: organization.title,
|
||||
slug: organizationSlug,
|
||||
projectsCount: organization._count.projects,
|
||||
isActivated: organization.isActivated,
|
||||
v2Enabled: organization.v2Enabled,
|
||||
hasRequestedV3: organization.hasRequestedV3,
|
||||
},
|
||||
defaultVersion: url.searchParams.get("version") ?? "v2",
|
||||
message: message ? decodeURIComponent(message) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
projectVersion: z.enum(["v2", "v3"]),
|
||||
workingOn: z.string().optional(),
|
||||
workingOnOther: z.string().optional(),
|
||||
technologies: z.string().optional(),
|
||||
technologiesOther: z.string().optional(),
|
||||
goals: z.string().optional(),
|
||||
goalsOther: z.string().optional(),
|
||||
workingOnPositions: z.string().optional(),
|
||||
goalsPositions: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
invariant(organizationSlug, "organizationSlug is required");
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parseWithZod(formData, { schema });
|
||||
|
||||
if (submission.status !== "success") {
|
||||
return json(submission.reply());
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
function safeParseStringArray(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const result = stringArraySchema.safeParse(JSON.parse(value));
|
||||
return result.success && result.data.length > 0 ? result.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const numberArraySchema = z.array(z.number());
|
||||
function safeParseNumberArray(value: string | undefined): number[] | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const result = numberArraySchema.safeParse(JSON.parse(value));
|
||||
return result.success && result.data.length > 0 ? result.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const onboardingData: Record<string, Prisma.InputJsonValue> = {};
|
||||
|
||||
const workingOn = safeParseStringArray(submission.value.workingOn);
|
||||
if (workingOn) {
|
||||
onboardingData.workingOn = workingOn;
|
||||
const workingOnPositions = safeParseNumberArray(submission.value.workingOnPositions);
|
||||
if (workingOnPositions) onboardingData.workingOnPositions = workingOnPositions;
|
||||
}
|
||||
|
||||
if (submission.value.workingOnOther) {
|
||||
onboardingData.workingOnOther = submission.value.workingOnOther;
|
||||
}
|
||||
|
||||
const technologies = safeParseStringArray(submission.value.technologies);
|
||||
if (technologies) onboardingData.technologies = technologies;
|
||||
|
||||
const technologiesOther = safeParseStringArray(submission.value.technologiesOther);
|
||||
if (technologiesOther) onboardingData.technologiesOther = technologiesOther;
|
||||
|
||||
const goals = safeParseStringArray(submission.value.goals);
|
||||
if (goals) {
|
||||
onboardingData.goals = goals;
|
||||
const goalsPositions = safeParseNumberArray(submission.value.goalsPositions);
|
||||
if (goalsPositions) onboardingData.goalsPositions = goalsPositions;
|
||||
}
|
||||
|
||||
if (submission.value.goalsOther) {
|
||||
onboardingData.goalsOther = submission.value.goalsOther;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await createProject({
|
||||
organizationSlug: organizationSlug,
|
||||
name: submission.value.projectName,
|
||||
userId,
|
||||
version: submission.value.projectVersion,
|
||||
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
|
||||
});
|
||||
|
||||
if (code && configurationId) {
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: "prod",
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to find project environment."
|
||||
);
|
||||
}
|
||||
|
||||
const state = await generateVercelOAuthState({
|
||||
organizationId: project.organization.id,
|
||||
projectId: project.id,
|
||||
environmentSlug: environment.slug,
|
||||
organizationSlug: project.organization.slug,
|
||||
projectSlug: project.slug,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
state,
|
||||
code,
|
||||
configurationId,
|
||||
origin: "marketplace",
|
||||
});
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
return redirect(`/vercel/connect?${params.toString()}`);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath(project.organization, project),
|
||||
request,
|
||||
`${submission.value.projectName} created`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ExceededProjectLimitError) {
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error.message,
|
||||
{
|
||||
title: "Failed to create project",
|
||||
action: {
|
||||
label: "Request more projects",
|
||||
variant: "secondary/small",
|
||||
action: { type: "help", feedbackType: "help" },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error instanceof Error ? error.message : "Something went wrong",
|
||||
{ ephemeral: false }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { organization, message } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const canCreateV3Projects = organization.isActivated;
|
||||
|
||||
const [form, { projectName, projectVersion }] = useForm({
|
||||
id: "create-project",
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
|
||||
|
||||
const [selectedWorkingOn, setSelectedWorkingOn] = useState<string[]>([]);
|
||||
const [workingOnOther, setWorkingOnOther] = useState("");
|
||||
const [selectedTechnologies, setSelectedTechnologies] = useState<string[]>([]);
|
||||
const [customTechnologies, setCustomTechnologies] = useState<string[]>([]);
|
||||
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
|
||||
const [goalsOther, setGoalsOther] = useState("");
|
||||
|
||||
const [shuffledWorkingOn, setShuffledWorkingOn] = useState<string[]>([...workingOnOptions]);
|
||||
const [shuffledGoals, setShuffledGoals] = useState<string[]>([...goalOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const nonOther = workingOnOptions.filter((o) => o !== WORKING_ON_OTHER);
|
||||
setShuffledWorkingOn([...shuffleArray(nonOther), WORKING_ON_OTHER]);
|
||||
|
||||
const nonOtherGoals = goalOptions.filter((o) => o !== GOALS_OTHER);
|
||||
setShuffledGoals([...shuffleArray(nonOtherGoals), GOALS_OTHER]);
|
||||
}, []);
|
||||
|
||||
const showWorkingOnOther = selectedWorkingOn.includes(WORKING_ON_OTHER);
|
||||
const showGoalsOther = selectedGoals.includes(GOALS_OTHER);
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-background-deep">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer
|
||||
variant="onboarding"
|
||||
className="max-w-116 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
|
||||
>
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
|
||||
title="Create a new project"
|
||||
description={`This will create a new project in your "${organization.title}" organization.`}
|
||||
/>
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
{message && (
|
||||
<Callout variant="success" className="mb-4">
|
||||
{message}
|
||||
</Callout>
|
||||
)}
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>
|
||||
Project name <span className="text-text-bright">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
{...getInputProps(projectName, { type: "text" })}
|
||||
placeholder="Your project name"
|
||||
icon={FolderIcon}
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.errors}</FormError>
|
||||
</InputGroup>
|
||||
{canCreateV3Projects ? (
|
||||
<input
|
||||
{...getInputProps(projectVersion, { type: "hidden" })}
|
||||
defaultValue={"v3"}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...getInputProps(projectVersion, { type: "hidden" })}
|
||||
defaultValue={"v2"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-t border-grid-bright" />
|
||||
<InputGroup>
|
||||
<Label>What are you working on?</Label>
|
||||
<input type="hidden" name="workingOn" value={JSON.stringify(selectedWorkingOn)} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="workingOnPositions"
|
||||
value={JSON.stringify(
|
||||
selectedWorkingOn.map((v) => shuffledWorkingOn.indexOf(v) + 1)
|
||||
)}
|
||||
/>
|
||||
<MultiSelectField
|
||||
value={selectedWorkingOn}
|
||||
setValue={setSelectedWorkingOn}
|
||||
items={shuffledWorkingOn}
|
||||
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
|
||||
/>
|
||||
{showWorkingOnOther && (
|
||||
<>
|
||||
<input type="hidden" name="workingOnOther" value={workingOnOther} />
|
||||
<Input
|
||||
type="text"
|
||||
variant="small"
|
||||
value={workingOnOther}
|
||||
onChange={(e) => setWorkingOnOther(e.target.value)}
|
||||
placeholder="Tell us what you're working on"
|
||||
spellCheck={false}
|
||||
containerClassName="h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>What technologies are you using?</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="technologies"
|
||||
value={JSON.stringify(selectedTechnologies)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="technologiesOther"
|
||||
value={JSON.stringify(customTechnologies)}
|
||||
/>
|
||||
<TechnologyPicker
|
||||
value={selectedTechnologies}
|
||||
onChange={setSelectedTechnologies}
|
||||
customValues={customTechnologies}
|
||||
onCustomValuesChange={setCustomTechnologies}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>What are you trying to do with Trigger.dev?</Label>
|
||||
<input type="hidden" name="goals" value={JSON.stringify(selectedGoals)} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="goalsPositions"
|
||||
value={JSON.stringify(selectedGoals.map((v) => shuffledGoals.indexOf(v) + 1))}
|
||||
/>
|
||||
<MultiSelectField
|
||||
value={selectedGoals}
|
||||
setValue={setSelectedGoals}
|
||||
items={shuffledGoals}
|
||||
icon={<CommandLineIcon className="mr-1 size-4 text-text-dimmed" />}
|
||||
/>
|
||||
{showGoalsOther && (
|
||||
<>
|
||||
<input type="hidden" name="goalsOther" value={goalsOther} />
|
||||
<Input
|
||||
type="text"
|
||||
variant="small"
|
||||
value={goalsOther}
|
||||
onChange={(e) => setGoalsOther(e.target.value)}
|
||||
placeholder="Tell us what you're trying to do with Trigger.dev"
|
||||
spellCheck={false}
|
||||
containerClassName="h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} isLoading={isLoading}>
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
organization.projectsCount > 0 ? (
|
||||
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
<Feedback button={<></>} />
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user