Merge remote-tracking branch 'origin/dev' into features/new-integrations
# Conflicts: # apps/webapp/app/env.server.ts # apps/webapp/app/models/integrations.server.ts # apps/webapp/app/models/organizationTemplatePresenter.server.ts # apps/webapp/app/models/templateListPresenter.server.ts # apps/webapp/app/models/templatePresenter.server.ts # apps/webapp/app/models/workflowListPresenter.server.ts # apps/webapp/app/models/workflowStartPresenter.server.ts # apps/webapp/app/models/workflowsPresenter.server.ts # apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/index.tsx # apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx # pnpm-lock.yaml
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
# **⚡️ Trigger.dev**
|
||||
# **✨ Trigger.dev**
|
||||
### **The developer-first open source Zapier alternative.**
|
||||
|
||||
|
||||
@@ -172,8 +172,7 @@ new Trigger({
|
||||
|
||||
One of the most powerful features of Trigger.dev is the [runs page](https://docs.trigger.dev/viewing-runs). All of the steps in a workflow, including the initial event, can be viewed in detail. See the status / output of each step, the logs, rich previews, errors and much more.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ ENV NODE_ENV production
|
||||
RUN pnpm run generate
|
||||
RUN pnpm run build --filter=webapp...
|
||||
RUN pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma
|
||||
RUN pnpm run db:seed --filter=webapp
|
||||
|
||||
# Runner
|
||||
FROM node:16-bullseye AS runner
|
||||
|
||||
@@ -11,7 +11,7 @@ const variantStyle = {
|
||||
"bg-black/10 text-slate-900 rounded px-2 py-1 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white",
|
||||
lightTransparent:
|
||||
"bg-white/10 text-white-900 rounded px-2 py-1 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white",
|
||||
text: "text-sm text-slate-300 transition hover:text-slate-400",
|
||||
text: "text-sm text-slate-400 transition hover:text-slate-300",
|
||||
};
|
||||
|
||||
export type CopyTextButtonProps = {
|
||||
|
||||
@@ -1,70 +1,119 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BeakerIcon,
|
||||
BoltIcon,
|
||||
CloudArrowUpIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { TemplateListItem } from "~/presenters/templateListPresenter.server";
|
||||
import { ApiLogoIcon } from "./code/ApiLogoIcon";
|
||||
import { Panel } from "./layout/Panel";
|
||||
import { Body } from "./primitives/text/Body";
|
||||
import { Header1, Header3 } from "./primitives/text/Headers";
|
||||
|
||||
export function LoginPromoPanel() {
|
||||
export function LoginPromoPanel({ template }: { template?: TemplateListItem }) {
|
||||
return (
|
||||
<div className="hidden h-full max-w-[30vw] flex-col justify-center border-r border-black/20 bg-slate-950 p-12 lg:flex">
|
||||
<ul>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<WrenchScrewdriverIcon className="h-8 w-8 text-toxic" />
|
||||
<div className="h-full w-0.5 bg-toxic/50"></div>
|
||||
</div>
|
||||
{template ? (
|
||||
<div className="flex max-w-md flex-col">
|
||||
<Header1
|
||||
size="extra-large"
|
||||
className="mb-5 bg-gradient-to-r from-indigo-400 to-pink-500 bg-clip-text font-semibold text-transparent"
|
||||
>
|
||||
Login to continue setting up your Template
|
||||
</Header1>
|
||||
<Panel className="border border-slate-800 bg-slate-800/40 !p-6">
|
||||
<div className="h-fit w-full overflow-hidden rounded object-cover">
|
||||
<img src={template.imageUrl} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 pt-2">
|
||||
<Header3 size="extra-small" className=" text-slate-300">
|
||||
{template.title}
|
||||
</Header3>
|
||||
<Body size="small" className="text-slate-500">
|
||||
{template.description}
|
||||
</Body>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{template.services.map((service) => (
|
||||
<ApiLogoIcon
|
||||
key={service.slug}
|
||||
integration={service}
|
||||
size="small"
|
||||
className="border border-slate-700/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Link
|
||||
to="/templates"
|
||||
className="mt-4 flex items-center gap-2 text-sm text-slate-500 transition hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3 " />
|
||||
Choose a different Template
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<WrenchScrewdriverIcon className="text-toxic h-8 w-8" />
|
||||
<div className="bg-toxic/50 h-full w-0.5"></div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Create</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Write workflows by creating triggers directly in your code. These
|
||||
can be 3rd-party integrations, custom events or on a schedule.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<BoltIcon className="h-8 w-8 text-toxic" />
|
||||
<div className="h-full w-0.5 bg-toxic/50"></div>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Create</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Write workflows by creating triggers directly in your code.
|
||||
These can be 3rd-party integrations, custom events or on a
|
||||
schedule.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<BoltIcon className="text-toxic h-8 w-8" />
|
||||
<div className="bg-toxic/50 h-full w-0.5"></div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Run</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
When your server runs, your workflow will be registered and you
|
||||
can authenticate with any APIs you’re using.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<BeakerIcon className="h-8 w-8 text-toxic" />
|
||||
<div className="h-full w-0.5 bg-toxic/50"></div>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Test</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Test your workflow by triggering them manually in your dashboard.
|
||||
Follow it as it runs step-by-step.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Run</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
When your server runs, your workflow will be registered and you
|
||||
can authenticate with any APIs you’re using.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<BeakerIcon className="text-toxic h-8 w-8" />
|
||||
<div className="bg-toxic/50 h-full w-0.5"></div>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Test</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Test your workflow by triggering them manually in your
|
||||
dashboard. Follow it as it runs step-by-step.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<CloudArrowUpIcon className="ml-0.5 h-7 w-7 text-toxic" />
|
||||
</div>
|
||||
<li className="flex gap-3 text-white">
|
||||
<div className="mt-1.5 flex flex-col items-center gap-2">
|
||||
<CloudArrowUpIcon className="text-toxic ml-0.5 h-7 w-7" />
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Deploy</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Deploy your new workflow as you would any other code commit and
|
||||
inspect each workflow run in real time.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mb-1">
|
||||
<h2 className="mb-2 text-2xl font-semibold">Deploy</h2>
|
||||
<p className="mb-10 text-white/60">
|
||||
Deploy your new workflow as you would any other code commit and
|
||||
inspect each workflow run in real time.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-typescript";
|
||||
import "prismjs/components/prism-jsx";
|
||||
import "prismjs/components/prism-tsx";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/components/prism-bash";
|
||||
import "prismjs/plugins/line-numbers/prism-line-numbers";
|
||||
@@ -12,7 +14,7 @@ Prism.manual = true;
|
||||
|
||||
type CodeBlockProps = {
|
||||
code: string;
|
||||
language?: "typescript" | "json" | "bash";
|
||||
language?: "typescript" | "json" | "bash" | "tsx";
|
||||
showCopyButton?: boolean;
|
||||
align?: "top" | "center";
|
||||
maxHeight?: string;
|
||||
@@ -53,10 +55,7 @@ export default function CodeBlock({
|
||||
className={classNames(showLineNumbers && `line-numbers`)}
|
||||
ref={codeRef}
|
||||
>
|
||||
<code
|
||||
className={`language-${language}`}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
></code>
|
||||
<code className={`language-${language}`}>{code}</code>
|
||||
</pre>
|
||||
{showCopyButton === true && (
|
||||
<CopyTextButton
|
||||
|
||||
@@ -75,7 +75,7 @@ export function AddApiKeyButton({
|
||||
Instructions
|
||||
</Body>
|
||||
</div>
|
||||
<p
|
||||
<div
|
||||
className="prose prose-sm prose-invert"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked(authentication.documentation),
|
||||
|
||||
@@ -24,6 +24,14 @@ export function AppLayout({
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicAppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-[5rem_auto_2rem]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppBody({
|
||||
children,
|
||||
className = "bg-slate-850",
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import classNames from "classnames";
|
||||
|
||||
export type ListProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function List({ children }: { children: React.ReactNode }) {
|
||||
export function List({ children, className }: ListProps) {
|
||||
return (
|
||||
<div className="bg-slate-800 shadow-md sm:rounded-md mb-4">
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"mb-4 bg-slate-800 shadow-md sm:rounded-md"
|
||||
)}
|
||||
>
|
||||
<ul className="divide-y divide-slate-850">{children}</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Link, NavLink } from "@remix-run/react";
|
||||
import { Fragment } from "react";
|
||||
import { Logo } from "../Logo";
|
||||
import { PrimaryLink, ToxicLink } from "../primitives/Buttons";
|
||||
import { MobileNavIcon, MobileNavLink } from "../primitives/NavLink";
|
||||
|
||||
function MobileNavigation() {
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Button
|
||||
className="bg-slate-70 relative z-10 flex h-8 w-8 items-center justify-center rounded-md border-none bg-opacity-50 focus:border-none [&:not(:focus-visible)]:focus:outline-none"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
{({ open }) => <MobileNavIcon open={open} />}
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="absolute inset-x-6 top-full mt-4 flex origin-top flex-col gap-2 rounded-2xl bg-slate-600 p-6 text-lg tracking-tight text-slate-900 shadow-xl ring-1 ring-slate-900/5" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-100 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="absolute inset-x-6 top-full mt-4 flex origin-top flex-col gap-4 rounded-2xl bg-slate-800 p-6 text-lg tracking-tight text-slate-900 shadow-xl ring-1 ring-slate-700"
|
||||
>
|
||||
<PrimaryLink
|
||||
className="whitespace-nowrap text-base"
|
||||
to="https://app.trigger.dev"
|
||||
>
|
||||
Sign up
|
||||
</PrimaryLink>
|
||||
<MobileNavLink
|
||||
className="whitespace-nowrap text-base"
|
||||
to="https://docs.trigger.dev"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</MobileNavLink>
|
||||
|
||||
<MobileNavLink
|
||||
className="whitespace-nowrap text-base"
|
||||
to="https://docs.trigger.dev/examples/examples"
|
||||
target="_blank"
|
||||
>
|
||||
Examples
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/pricing" title="Pricing">
|
||||
Pricing
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to="https://github.com/triggerdotdev/trigger.dev"
|
||||
target="_blank"
|
||||
>
|
||||
GitHub
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="https://app.trigger.dev">Login</MobileNavLink>
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketingHeader() {
|
||||
return (
|
||||
<>
|
||||
<header className="z-50 w-full bg-slate-900">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-6 sm:px-10 lg:px-16 ">
|
||||
<div className="flex items-center gap-x-6 md:gap-x-[56px]">
|
||||
<a
|
||||
href="https://trigger.dev"
|
||||
target="_self"
|
||||
rel="noreferrer"
|
||||
className="w-[160px]"
|
||||
>
|
||||
<Logo className="h-full" />
|
||||
</a>
|
||||
<div className="hidden gap-x-4 font-semibold md:flex md:gap-x-4 lg:gap-x-10">
|
||||
<a
|
||||
href="https://docs.trigger.dev/"
|
||||
title="Docs"
|
||||
aria-label="Docs"
|
||||
target="_blank"
|
||||
className="transform text-slate-200 hover:text-toxic-500"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
<NavLink
|
||||
to="/templates"
|
||||
title="Templates"
|
||||
aria-label="Templates"
|
||||
className="transform text-slate-200 hover:text-toxic-500"
|
||||
>
|
||||
Templates
|
||||
</NavLink>
|
||||
<a
|
||||
href="https://trigger.dev/pricing"
|
||||
title="Pricing"
|
||||
aria-label="Pricing"
|
||||
className="transform text-slate-200 hover:text-toxic-500"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-x-4 md:gap-x-4 lg:gap-x-6">
|
||||
<a
|
||||
href="https://github.com/triggerdotdev/trigger.dev"
|
||||
rel="noreferrer"
|
||||
aria-label="Trigger.dev GitHub"
|
||||
target="_blank"
|
||||
title="Trigger.dev GitHub"
|
||||
className="hidden items-center text-right text-xs text-slate-500 transition hover:text-toxic-500 md:flex"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.9906 1.78809C6.19453 1.78574 1.5 6.47793 1.5 12.2693C1.5 16.849 4.43672 20.742 8.52656 22.1717C9.07734 22.31 8.99297 21.9186 8.99297 21.6514V19.835C5.8125 20.2076 5.68359 18.1029 5.47031 17.7514C5.03906 17.0154 4.01953 16.8279 4.32422 16.4764C5.04844 16.1037 5.78672 16.5701 6.64219 17.8334C7.26094 18.7498 8.46797 18.5951 9.07969 18.4428C9.21328 17.892 9.49922 17.3998 9.89297 17.0178C6.59766 16.4271 5.22422 14.4162 5.22422 12.0256C5.22422 10.8654 5.60625 9.79902 6.35625 8.93887C5.87812 7.5209 6.40078 6.30684 6.47109 6.12637C7.83281 6.00449 9.24844 7.10137 9.35859 7.18809C10.132 6.97949 11.0156 6.86934 12.0047 6.86934C12.9984 6.86934 13.8844 6.98418 14.6648 7.19512C14.9297 6.99355 16.2422 6.05137 17.5078 6.16621C17.5758 6.34668 18.0867 7.53262 17.6367 8.93184C18.3961 9.79434 18.7828 10.8701 18.7828 12.0326C18.7828 14.4279 17.4 16.4412 14.0953 17.0225C14.3784 17.3008 14.6031 17.6328 14.7564 17.999C14.9098 18.3652 14.9886 18.7583 14.9883 19.1553V21.792C15.007 22.0029 14.9883 22.2115 15.3398 22.2115C19.4906 20.8123 22.4789 16.8912 22.4789 12.2717C22.4789 6.47793 17.782 1.78809 11.9906 1.78809V1.78809Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<NavLink
|
||||
to="/login"
|
||||
title="Login"
|
||||
aria-label="Login"
|
||||
className="hidden transform font-semibold text-slate-200 hover:text-toxic-500 md:flex"
|
||||
>
|
||||
Login
|
||||
</NavLink>
|
||||
|
||||
<ToxicLink className="font-lg whitespace-nowrap " to="/login">
|
||||
Sign up
|
||||
</ToxicLink>
|
||||
|
||||
<div className="-mr-1 md:hidden">
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
|
||||
export type PanelInfoProps = {
|
||||
@@ -11,16 +12,60 @@ export function PanelInfo({
|
||||
className,
|
||||
message,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<IconPanel
|
||||
className={className}
|
||||
message={message}
|
||||
icon={
|
||||
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-blue-500" />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</IconPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelLoading({
|
||||
children,
|
||||
className,
|
||||
message,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<IconPanel
|
||||
className={className}
|
||||
message={message}
|
||||
icon={<Spinner className="h-6 w-6 min-w-[24px]" />}
|
||||
>
|
||||
{children}
|
||||
</IconPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPanel({
|
||||
children,
|
||||
className,
|
||||
message,
|
||||
icon,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
message?: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full justify-between gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-3 shadow-md ${className}`}
|
||||
className={`flex w-full justify-between gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-4 shadow-md backdrop-blur-sm ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2.5">
|
||||
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-blue-500" />
|
||||
{icon}
|
||||
<Body className="text-slate-300">{message}</Body>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Body } from "../primitives/text/Body";
|
||||
import { Header1 } from "../primitives/text/Headers";
|
||||
import invariant from "tiny-invariant";
|
||||
import { CopyText } from "../CopyText";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
|
||||
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid h-full grid-cols-[300px_2fr]">{children}</div>;
|
||||
@@ -81,27 +82,46 @@ export function OrganizationsSideMenu() {
|
||||
export function WorkflowsSideMenu() {
|
||||
const currentWorkflow = useCurrentWorkflow();
|
||||
const organization = useCurrentOrganization();
|
||||
const environment = useCurrentEnvironment();
|
||||
|
||||
if (currentWorkflow === undefined || organization === undefined) {
|
||||
if (
|
||||
currentWorkflow === undefined ||
|
||||
organization === undefined ||
|
||||
environment === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: SideMenuItem[] = [
|
||||
const workflowEventRule = currentWorkflow.rules.find(
|
||||
(rule) => rule.environmentId === environment.id
|
||||
);
|
||||
|
||||
let items: SideMenuItem[] = [
|
||||
{
|
||||
name: "Overview",
|
||||
icon: <ArrowsRightLeftIcon className={iconStyle} />,
|
||||
to: ``,
|
||||
},
|
||||
{
|
||||
name: "Test",
|
||||
icon: <BeakerIcon className={iconStyle} />,
|
||||
to: `test`,
|
||||
},
|
||||
{
|
||||
name: "Runs",
|
||||
icon: <ForwardIcon className={iconStyle} />,
|
||||
to: `runs`,
|
||||
},
|
||||
];
|
||||
|
||||
if (workflowEventRule) {
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
name: "Test",
|
||||
icon: <BeakerIcon className={iconStyle} />,
|
||||
to: `test`,
|
||||
},
|
||||
{
|
||||
name: "Runs",
|
||||
icon: <ForwardIcon className={iconStyle} />,
|
||||
to: `runs`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
name: "Connected APIs",
|
||||
icon: <Squares2X2Icon className={iconStyle} />,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { TertiaryLink } from "../primitives/Buttons";
|
||||
import { SubTitle } from "../primitives/text/SubTitle";
|
||||
import { StepNumber } from "./StepNumber";
|
||||
|
||||
export function BackToStep1() {
|
||||
return (
|
||||
<>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<Link to=".." className="transition hover:text-slate-300">
|
||||
I'll host the workflow myself
|
||||
</Link>
|
||||
</SubTitle>
|
||||
<TertiaryLink to="..">Change answer</TertiaryLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackToStep2({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<Link to="../step2" className="transition hover:text-slate-300">
|
||||
{text}
|
||||
</Link>
|
||||
</SubTitle>
|
||||
<TertiaryLink to="../step2">Change answer</TertiaryLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function StepNumber({
|
||||
stepNumber,
|
||||
drawLine,
|
||||
active = false,
|
||||
complete = false,
|
||||
}: {
|
||||
stepNumber?: string;
|
||||
drawLine?: boolean;
|
||||
active?: boolean;
|
||||
complete?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mr-3 flex flex-col items-center justify-center">
|
||||
{active ? (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded bg-green-600 py-1 text-sm font-semibold text-slate-900 shadow">
|
||||
{stepNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded border border-slate-700 bg-slate-800 py-1 text-sm font-semibold text-green-400 shadow">
|
||||
{complete ? "✓" : stepNumber}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{drawLine ? (
|
||||
<div className="h-full border-l border-slate-700"></div>
|
||||
) : (
|
||||
<div className="h-full"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const onboarding = {
|
||||
buttonStyles:
|
||||
"relative flex flex-col cursor-pointer items-center justify-start hover:bg-slate-700 px-4 shadow gap-4 rounded bg-slate-700/50 py-8 border border-slate-700 transition",
|
||||
labelStyles:
|
||||
"absolute top-0 right-0 uppercase text-xs text-slate-900 px-2 py-1 font-semibold rounded-bl rounded-tr",
|
||||
maxWidth: "flex flex-col max-w-4xl",
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import classnames from "classnames";
|
||||
|
||||
type Size = "regular" | "large";
|
||||
|
||||
const commonClasses =
|
||||
"inline-flex items-center justify-center max-w-max rounded text-sm transition whitespace-nowrap";
|
||||
"inline-flex items-center justify-center max-w-max rounded transition whitespace-nowrap";
|
||||
const primaryClasses = classnames(
|
||||
commonClasses,
|
||||
"px-4 py-2 bg-indigo-700 text-white hover:bg-indigo-600 focus:ring-indigo-800 gap-2"
|
||||
"px-4 py-2 bg-indigo-700 text-white hover:bg-indigo-600 focus:ring-indigo-800 gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-400"
|
||||
);
|
||||
const secondaryClasses = classnames(
|
||||
commonClasses,
|
||||
@@ -13,28 +15,56 @@ const secondaryClasses = classnames(
|
||||
);
|
||||
const tertiaryClasses = classnames(
|
||||
commonClasses,
|
||||
"text-white/60 hover:text-white gap-1"
|
||||
"text-slate-300/70 hover:text-white gap-1"
|
||||
);
|
||||
const dangerClasses = classnames(
|
||||
commonClasses,
|
||||
"px-4 py-2 bg-rose-700 text-white hover:bg-rose-600 focus:ring-rose-800 gap-2"
|
||||
);
|
||||
const toxicClasses = classnames(
|
||||
commonClasses,
|
||||
"hover:cursor-pointer px-3 py-1 transition bg-gradient-to-r from-acid-500 to-toxic-500 text-slate-1000 !text-base font-bold hover:from-acid-600 hover:to-toxic-600 focus:ring-slate-300"
|
||||
);
|
||||
|
||||
function getSizeClassName(size: Size) {
|
||||
switch (size) {
|
||||
case "large":
|
||||
return "text-lg";
|
||||
case "regular":
|
||||
default:
|
||||
return "text-sm";
|
||||
}
|
||||
}
|
||||
|
||||
type ButtonProps = React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>;
|
||||
> & {
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type LinkProps = Parameters<typeof Link>[0];
|
||||
type LinkProps = Parameters<typeof Link>[0] & {
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type AProps = React.DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
> & {
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
export function PrimaryButton({ children, className, ...props }: ButtonProps) {
|
||||
export function PrimaryButton({
|
||||
children,
|
||||
size = "regular",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={classnames(primaryClasses, className)} {...props}>
|
||||
<button
|
||||
className={classnames(primaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -42,35 +72,69 @@ export function PrimaryButton({ children, className, ...props }: ButtonProps) {
|
||||
|
||||
export function SecondaryButton({
|
||||
children,
|
||||
size = "regular",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={classnames(secondaryClasses, className)} {...props}>
|
||||
<button
|
||||
className={classnames(
|
||||
secondaryClasses,
|
||||
getSizeClassName(size),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TertiaryButton({ children, className, ...props }: ButtonProps) {
|
||||
export function TertiaryButton({
|
||||
children,
|
||||
size = "regular",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={classnames(tertiaryClasses, className)} {...props}>
|
||||
<button
|
||||
className={classnames(tertiaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DangerButton({ children, className, ...props }: ButtonProps) {
|
||||
export function DangerButton({
|
||||
children,
|
||||
size = "regular",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button className={classnames(dangerClasses, className)} {...props}>
|
||||
<button
|
||||
className={classnames(dangerClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryLink({ children, className, to, ...props }: LinkProps) {
|
||||
export function PrimaryLink({
|
||||
children,
|
||||
size = "regular",
|
||||
className,
|
||||
to,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<Link to={to} className={classnames(primaryClasses, className)} {...props}>
|
||||
<Link
|
||||
to={to}
|
||||
className={classnames(primaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
@@ -79,13 +143,18 @@ export function PrimaryLink({ children, className, to, ...props }: LinkProps) {
|
||||
export function SecondaryLink({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
to,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={classnames(secondaryClasses, className)}
|
||||
className={classnames(
|
||||
secondaryClasses,
|
||||
getSizeClassName(size),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -93,27 +162,71 @@ export function SecondaryLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function TertiaryLink({ children, className, to, ...props }: LinkProps) {
|
||||
export function TertiaryLink({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
to,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<Link to={to} className={classnames(tertiaryClasses, className)} {...props}>
|
||||
<Link
|
||||
to={to}
|
||||
className={classnames(tertiaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryA({ children, className, href, ...props }: AProps) {
|
||||
export function DangerLink({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
to,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<a href={href} className={classnames(primaryClasses, className)} {...props}>
|
||||
<Link
|
||||
to={to}
|
||||
className={classnames(dangerClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryA({ children, className, href, ...props }: AProps) {
|
||||
export function ToxicLink({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
to,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={classnames(toxicClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryA({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
href,
|
||||
...props
|
||||
}: AProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classnames(secondaryClasses, className)}
|
||||
className={classnames(primaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -121,11 +234,57 @@ export function SecondaryA({ children, className, href, ...props }: AProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TertiaryA({ children, className, href, ...props }: AProps) {
|
||||
export function SecondaryA({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
href,
|
||||
...props
|
||||
}: AProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classnames(tertiaryClasses, className)}
|
||||
className={classnames(
|
||||
secondaryClasses,
|
||||
getSizeClassName(size),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function TertiaryA({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
href,
|
||||
...props
|
||||
}: AProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classnames(tertiaryClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToxicA({
|
||||
children,
|
||||
className,
|
||||
size = "regular",
|
||||
href,
|
||||
...props
|
||||
}: AProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classnames(toxicClasses, getSizeClassName(size), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,7 +8,7 @@ type DialogProps = Parameters<typeof HeadlessDialog>[0] & {
|
||||
function Dialog({ onClose, children, ...props }: DialogProps) {
|
||||
return (
|
||||
<Transition {...props}>
|
||||
<HeadlessDialog as="div" className="relative z-40" onClose={onClose}>
|
||||
<HeadlessDialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -18,7 +18,7 @@ function Dialog({ onClose, children, ...props }: DialogProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div className="fixed inset-0 bg-black/70" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
@@ -44,7 +44,7 @@ function Dialog({ onClose, children, ...props }: DialogProps) {
|
||||
type PanelProps = Parameters<typeof HeadlessDialog.Panel>[0];
|
||||
const Panel = forwardRef((props: PanelProps, ref) => (
|
||||
<HeadlessDialog.Panel
|
||||
className="w-full max-w-xl transform overflow-hidden rounded-md shadow-md bg-slate-800 text-slate-200 p-10 text-left align-middle transition-all"
|
||||
className="w-full max-w-xl transform overflow-hidden rounded-md bg-slate-800 p-10 text-left align-middle text-slate-200 shadow-md transition-all"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -3,13 +3,19 @@ import classNames from "classnames";
|
||||
type InputGroupProps = {
|
||||
layout?: "vertical" | "horizontal";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InputGroup({ layout = "vertical", children }: InputGroupProps) {
|
||||
export function InputGroup({
|
||||
layout = "vertical",
|
||||
children,
|
||||
className,
|
||||
}: InputGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid gap-1 mb-2",
|
||||
"mb-2 grid gap-1",
|
||||
{ className },
|
||||
layout === "horizontal" ? "grid-cols-2" : "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import classnames from "classnames";
|
||||
|
||||
type NavLinkProps = Parameters<typeof Link>[0];
|
||||
|
||||
export function NavLink({
|
||||
to,
|
||||
children,
|
||||
target,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: NavLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
{...props}
|
||||
onClick={onClick}
|
||||
target={target}
|
||||
className={classnames(
|
||||
"hover:text-toxic-500 inline-block whitespace-nowrap py-1 text-sm text-slate-200 transition md:px-2",
|
||||
{ className }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavLink({
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: NavLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
{...props}
|
||||
onClick={onClick}
|
||||
className={classnames(
|
||||
"hover:text-toxic-500 text-s block w-full whitespace-nowrap rounded-lg bg-slate-900 p-2 text-center text-sm text-slate-50 transition",
|
||||
{ className }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileNavIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 overflow-visible stroke-slate-300"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path
|
||||
d="M0 1H14M0 7H14M0 13H14"
|
||||
className={classnames(
|
||||
"origin-center transition",
|
||||
open && "scale-90 opacity-0"
|
||||
)}
|
||||
/>
|
||||
<path
|
||||
d="M2 2L12 12M12 2L2 12"
|
||||
className={classnames(
|
||||
"origin-center transition",
|
||||
!open && "scale-90 opacity-0"
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Header1 } from "./Headers";
|
||||
|
||||
export function Title({ children }: { children: string }) {
|
||||
export function Title({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Header1 size="extra-large" className="text-slate-200 mb-6">
|
||||
<Header1 size="extra-large" className="mb-6 text-slate-200">
|
||||
{children}
|
||||
</Header1>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ export function customEvent(apiKey: string) {
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "user-created-notify-slack",
|
||||
name: "User Created - Notify Slack",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
@@ -10,11 +11,19 @@ new Trigger({
|
||||
apiKey: "${apiKey}",
|
||||
on: customEvent({
|
||||
name: "user.created",
|
||||
//todo define the schema for the events you want to receive
|
||||
//this example accepts JSON like this: { id: "123", admin: false }
|
||||
//you can use z.any() to accept any data, but you won't get payload validation or type inference in run()
|
||||
schema: z.object({ id: z.string(), admin: z.boolean() }),
|
||||
//todo define or remove the filter
|
||||
//filters are optional, but can be used to filter out events
|
||||
//this example stops the run function firing when data.admin === true
|
||||
filter: {
|
||||
admin: [false],
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {},
|
||||
run: async (event, ctx) => {
|
||||
//insert your code here
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,20 @@ import * as github from "@trigger.dev/github";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "github-issues-to-slack",
|
||||
name: "Posts to Slack when GitHub Issue created or modified",
|
||||
name: "Posts to Slack when a GitHub Issue created or modified",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: github.events.issueEvent({
|
||||
//todo set your repo here
|
||||
repo: "my-github-org/my-github-repo",
|
||||
}),
|
||||
|
||||
run: async (event, ctx) => {
|
||||
//we post a Slack message
|
||||
const response = await slack.postMessage("send-to-slack", {
|
||||
//todo set your Slack channel name here
|
||||
channelName: "my-slack-channel-name",
|
||||
text: \`A new issue has been created or modified. \${event.action}\`,
|
||||
});
|
||||
|
||||
@@ -4,16 +4,20 @@ import * as github from "@trigger.dev/github";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "new-github-star-to-slack",
|
||||
name: "New GitHub Star: triggerdotdev/trigger.dev",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
on: github.events.newStarEvent({
|
||||
//todo set your repo here
|
||||
repo: "triggerdotdev/trigger.dev",
|
||||
}),
|
||||
run: async (event) => {
|
||||
//we post a Slack message
|
||||
await slack.postMessage("github-stars", {
|
||||
//todo set your Slack channel name here
|
||||
channelName: "github-stars",
|
||||
text: \`New GitHub star from \n<\${event.sender.html_url}|\${event.sender.login}>\`,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { postMessage } from "@trigger.dev/slack";
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "new-user",
|
||||
name: "New user slack message",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
@@ -22,7 +23,7 @@ new Trigger({
|
||||
|
||||
//send a message to the #new-users Slack channel with user details
|
||||
const response = await postMessage("send-to-slack", {
|
||||
channel: "new-users",
|
||||
channelName: "new-users",
|
||||
text: \`New user: \${event.name} (\${event.email}) signed up. \${
|
||||
event.paidPlan ? "They are paying" : "They are on the free plan"
|
||||
}.\`,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
export function resendEmailDripCampaign(apiKey: string) {
|
||||
return `import { customEvent, Trigger, sendEvent } from "@trigger.dev/sdk";
|
||||
return `import { customEvent, Trigger } from "@trigger.dev/sdk";
|
||||
import * as resend from "@trigger.dev/resend";
|
||||
import * as slack from "@trigger.dev/slack";
|
||||
import React from "react";
|
||||
import { Html } from "@react-email/html";
|
||||
import { Preview } from "@react-email/preview";
|
||||
import { Section } from "@react-email/section";
|
||||
import { Text } from "@react-email/text";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "../db";
|
||||
import { InactiveEmail, TipsEmail, WelcomeEmail } from "./email-templates";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "welcome-email-campaign",
|
||||
name: "Welcome email drip campaign",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
@@ -63,6 +65,55 @@ new Trigger({
|
||||
});
|
||||
}
|
||||
},
|
||||
}).listen();`;
|
||||
}).listen();
|
||||
|
||||
function WelcomeEmail({ name }: { name?: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Preview>This is the text that appears in the inbox list</Preview>
|
||||
<Section>
|
||||
<Text>Hey {name ?? "there"},</Text>
|
||||
<Text>Your message goes here.</Text>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
function TipsEmail({ name }: { name: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Preview>This is the text that appears in the inbox list</Preview>
|
||||
<Section>
|
||||
<Text>Hi {name ?? "there"},</Text>
|
||||
<Text>
|
||||
Tips content goes here
|
||||
</Text>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
function InactiveEmail({ name }: { name: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Preview>This is the text that appears in the inbox list</Preview>
|
||||
<Section>
|
||||
<Text>Hi {name ?? "there"},</Text>
|
||||
<Text>
|
||||
Re-engagement content goes here
|
||||
</Text>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
//This file is a mock database, in your real app you would access your real database from inside these functions
|
||||
async function getUser(userId: string, hasOnboarded = false) {
|
||||
return {
|
||||
id: userId,
|
||||
name: "Matt Aitken",
|
||||
email: "matt@trigger.dev",
|
||||
hasOnboarded,
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
StarIcon,
|
||||
EnvelopeIcon,
|
||||
ShoppingCartIcon,
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
UserIcon,
|
||||
@@ -10,34 +9,35 @@ import { customEvent } from "./custom-event";
|
||||
import { githubIssues } from "./github-issues";
|
||||
import { githubStars } from "./github-stars";
|
||||
import { newUserSlackMessage } from "./new-user-slack-message";
|
||||
import { resendEmailDripCampaign } from "./resend-email-drip-campaign";
|
||||
import { shopifyCreateNewProducts } from "./shopify-create-new-product";
|
||||
import { webhook } from "./webhook";
|
||||
import { scheduled } from "./scheduled";
|
||||
|
||||
import { whatsappListenForMessageAndReply } from "./whatsapp-listen-for-message-and-reply";
|
||||
import { scheduledCron } from "./scheduled-cron";
|
||||
|
||||
export const exampleProjects = [
|
||||
export type ExampleProject = {
|
||||
name: string;
|
||||
title?: string;
|
||||
icon?: React.ReactNode;
|
||||
docsLink?: string;
|
||||
description: string;
|
||||
requiredPackages: string;
|
||||
code: (apiKey: string) => string;
|
||||
packagesCopy?: string;
|
||||
bulletPoints?: string[];
|
||||
type: "example" | "from-scratch" | "blank";
|
||||
};
|
||||
|
||||
export const allExamples: ExampleProject[] = [
|
||||
{
|
||||
icon: <StarIcon className="h-8 w-8 text-yellow-400" />,
|
||||
name: "GitHub star → Slack",
|
||||
title: "When you receive a GitHub star, post that user's details to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a GitHub user adds a star to a repository. The user's details will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/github @trigger.dev/slack zod",
|
||||
requiredPackages: "@trigger.dev/sdk @trigger.dev/github @trigger.dev/slack",
|
||||
code: githubStars,
|
||||
},
|
||||
|
||||
{
|
||||
icon: <EnvelopeIcon className="h-8 w-8 text-blue-400" />,
|
||||
name: "New user → email",
|
||||
title:
|
||||
"When a new user signs up, post a message to Slack and send them a series of emails",
|
||||
description:
|
||||
"This workflow is triggered when a new user signs up. A welcome email is sent straight away and an alert is sent to a specific Slack channel. 1 day later it checks if the user has completed the onboarding, if they have, they get a ‘tips’ email, otherwise they get a re-engagement email.",
|
||||
requiredPackages: "@trigger.dev/resend @trigger.dev/slack zod",
|
||||
code: resendEmailDripCampaign,
|
||||
type: "example",
|
||||
},
|
||||
{
|
||||
icon: <UserIcon className="h-8 w-8 text-rose-400" />,
|
||||
@@ -45,8 +45,9 @@ export const exampleProjects = [
|
||||
title: "When a new user signs up, post a message to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a new user signs up. The user's details will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/slack zod",
|
||||
requiredPackages: "@trigger.dev/sdk @trigger.dev/slack zod",
|
||||
code: newUserSlackMessage,
|
||||
type: "example",
|
||||
},
|
||||
|
||||
{
|
||||
@@ -55,9 +56,10 @@ export const exampleProjects = [
|
||||
title: "When a custom event is triggered, create a new product in Shopify",
|
||||
description:
|
||||
"This workflow is triggered by a custom event. Once it is triggered, a new product is created in Shopify with the specified details.",
|
||||
requiredPackages: "@trigger.dev/shopify zod",
|
||||
requiredPackages: "@trigger.dev/sdk @trigger.dev/shopify zod",
|
||||
code: shopifyCreateNewProducts,
|
||||
packagesCopy: "Shopify",
|
||||
type: "example",
|
||||
},
|
||||
{
|
||||
icon: <DocumentTextIcon className="h-8 w-8 text-orange-400" />,
|
||||
@@ -65,23 +67,22 @@ export const exampleProjects = [
|
||||
title: "When a GitHub issue is created or modified, post it to Slack",
|
||||
description:
|
||||
"This workflow is triggered when a new issue is created or modified in GitHub. The issue will then be posted in a specific Slack channel.",
|
||||
requiredPackages: "@trigger.dev/github @trigger.dev/slack zod",
|
||||
requiredPackages: "@trigger.dev/sdk @trigger.dev/github @trigger.dev/slack",
|
||||
code: githubIssues,
|
||||
type: "example",
|
||||
},
|
||||
|
||||
{
|
||||
icon: <ChatBubbleOvalLeftEllipsisIcon className="h-8 w-8 text-green-400" />,
|
||||
name: "WhatsApp → Slack",
|
||||
name: "WhatsApp → WhatsApp",
|
||||
title: "Listen for WhatsApp messages and automatically reply",
|
||||
description:
|
||||
"This workflow is triggered when a WhatsApp message has been received. When received, a pre-determined reply is sent.",
|
||||
requiredPackages: "@trigger.dev/whatsapp zod",
|
||||
requiredPackages: "@trigger.dev/sdk @trigger.dev/whatsapp",
|
||||
code: whatsappListenForMessageAndReply,
|
||||
packagesCopy: "WhatsApp",
|
||||
type: "example",
|
||||
},
|
||||
];
|
||||
|
||||
export const fromScratchProjects = [
|
||||
{
|
||||
name: "Webhook",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
@@ -89,12 +90,12 @@ export const fromScratchProjects = [
|
||||
docsLink: "https://docs.trigger.dev/triggers/webhooks",
|
||||
description:
|
||||
"Webhooks allow you to subscribe to events from APIs but can be difficult to work with, especially when developing locally. Trigger.dev makes using webhooks easy:",
|
||||
bulletPoint1:
|
||||
bulletPoints: [
|
||||
"You don’t need to register/unregister for webhooks, we do it for you.",
|
||||
bulletPoint2:
|
||||
"They work locally during development without needing to use tunnels (e.g. Ngrok).",
|
||||
bulletPoint3:
|
||||
"We receive the webhook, then keep trying to send it to you until you receive it. If your server goes down, no problem.",
|
||||
],
|
||||
type: "from-scratch",
|
||||
},
|
||||
{
|
||||
name: "Custom event",
|
||||
@@ -103,30 +104,32 @@ export const fromScratchProjects = [
|
||||
docsLink: "https://docs.trigger.dev/triggers/custom-events",
|
||||
description:
|
||||
"Custom event triggers allow you to run workflows from your own code (or your other workflows). Send an event and any workflows that subscribe to that custom event will get triggered. You can easily send an event from anywhere, including from inside another workflow. Events don’t have to come from the same server as your workflow and can be sent as HTTP requests from any language.",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
type: "from-scratch",
|
||||
},
|
||||
{
|
||||
name: "Scheduled (recurring)",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
requiredPackages: "@trigger.dev/sdk",
|
||||
code: scheduled,
|
||||
docsLink: "https://docs.trigger.dev/triggers/scheduled",
|
||||
description:
|
||||
"Run a workflow on a recurring schedule. The example below will run every 5 minutes, starting 5 minutes after this code is first run on your server (that includes running locally).",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
type: "from-scratch",
|
||||
},
|
||||
{
|
||||
name: "Scheduled (CRON)",
|
||||
requiredPackages: "@trigger.dev/sdk zod",
|
||||
requiredPackages: "@trigger.dev/sdk",
|
||||
code: scheduledCron,
|
||||
docsLink: "https://docs.trigger.dev/triggers/scheduled",
|
||||
description:
|
||||
"Run a workflow on a recurring schedule. The example job below will run at 2:30pm every Monday.",
|
||||
bulletPoint1: "",
|
||||
bulletPoint2: "",
|
||||
bulletPoint3: "",
|
||||
type: "from-scratch",
|
||||
},
|
||||
];
|
||||
|
||||
export const exampleProjects = allExamples.filter(
|
||||
(example) => example.type === "example"
|
||||
);
|
||||
|
||||
export const fromScratchProjects = allExamples.filter(
|
||||
(example) => example.type === "from-scratch"
|
||||
);
|
||||
|
||||
@@ -2,13 +2,19 @@ export function scheduledCron(apiKey: string) {
|
||||
return `import { Trigger, scheduleEvent } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "cron-scheduled-workflow",
|
||||
name: "Cron Scheduled Workflow",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//todo set your CRON expression here
|
||||
//this example runs every Monday at 2:30pm UTC
|
||||
//this site is useful when writing CRON: https://crontab.guru
|
||||
//you don't have to wait to test, use our "Test" button on your workflow page
|
||||
on: scheduleEvent({ cron: "30 14 * * 1" }),
|
||||
run: async (event, ctx) => {
|
||||
//this function is run every Monday at 2:30pm UTC
|
||||
await ctx.logger.info("Received the cron scheduled event", {
|
||||
event,
|
||||
wallTime: new Date(),
|
||||
|
||||
@@ -2,13 +2,18 @@ export function scheduled(apiKey: string) {
|
||||
return `import { Trigger, scheduleEvent } from "@trigger.dev/sdk";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "scheduled-workflow",
|
||||
name: "Scheduled Workflow",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//todo set how often you want your event to fire here
|
||||
//this example runs every 5 minutes, the first run will be 5 minutes after the workflow is first connected
|
||||
//you don't have to wait to test, use our "Test" button on your workflow page
|
||||
on: scheduleEvent({ rateOf: { minutes: 5 } }),
|
||||
run: async (event, ctx) => {
|
||||
//this function is run every 5 minutes
|
||||
await ctx.logger.info("Received the scheduled event", {
|
||||
event,
|
||||
wallTime: new Date(),
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
export function shopifyCreateNewProducts(apiKey: string) {
|
||||
return `import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
return `import { Trigger, customEvent } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
import * as shopify from "@trigger.dev/shopify";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "shopify-products",
|
||||
name: "Shopify products",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//todo define the schema for the events you want to receive
|
||||
//this example accepts an empty JSON object: {}
|
||||
//you can use z.any() to accept any JSON, but you won't get nice types in the run function
|
||||
on: customEvent({
|
||||
name: "shopify.products",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
run: async (event, ctx) => {
|
||||
//this creates a new product in your Shopify store, with a variant
|
||||
const newProduct = await shopify.createProduct("create-product", {
|
||||
descriptionHtml: "This is my brilliant <i>product description</i>.",
|
||||
title: \`Fantastic product \${Math.floor(Math.random() * 1000)}\`,
|
||||
@@ -21,6 +26,7 @@ new Trigger({
|
||||
vendor: "Nike",
|
||||
options: ["Color", "Size"],
|
||||
standardizedProductType: {
|
||||
//you may need to update this to match your store's product taxonomy
|
||||
productTaxonomyNodeId: "gid://shopify/ProductTaxonomyNode/352",
|
||||
},
|
||||
variants: [
|
||||
@@ -30,17 +36,12 @@ new Trigger({
|
||||
inventoryItem: {
|
||||
tracked: true,
|
||||
},
|
||||
inventoryQuantities: [
|
||||
{
|
||||
availableQuantity: 1,
|
||||
locationId: "gid://shopify/Location/76187369773",
|
||||
},
|
||||
],
|
||||
options: ["Maroon", "Tiny"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
//we add two images to the product
|
||||
const newImages = await shopify.appendProductImages("append-images", {
|
||||
id: newProduct.id,
|
||||
images: [
|
||||
@@ -59,4 +60,4 @@ new Trigger({
|
||||
},
|
||||
}).listen();
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,32 @@ export function webhook(apiKey: string) {
|
||||
import { z } from "zod";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "caldotcom-to-slack",
|
||||
name: "Cal.com To Slack",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//todo setup your custom webhook.
|
||||
//we have integrations that make this much easier for supported APIs
|
||||
on: webhookEvent({
|
||||
service: "cal.com",
|
||||
//the name of the event you want to subscribe for
|
||||
eventName: "BOOKING_CREATED",
|
||||
filter: {
|
||||
triggerEvent: ["BOOKING_CREATED"],
|
||||
},
|
||||
//you can define a schema to validate the payload, and have nice types in the run function
|
||||
//here we use z.any() to accept any payload
|
||||
schema: z.any(),
|
||||
//some webhooks are signed, set the header name and we'll verify the signature for you
|
||||
verifyPayload: {
|
||||
enabled: true,
|
||||
header: "X-Cal-Signature-256",
|
||||
},
|
||||
}),
|
||||
run: async (event, ctx) => {},
|
||||
run: async (event, ctx) => {
|
||||
//insert your code here
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
export function whatsappListenForMessageAndReply(apiKey: string) {
|
||||
return `import { Trigger } from "@trigger.dev/sdk";
|
||||
return `import { Trigger } from "@trigger.dev/sdk";
|
||||
import { events, sendReaction, sendText } from "@trigger.dev/whatsapp";
|
||||
|
||||
new Trigger({
|
||||
//todo: ensure this id is only used for this workflow
|
||||
id: "whatsapp-webhook",
|
||||
name: "Listen for WhatsApp messages and reply",
|
||||
// For security, we recommend moving this api key to your .env / secrets file.
|
||||
// Our env variable is called TRIGGER_API_KEY
|
||||
apiKey: "${apiKey}",
|
||||
//this listens for all WhatsApp messages sent to your WhatsApp Business account
|
||||
//todo you need put in your WhatsApp Business account ID
|
||||
on: events.messageEvent({
|
||||
accountId: "<your_account_id>",
|
||||
}),
|
||||
@@ -36,4 +37,3 @@ new Trigger({
|
||||
},
|
||||
}).listen();`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ExampleProject } from "~/components/samples/samplesList";
|
||||
import {
|
||||
exampleProjects,
|
||||
fromScratchProjects,
|
||||
} from "~/components/samples/samplesList";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
import CodeBlock from "../code/CodeBlock";
|
||||
import { ToxicA } from "../primitives/Buttons";
|
||||
import { StyledDialog } from "../primitives/Dialog";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
import { Header2 } from "../primitives/text/Headers";
|
||||
|
||||
const buttonStyles =
|
||||
"relative flex flex-col cursor-pointer items-center justify-start hover:bg-slate-700 px-1 shadow gap-4 rounded bg-slate-700/50 py-8 border border-slate-700 transition";
|
||||
|
||||
export function ExampleOverview({
|
||||
onSelectedProject,
|
||||
}: {
|
||||
onSelectedProject: (project: ExampleProject) => void;
|
||||
}) {
|
||||
const environment = useCurrentEnvironment();
|
||||
invariant(environment, "No environment selected");
|
||||
|
||||
const [openProject, setOpenProject] = useState<ExampleProject | null>(null);
|
||||
const isOpen = !!openProject;
|
||||
|
||||
return (
|
||||
<>
|
||||
{openProject && (
|
||||
<StyledDialog.Dialog
|
||||
onClose={(e) => setOpenProject(null)}
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<StyledDialog.Panel className="top-0 mx-auto flex max-h-[80vh] max-w-5xl items-start gap-2 overflow-y-auto ">
|
||||
<div className="grid grid-cols-[minmax(0,_1fr)_20rem] gap-4 rounded-md bg-slate-800 p-4">
|
||||
<CodeBlock
|
||||
code={openProject.code(environment.apiKey)}
|
||||
language="tsx"
|
||||
align="top"
|
||||
/>
|
||||
<div className="sticky top-4 flex flex-col gap-y-4">
|
||||
<ToxicA
|
||||
className="group flex h-12 min-w-full"
|
||||
onClick={() => {
|
||||
setOpenProject(null);
|
||||
onSelectedProject(openProject);
|
||||
}}
|
||||
>
|
||||
<span>Use this example</span>
|
||||
<ArrowRightIcon className="ml-1 h-5 w-5 transition group-hover:translate-x-0.5" />
|
||||
</ToxicA>
|
||||
{openProject.icon}
|
||||
<Header2
|
||||
size="regular"
|
||||
className="text-left font-semibold text-slate-300"
|
||||
>
|
||||
{openProject.title}
|
||||
</Header2>
|
||||
<Body className="text-left text-slate-400">
|
||||
{openProject.description}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpenProject(null)}
|
||||
className="sticky top-0 text-slate-600 transition hover:text-slate-500"
|
||||
>
|
||||
<XCircleIcon className="h-10 w-10" />
|
||||
</button>
|
||||
</StyledDialog.Panel>
|
||||
</StyledDialog.Dialog>
|
||||
)}
|
||||
<>
|
||||
{exampleProjects.map((project) => (
|
||||
<button
|
||||
key={project.name}
|
||||
type="button"
|
||||
onClick={(e) => setOpenProject(project)}
|
||||
className={buttonStyles}
|
||||
>
|
||||
{project.icon}
|
||||
<Body>{project.name}</Body>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FromScratchOverview({
|
||||
onSelectedProject,
|
||||
}: {
|
||||
onSelectedProject: (project: ExampleProject) => void;
|
||||
}) {
|
||||
const environment = useCurrentEnvironment();
|
||||
invariant(environment, "No environment selected");
|
||||
|
||||
const [openProject, setOpenProject] = useState<ExampleProject | null>(null);
|
||||
const isOpen = openProject !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{openProject && (
|
||||
<StyledDialog.Dialog
|
||||
onClose={(e) => setOpenProject(null)}
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<StyledDialog.Panel className="top-0 mx-auto flex max-h-[90vh] max-w-5xl items-start gap-2 overflow-hidden overflow-y-auto rounded-md p-4">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-md bg-slate-800 text-left">
|
||||
<div className="flex items-start justify-start gap-4 border-b border-slate-850/80 bg-slate-700/30 p-4">
|
||||
<div className="flex flex-col">
|
||||
<CodeBlock
|
||||
code={openProject.code(environment.apiKey)}
|
||||
align="top"
|
||||
className="flex w-[650px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-80 flex-col items-start justify-start gap-y-4">
|
||||
<ToxicA
|
||||
className="group flex h-12 min-w-full"
|
||||
onClick={() => {
|
||||
setOpenProject(null);
|
||||
onSelectedProject(openProject);
|
||||
}}
|
||||
>
|
||||
<span>Use this example</span>
|
||||
<ArrowRightIcon className="ml-1 h-5 w-5 transition group-hover:translate-x-0.5" />
|
||||
</ToxicA>
|
||||
|
||||
<Header2 size="regular" className="font-semibold">
|
||||
{openProject.name}
|
||||
</Header2>
|
||||
|
||||
<Body>{openProject.description}</Body>
|
||||
{openProject.bulletPoints && (
|
||||
<ul className="list-disc pl-4 text-slate-300">
|
||||
{openProject.bulletPoints.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpenProject(null)}
|
||||
className="sticky top-0 text-slate-600 transition hover:text-slate-500"
|
||||
>
|
||||
<XCircleIcon className="h-10 w-10" />
|
||||
</button>
|
||||
</StyledDialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog.Dialog>
|
||||
)}
|
||||
<>
|
||||
{fromScratchProjects.map((project) => (
|
||||
<React.Fragment key={project.name}>
|
||||
<button
|
||||
key={project.name}
|
||||
type="button"
|
||||
onClick={(e) => setOpenProject(project)}
|
||||
className={buttonStyles}
|
||||
>
|
||||
<Body>{project.name}</Body>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import classNames from "classnames";
|
||||
import { TemplateListItem } from "~/presenters/templateListPresenter.server";
|
||||
import { ApiLogoIcon } from "../code/ApiLogoIcon";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
import { Header1 } from "../primitives/text/Headers";
|
||||
|
||||
export function TemplateCard({
|
||||
template,
|
||||
className,
|
||||
}: {
|
||||
template: TemplateListItem;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key={template.title}
|
||||
className={classNames(
|
||||
className,
|
||||
"flex h-fit w-full flex-col overflow-hidden rounded-md border border-slate-700/50 bg-slate-1000 text-slate-300 shadow-md"
|
||||
)}
|
||||
>
|
||||
<div className="h-36 w-full">
|
||||
<img
|
||||
src={template.imageUrl}
|
||||
alt="template image"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-5">
|
||||
<div className="flex flex-col gap-y-2 ">
|
||||
<Header1 size="extra-small" className="text-slate-300">
|
||||
{template.title}
|
||||
</Header1>
|
||||
<Body size="small" className="text-slate-500">
|
||||
{template.description}
|
||||
</Body>
|
||||
</div>
|
||||
<div className="flex flex-row gap-x-1">
|
||||
{template.services.map((service) => (
|
||||
<div key={service.slug} className="">
|
||||
<ApiLogoIcon
|
||||
integration={service}
|
||||
size="regular"
|
||||
className="mt-2 flex h-8 w-8 items-center justify-center rounded border-[1px] border-slate-700 bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import classNames from "classnames";
|
||||
import { Fragment } from "react";
|
||||
import type { TemplateListItem } from "~/presenters/templateListPresenter.server";
|
||||
import { ApiLogoIcon } from "../code/ApiLogoIcon";
|
||||
import { OctoKitty } from "../GitHubLoginButton";
|
||||
import { TertiaryA, ToxicLink } from "../primitives/Buttons";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
import { Header1 } from "../primitives/text/Headers";
|
||||
|
||||
export function TemplateOverview({
|
||||
template,
|
||||
className,
|
||||
}: {
|
||||
template: TemplateListItem;
|
||||
className?: string;
|
||||
}) {
|
||||
const { docsHTML, imageUrl } = template;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"grid w-full grid-cols-1 gap-8 rounded-lg bg-slate-850 pl-8 text-left md:grid-cols-[20rem_minmax(0,_1fr)]"
|
||||
)}
|
||||
>
|
||||
<div className="sticky top-4 flex h-max flex-col rounded-r">
|
||||
<TemplateDetails template={template} className="hidden md:flex" />
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col rounded">
|
||||
<div className="z-90 h-fit w-full transition group-hover:opacity-90">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-t object-cover"
|
||||
/>
|
||||
</div>
|
||||
<TemplateDetails template={template} className="md:hidden" />
|
||||
<div className="flex rounded-b bg-slate-900/75 p-8">
|
||||
<div
|
||||
className="prose prose-sm prose-invert min-w-full [&>pre]:bg-[rgb(17,23,41)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: docsHTML,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDetails({
|
||||
className,
|
||||
template,
|
||||
}: {
|
||||
className?: string;
|
||||
template: TemplateListItem;
|
||||
}) {
|
||||
const { title, description, repositoryUrl, id } = template;
|
||||
return (
|
||||
<div className={classNames(className, "flex flex-col")}>
|
||||
<Header1 size="extra-large" className="mt-2 mb-4 font-semibold">
|
||||
{title}
|
||||
</Header1>
|
||||
<Body className="mb-6 text-slate-400">{description}</Body>
|
||||
{template.services.length != 0 ? (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="uppercase tracking-wide text-slate-500"
|
||||
>
|
||||
Integrations
|
||||
</Body>
|
||||
<div className="ml-2 h-px w-full bg-slate-800" />
|
||||
</div>
|
||||
<div className="mb-4 flex gap-x-1">
|
||||
{template.services.map((service) => (
|
||||
<Fragment key={service.slug}>
|
||||
<ApiLogoIcon
|
||||
integration={service}
|
||||
size="regular"
|
||||
className="mt-2 flex h-8 w-8 items-center justify-center rounded border border-slate-800 bg-slate-900 transition group-hover:border-slate-600 group-hover:bg-slate-900/80"
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="mb-2 flex items-center">
|
||||
<Body
|
||||
size="extra-small"
|
||||
className="uppercase tracking-wide text-slate-500"
|
||||
>
|
||||
Repo
|
||||
</Body>
|
||||
<div className="ml-2 h-px w-full bg-slate-800" />
|
||||
</div>
|
||||
<TertiaryA href={repositoryUrl} target="_blank" className="mb-8">
|
||||
<OctoKitty className="h-4 w-4" />
|
||||
<Body size="small" className="truncate font-mono">
|
||||
{repositoryUrl.replace("https://github.com/triggerdotdev", "")}
|
||||
</Body>
|
||||
</TertiaryA>
|
||||
<ToxicLink
|
||||
size="large"
|
||||
className="group flex h-12 min-w-full"
|
||||
to={`../../templates/add?templateId=${id}`}
|
||||
>
|
||||
<span> Use this template </span>
|
||||
<ArrowRightIcon className="ml-1 h-5 w-5 transition group-hover:translate-x-0.5" />
|
||||
</ToxicLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import GitHubStarsTemplateBg from "../../../public/images/templates/github-stars-template-bg.png";
|
||||
import ResendSlackTemplateBg from "../../../public/images/templates/resend-slack-template-bg.png";
|
||||
import ShopifyTemplateBg from "../../../public/images/templates/shopify-template-bg.png";
|
||||
|
||||
export type TemplateData = {
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
imageURL: string;
|
||||
githubRepoURL: string;
|
||||
services: string[];
|
||||
documentation?: string;
|
||||
};
|
||||
|
||||
export const templateData = [
|
||||
{
|
||||
title: "Slack notifications when a GitHub repo is starred",
|
||||
shortTitle: "GitHub stars to Slack",
|
||||
description:
|
||||
"When a GitHub repo is starred, post information about the user to Slack",
|
||||
imageURL: GitHubStarsTemplateBg,
|
||||
githubRepoURL: "repo-url",
|
||||
services: ["slack", "github"],
|
||||
documentation: `
|
||||
### 🚀 Installation
|
||||
|
||||
Download the Mintlify CLI using the following command
|
||||
|
||||
\`\`\`
|
||||
npm i -g mintlify
|
||||
\`\`\`
|
||||
|
||||
### 👩💻 Development
|
||||
|
||||
Run the following command at the root of your Mintlify application to preview changes locally.
|
||||
|
||||
\`\`\`
|
||||
mintlify dev
|
||||
\`\`\`
|
||||
|
||||
Note - \`mintlify dev\` requires \`yarn\` and it's recommended you install it as a global installation. If you don't have yarn installed already run \`npm install --global yarn\` in your terminal.
|
||||
|
||||
### Custom Ports
|
||||
|
||||
Mintlify uses port 3000 by default. You can use the \`--port\` flag to customize the port Mintlify runs on. For example, use this command to run in port 3333:
|
||||
|
||||
\`\`\`
|
||||
mintlify dev --port 3333
|
||||
\`\`\`
|
||||
|
||||
You will see an error like this if you try to run Mintlify in a port that's already taken:
|
||||
|
||||
\`\`\`
|
||||
Error: listen EADDRINUSE: address already in use :::3000
|
||||
\`\`\`
|
||||
|
||||
`
|
||||
},
|
||||
{
|
||||
title: "New user welcome email drip campaign",
|
||||
shortTitle: "GitHub stars to Slack",
|
||||
description: "Create a welcome email drip campaign using Slack and Resend",
|
||||
imageURL: ResendSlackTemplateBg,
|
||||
githubRepoURL: "repo-url",
|
||||
services: ["slack", "github"],
|
||||
},
|
||||
{
|
||||
title: "Add a new product to Shopify",
|
||||
shortTitle: "GitHub stars to Slack",
|
||||
description: "Add a new product to Shopify",
|
||||
imageURL: ShopifyTemplateBg,
|
||||
githubRepoURL: "repo-url",
|
||||
services: ["slack", "github"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import type { TemplateListItem } from "~/presenters/templateListPresenter.server";
|
||||
import { ApiLogoIcon } from "../code/ApiLogoIcon";
|
||||
import { StyledDialog } from "../primitives/Dialog";
|
||||
import { Body } from "../primitives/text/Body";
|
||||
import { Header1 } from "../primitives/text/Headers";
|
||||
import { TemplateOverview } from "./TemplateOverview";
|
||||
|
||||
export function TemplatesGrid({
|
||||
templates,
|
||||
openInNewPage,
|
||||
}: {
|
||||
templates: Array<TemplateListItem>;
|
||||
openInNewPage: boolean;
|
||||
}) {
|
||||
const [openedTemplate, setOpenedTemplate] = useState<TemplateListItem | null>(
|
||||
null
|
||||
);
|
||||
const isOpen = openedTemplate !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledDialog.Dialog
|
||||
onClose={(e) => setOpenedTemplate(null)}
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<StyledDialog.Panel className="relative mx-auto flex max-h-[80vh] max-w-5xl items-start gap-2 overflow-hidden overflow-y-auto rounded-md">
|
||||
{openedTemplate && <TemplateOverview template={openedTemplate} />}
|
||||
<button
|
||||
onClick={() => setOpenedTemplate(null)}
|
||||
className="sticky top-0 text-slate-600 transition hover:text-slate-500"
|
||||
>
|
||||
<XCircleIcon className="h-10 w-10" />
|
||||
</button>
|
||||
</StyledDialog.Panel>
|
||||
</StyledDialog.Dialog>
|
||||
<div className="grid w-full grid-cols-1 items-start justify-start gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template) => {
|
||||
return (
|
||||
<TemplateButtonOrLink
|
||||
key={template.slug}
|
||||
template={template}
|
||||
openInNewPage={openInNewPage}
|
||||
onClick={() => setOpenedTemplate(template)}
|
||||
>
|
||||
<div className="h-36 w-full bg-slate-600 transition group-hover:opacity-90">
|
||||
<img
|
||||
src={template.imageUrl}
|
||||
alt=""
|
||||
className="h-36 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col place-content-between p-4">
|
||||
<div className="flex flex-col gap-y-2 ">
|
||||
<Header1 size="small" className="font-semibold">
|
||||
{template.title}
|
||||
</Header1>
|
||||
<Body size="small" className="text-slate-400">
|
||||
{template.description}
|
||||
</Body>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-row gap-x-1">
|
||||
{template.services.map((service) => (
|
||||
<div key={service.slug} className="">
|
||||
<ApiLogoIcon
|
||||
integration={service}
|
||||
size="regular"
|
||||
className="mt-2 flex h-8 w-8 items-center justify-center rounded border-[1px] border-slate-700 bg-slate-900 transition group-hover:border-slate-600 group-hover:bg-slate-900/80"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TemplateButtonOrLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateButtonOrLink({
|
||||
template,
|
||||
openInNewPage,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
template: TemplateListItem;
|
||||
openInNewPage: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const classNames =
|
||||
"group flex w-full flex-col self-stretch overflow-hidden rounded-md border border-slate-700 bg-slate-800 text-left text-slate-200 shadow-md transition hover:cursor-pointer hover:border-slate-500 hover:bg-slate-700/30 disabled:opacity-50";
|
||||
|
||||
if (openInNewPage) {
|
||||
return (
|
||||
<Link to={template.slug} className={classNames}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
key={template.title}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex w-full flex-col self-stretch overflow-hidden rounded-md border border-slate-700 bg-slate-800 text-left text-slate-200 shadow-md transition hover:cursor-pointer hover:border-slate-500 hover:bg-slate-700/30 disabled:opacity-50"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import classNames from "classnames";
|
||||
import { WorkflowListItem } from "~/models/workflowListPresenter.server";
|
||||
import { formatDateTime } from "~/utils";
|
||||
import { ApiLogoIcon } from "../code/ApiLogoIcon";
|
||||
import { List } from "../layout/List";
|
||||
import { Header2, Header3 } from "../primitives/text/Headers";
|
||||
import { runStatusLabel } from "../runs/runStatus";
|
||||
import { TriggerTypeIcon } from "../triggers/TriggerIcons";
|
||||
|
||||
export function WorkflowList({
|
||||
workflows,
|
||||
currentOrganizationSlug,
|
||||
className,
|
||||
}: {
|
||||
workflows: WorkflowListItem[];
|
||||
currentOrganizationSlug: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<List className={className}>
|
||||
{workflows.map((workflow) => {
|
||||
return (
|
||||
<li key={workflow.id}>
|
||||
<Link
|
||||
to={`/orgs/${currentOrganizationSlug}/workflows/${workflow.slug}`}
|
||||
className={classNames(
|
||||
"relative block overflow-hidden transition hover:bg-slate-850/40",
|
||||
workflow.status === "DISABLED" ? workflowDisabled : ""
|
||||
)}
|
||||
>
|
||||
{workflow.lastRun === undefined && (
|
||||
<div className="absolute top-2 -right-8 rotate-45 bg-green-700 px-8 py-0.5 text-xs font-semibold uppercase tracking-wide text-green-200 shadow-md">
|
||||
New
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="relative flex items-center">
|
||||
{workflow.status === "CREATED" && (
|
||||
<ExclamationTriangleIcon className="absolute -top-1.5 -left-1.5 h-6 w-6 text-amber-400" />
|
||||
)}
|
||||
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
|
||||
<TriggerTypeIcon
|
||||
type={workflow.trigger.type}
|
||||
provider={workflow.integrations.source}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-1 flex flex-col gap-1 truncate">
|
||||
<Header2
|
||||
size="regular"
|
||||
className="truncate text-slate-200"
|
||||
>
|
||||
{workflow.title}
|
||||
</Header2>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<PillLabel label={workflow.trigger.typeTitle} />
|
||||
<Header3
|
||||
size="extra-small"
|
||||
className="truncate text-slate-400"
|
||||
>
|
||||
{workflow.trigger.title}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
{workflow.trigger.properties &&
|
||||
workflow.trigger.properties.map((property) => (
|
||||
<WorkflowProperty
|
||||
key={property.key}
|
||||
label={property.key}
|
||||
content={`${property.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="ml-5 h-5 w-5 shrink-0 text-slate-400 lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center lg:flex-grow-0">
|
||||
<div className="mt-2 flex w-full flex-wrap-reverse items-center justify-between gap-3 lg:mt-0 lg:justify-end">
|
||||
<div className="flex flex-col text-left lg:text-right">
|
||||
<Body size="extra-small" className="text-slate-500">
|
||||
Last run: {lastRunDescription(workflow.lastRun)}
|
||||
</Body>
|
||||
<Body size="extra-small" className="text-slate-500">
|
||||
{workflow.slug}
|
||||
</Body>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow.integrations.source && (
|
||||
<ApiLogoIcon
|
||||
integration={workflow.integrations.source}
|
||||
size="regular"
|
||||
/>
|
||||
)}
|
||||
{workflow.integrations.services.map((service) => {
|
||||
if (service === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ApiLogoIcon
|
||||
size="regular"
|
||||
key={service.slug}
|
||||
integration={service}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="ml-5 hidden h-5 w-5 shrink-0 text-slate-400 lg:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function lastRunDescription(lastRun: WorkflowListItem["lastRun"]) {
|
||||
if (lastRun === null || lastRun === undefined) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
if (lastRun.status === "SUCCESS") {
|
||||
if (lastRun.finishedAt) {
|
||||
return formatDateTime(lastRun.finishedAt);
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return runStatusLabel(lastRun.status);
|
||||
}
|
||||
|
||||
function PillLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="rounded bg-slate-700 px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowProperty({
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
label: string;
|
||||
content: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-x-1">
|
||||
<Body size="extra-small" className="uppercase text-slate-500">
|
||||
{label}
|
||||
</Body>
|
||||
<Body size="small" className="truncate text-slate-400">
|
||||
{content}
|
||||
</Body>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workflowDisabled = "opacity-30";
|
||||
@@ -32,7 +32,7 @@ function getClient() {
|
||||
urlWithoutCredentials.password = "";
|
||||
|
||||
console.log(
|
||||
`1. 🔌 setting up prisma client to ${urlWithoutCredentials.toString()}`
|
||||
`🔌 setting up prisma client to ${urlWithoutCredentials.toString()}`
|
||||
);
|
||||
|
||||
const client = new PrismaClient({
|
||||
@@ -41,15 +41,36 @@ function getClient() {
|
||||
url: DATABASE_URL,
|
||||
},
|
||||
},
|
||||
log: ["warn", "error"],
|
||||
log: [
|
||||
// {
|
||||
// emit: "event",
|
||||
// level: "query",
|
||||
// },
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "error",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "info",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "warn",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log(`2.0 🔌 prisma client connecting`);
|
||||
// client.$on("query", (e) => {
|
||||
// console.log("Query: " + e.query);
|
||||
// console.log("Params: " + e.params);
|
||||
// console.log("Duration: " + e.duration + "ms");
|
||||
// });
|
||||
|
||||
// connect eagerly
|
||||
client.$connect();
|
||||
|
||||
console.log(`3.0 🔌 prisma client connected`);
|
||||
console.log(`🔌 prisma client connected`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -2,21 +2,31 @@ import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import * as Sentry from "@sentry/remix";
|
||||
import { useEffect } from "react";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
hydrateRoot(document, <RemixBrowser />);
|
||||
|
||||
//hack because the type is not exported
|
||||
type SentryIntegration = (typeof Sentry.defaultIntegrations)[number];
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.init({
|
||||
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
|
||||
tracesSampleRate: 1,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing({
|
||||
routingInstrumentation: Sentry.remixRouterInstrumentation(
|
||||
useEffect,
|
||||
useLocation,
|
||||
useMatches
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
Sentry.init({
|
||||
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
|
||||
tracesSampleRate: 1,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing({
|
||||
routingInstrumentation: Sentry.remixRouterInstrumentation(
|
||||
useEffect,
|
||||
useLocation,
|
||||
useMatches
|
||||
),
|
||||
}),
|
||||
//casted because TypeScript is unhappy about the type from PostHog
|
||||
new posthog.SentryIntegration(
|
||||
posthog,
|
||||
"triggerdev",
|
||||
4504419607052288
|
||||
) as SentryIntegration,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ const EnvironmentSchema = z.object({
|
||||
PULSAR_AUDIENCE: z.string().optional(),
|
||||
PULSAR_DEBUG: z.string().optional(),
|
||||
INTERNAL_TRIGGER_API_KEY: z.string().optional(),
|
||||
GITHUB_APP_NAME: z.string().optional(),
|
||||
GITHUB_APP_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_APP_PRIVATE_KEY: z.string().optional(),
|
||||
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
|
||||
INTEGRATIONS_API_KEY: z.string(),
|
||||
INTEGRATIONS_API_ORIGIN: z.string(),
|
||||
});
|
||||
|
||||
@@ -4,14 +4,10 @@ import type { loader as orgLoader } from "~/routes/__app/orgs/$organizationSlug"
|
||||
import { hydrateObject, useMatchesData } from "~/utils";
|
||||
|
||||
export function useOrganizations() {
|
||||
const routeMatch = useMatchesData("routes/__app");
|
||||
|
||||
if (!routeMatch || !routeMatch.data.organizations) {
|
||||
return undefined;
|
||||
}
|
||||
return hydrateObject<
|
||||
UseDataFunctionReturn<typeof appLoader>["organizations"]
|
||||
>(routeMatch.data.organizations);
|
||||
return (
|
||||
getOrganizationsFromMatchesData("routes/__app") ??
|
||||
getOrganizationsFromMatchesData("routes/__public")
|
||||
);
|
||||
}
|
||||
|
||||
export function useCurrentOrganization() {
|
||||
@@ -37,3 +33,14 @@ export function useIsNewOrganizationPage(): boolean {
|
||||
const routeMatch = useMatchesData("routes/__app/orgs/new");
|
||||
return !!routeMatch;
|
||||
}
|
||||
|
||||
function getOrganizationsFromMatchesData(path: string) {
|
||||
const routeMatch = useMatchesData(path);
|
||||
|
||||
if (!routeMatch || !routeMatch.data.organizations) {
|
||||
return undefined;
|
||||
}
|
||||
return hydrateObject<
|
||||
UseDataFunctionReturn<typeof appLoader>["organizations"]
|
||||
>(routeMatch.data.organizations);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session } from "@remix-run/node";
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
@@ -26,6 +26,24 @@ export function setErrorMessage(session: Session, message: string) {
|
||||
session.flash("toastMessage", { message, type: "error" } as ToastMessage);
|
||||
}
|
||||
|
||||
export async function jsonWithSuccessMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message);
|
||||
|
||||
return json(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectWithSuccessMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
|
||||
@@ -41,6 +41,28 @@ export function getOrganizationFromSlug({
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkflowsCreatedSinceDate(
|
||||
userId: string,
|
||||
slug: string,
|
||||
since: Date
|
||||
) {
|
||||
return prisma.workflow.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
users: {
|
||||
some: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
slug,
|
||||
},
|
||||
createdAt: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrganizations({ userId }: { userId: User["id"] }) {
|
||||
return prisma.organization.findMany({
|
||||
where: { users: { some: { id: userId } } },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getIntegrationMetadataByService } from "~/models/integrations.server";
|
||||
import { getRuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { renderMarkdown } from "~/services/renderMarkdown.server";
|
||||
import { TemplateListItem } from "./templateListPresenter.server";
|
||||
import { WorkflowsPresenter } from "./workflowsPresenter.server";
|
||||
|
||||
export class OrganizationTemplatePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data(templateId: string, environmentSlug: string) {
|
||||
const organizationTemplate =
|
||||
await this.#prismaClient.organizationTemplate.findUnique({
|
||||
where: {
|
||||
id: templateId,
|
||||
},
|
||||
include: {
|
||||
template: true,
|
||||
authorization: true,
|
||||
organization: {
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organizationTemplate) {
|
||||
throw new Error("Organization template not found");
|
||||
}
|
||||
|
||||
const runtimeEnvironment = await getRuntimeEnvironment({
|
||||
organizationId: organizationTemplate.organizationId,
|
||||
slug: environmentSlug,
|
||||
});
|
||||
|
||||
if (!runtimeEnvironment) {
|
||||
throw new Error("Runtime environment not found");
|
||||
}
|
||||
|
||||
const workflowsPresenter = new WorkflowsPresenter(this.#prismaClient);
|
||||
|
||||
const workflows = await workflowsPresenter.data(
|
||||
{
|
||||
organizationId: organizationTemplate.organizationId,
|
||||
slug: {
|
||||
in: organizationTemplate.template.workflowIds,
|
||||
},
|
||||
},
|
||||
runtimeEnvironment.id
|
||||
);
|
||||
|
||||
const repositoryName =
|
||||
organizationTemplate.repositoryUrl.split("/").pop() ??
|
||||
"missing repository name";
|
||||
|
||||
const template: TemplateListItem = {
|
||||
...organizationTemplate.template,
|
||||
services: organizationTemplate.template.services.map(
|
||||
getIntegrationMetadataByService
|
||||
),
|
||||
docsHTML: renderMarkdown(organizationTemplate.template.markdownDocs),
|
||||
};
|
||||
|
||||
const developmentApiKey =
|
||||
organizationTemplate.organization.environments.find(
|
||||
(e) => e.slug === "development"
|
||||
)?.apiKey;
|
||||
const liveApiKey = organizationTemplate.organization.environments.find(
|
||||
(e) => e.slug === "live"
|
||||
)?.apiKey;
|
||||
|
||||
return {
|
||||
template,
|
||||
organizationTemplate,
|
||||
developmentApiKey,
|
||||
liveApiKey,
|
||||
workflows,
|
||||
runLocalDocsHTML: renderLocalDocsHTML(
|
||||
organizationTemplate.repositoryUrl,
|
||||
organizationTemplate.template.repositoryUrl,
|
||||
organizationTemplate.name,
|
||||
organizationTemplate.template.slug,
|
||||
developmentApiKey ?? runtimeEnvironment.apiKey,
|
||||
organizationTemplate.template.runLocalDocs
|
||||
),
|
||||
repositoryName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the templateRepoUrl in localDocs with the finalRepoUrl, and then renderMarkdown
|
||||
function renderLocalDocsHTML(
|
||||
finalRepoUrl: string,
|
||||
templateRepoUrl: string,
|
||||
finalRepoName: string,
|
||||
templateRepoName: string,
|
||||
apiKey: string,
|
||||
localDocs: string
|
||||
) {
|
||||
// Replace all instances (not just the first) of the templateRepoUrl with the finalRepoUrl
|
||||
const finalRepoUrlRegex = new RegExp(templateRepoUrl, "g");
|
||||
let finalDocs = localDocs.replace(finalRepoUrlRegex, finalRepoUrl);
|
||||
|
||||
// Replace all instances (not just the first) of the templateRepoName with the finalRepoName
|
||||
const finalRepoNameRegex = new RegExp(`cd ${templateRepoName}`, "g");
|
||||
finalDocs = finalDocs.replace(finalRepoNameRegex, `cd ${finalRepoName}`);
|
||||
|
||||
// Replace all instances of <API_KEY> or <APIKEY> or <your api key> with the apiKey
|
||||
const apiRegex = new RegExp("<API_KEY>", "g");
|
||||
finalDocs = finalDocs.replace(apiRegex, apiKey);
|
||||
|
||||
const apiRegex2 = new RegExp("<APIKEY>", "g");
|
||||
finalDocs = finalDocs.replace(apiRegex2, apiKey);
|
||||
|
||||
const apiRegex3 = new RegExp("<your api key>", "g");
|
||||
finalDocs = finalDocs.replace(apiRegex3, apiKey);
|
||||
|
||||
return renderMarkdown(finalDocs);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Template } from ".prisma/client";
|
||||
import type { IntegrationMetadata } from "@trigger.dev/integration-sdk";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getIntegrationMetadataByService } from "~/models/integrations.server";
|
||||
import { renderMarkdown } from "~/services/renderMarkdown.server";
|
||||
|
||||
export type TemplateListItem = Omit<Template, "services"> & {
|
||||
services: Array<IntegrationMetadata>;
|
||||
docsHTML: string;
|
||||
};
|
||||
|
||||
export class TemplateListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data(): Promise<{ templates: Array<TemplateListItem> }> {
|
||||
const templates = await this.#prismaClient.template.findMany({
|
||||
orderBy: { priority: "asc" },
|
||||
});
|
||||
|
||||
const templatesWithServiceMetadata = templates.map((template) => {
|
||||
const services = template.services.map(getIntegrationMetadataByService);
|
||||
|
||||
return {
|
||||
...template,
|
||||
docsHTML: renderMarkdown(template.markdownDocs),
|
||||
services,
|
||||
};
|
||||
});
|
||||
|
||||
return { templates: templatesWithServiceMetadata };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getIntegrationMetadataByService } from "~/models/integrations.server";
|
||||
import { renderMarkdown } from "~/services/renderMarkdown.server";
|
||||
import { TemplateListItem } from "./templateListPresenter.server";
|
||||
|
||||
export class TemplatePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data({
|
||||
slug,
|
||||
id,
|
||||
}: {
|
||||
slug?: string;
|
||||
id?: string;
|
||||
}): Promise<{ template: TemplateListItem | undefined }> {
|
||||
const template = slug
|
||||
? await this.#prismaClient.template.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
})
|
||||
: await this.#prismaClient.template.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return { template: undefined };
|
||||
}
|
||||
|
||||
const templateWithServiceMetadata = {
|
||||
...template,
|
||||
docsHTML: renderMarkdown(template.markdownDocs),
|
||||
services: template.services.map(getIntegrationMetadataByService),
|
||||
};
|
||||
|
||||
return { template: templateWithServiceMetadata };
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ export function getWorkflowFromSlugs({
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
organizationTemplate: {
|
||||
select: {
|
||||
repositoryUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
slug: workflowSlug,
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import type { SchedulerSource, InternalSource } from ".prisma/client";
|
||||
import {
|
||||
ScheduleSourceSchema,
|
||||
SlackInteractionSourceSchema,
|
||||
} from "@trigger.dev/common-schemas";
|
||||
import cronstrue from "cronstrue";
|
||||
import type { DisplayProperties } from "@trigger.dev/integration-sdk";
|
||||
import * as github from "@trigger.dev/github/internal";
|
||||
import invariant from "tiny-invariant";
|
||||
import { triggerLabel } from "~/components/triggers/triggerLabel";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { WorkflowsPresenter } from "~/presenters/workflowsPresenter.server";
|
||||
import { getRuntimeEnvironment } from "./runtimeEnvironment.server";
|
||||
import type { ExternalSource, Workflow } from "./workflow.server";
|
||||
import { getServiceMetadatas } from "./integrations.server";
|
||||
|
||||
export type WorkflowListItem = Awaited<
|
||||
ReturnType<WorkflowListPresenter["data"]>
|
||||
@@ -38,250 +28,11 @@ export class WorkflowListPresenter {
|
||||
});
|
||||
invariant(runtimeEnvironment, "Runtime environment not found");
|
||||
|
||||
const workflows = await getWorkflows(
|
||||
this.#prismaClient,
|
||||
organizationSlug,
|
||||
const workflowsPresenter = new WorkflowsPresenter();
|
||||
|
||||
return workflowsPresenter.data(
|
||||
{ organization: { slug: organizationSlug }, isArchived: false },
|
||||
runtimeEnvironment.id
|
||||
);
|
||||
const servicesMetadata = await getServiceMetadatas(true);
|
||||
|
||||
return workflows.map((workflow) => {
|
||||
const lastRun =
|
||||
workflow.runs[0] === undefined
|
||||
? undefined
|
||||
: {
|
||||
finishedAt: workflow.runs[0].finishedAt,
|
||||
status: workflow.runs[0].status,
|
||||
};
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
title: workflow.title,
|
||||
slug: workflow.slug,
|
||||
status: workflow.status,
|
||||
trigger: triggerProperties(
|
||||
workflow,
|
||||
workflow.externalSource ?? undefined,
|
||||
workflow.schedulerSources[0] ?? undefined,
|
||||
workflow.internalSources[0] ?? undefined
|
||||
),
|
||||
integrations: {
|
||||
source: workflow.service
|
||||
? servicesMetadata[workflow.service]
|
||||
: undefined,
|
||||
services: workflow.externalServices.map(
|
||||
(service) => servicesMetadata[service.service]
|
||||
),
|
||||
},
|
||||
lastRun,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkflows(
|
||||
prismaClient: PrismaClient,
|
||||
organizationSlug: string,
|
||||
environmentId: string
|
||||
) {
|
||||
return prismaClient.workflow.findMany({
|
||||
where: { organization: { slug: organizationSlug }, isArchived: false },
|
||||
include: {
|
||||
externalServices: {
|
||||
select: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
externalSource: {
|
||||
select: {
|
||||
service: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
schedulerSources: {
|
||||
select: {
|
||||
schedule: true,
|
||||
},
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
internalSources: {
|
||||
select: {
|
||||
source: true,
|
||||
type: true,
|
||||
},
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
finishedAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: { finishedAt: { sort: "desc", nulls: "last" } },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ disabledAt: { sort: "asc", nulls: "first" } },
|
||||
{ title: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function triggerProperties(
|
||||
workflow: Pick<Workflow, "type" | "eventNames">,
|
||||
externalSource?: Pick<ExternalSource, "service" | "source">,
|
||||
schedulerSource?: Pick<SchedulerSource, "schedule">,
|
||||
internalSource?: Pick<InternalSource, "type" | "source">
|
||||
): {
|
||||
type: Workflow["type"];
|
||||
typeTitle: string;
|
||||
title: string;
|
||||
properties?: DisplayProperties["properties"];
|
||||
} {
|
||||
switch (workflow.type) {
|
||||
case "WEBHOOK": {
|
||||
invariant(externalSource, "External source is required for webhook");
|
||||
|
||||
let displayProperties: DisplayProperties;
|
||||
switch (externalSource.service) {
|
||||
case "github":
|
||||
if (github.internalIntegration.webhooks) {
|
||||
displayProperties =
|
||||
github.internalIntegration.webhooks?.displayProperties(
|
||||
externalSource.source
|
||||
);
|
||||
} else {
|
||||
displayProperties = {
|
||||
title: externalSource.service,
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
displayProperties = {
|
||||
title: externalSource.service,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Webhook",
|
||||
title: displayProperties.title,
|
||||
properties: displayProperties.properties,
|
||||
};
|
||||
}
|
||||
case "SCHEDULE": {
|
||||
if (!schedulerSource) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: "Not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const source = ScheduleSourceSchema.parse(schedulerSource.schedule);
|
||||
|
||||
if ("rateOf" in source) {
|
||||
const unit =
|
||||
"minutes" in source.rateOf
|
||||
? source.rateOf.minutes > 1
|
||||
? "minutes"
|
||||
: "minute"
|
||||
: "hours" in source.rateOf
|
||||
? source.rateOf.hours > 1
|
||||
? "hours"
|
||||
: "hour"
|
||||
: source.rateOf.days > 1
|
||||
? "days"
|
||||
: "day";
|
||||
|
||||
const value =
|
||||
"minutes" in source.rateOf
|
||||
? source.rateOf.minutes
|
||||
: "hours" in source.rateOf
|
||||
? source.rateOf.hours
|
||||
: source.rateOf.days;
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: `Every ${value} ${unit}`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: cronstrue.toString(source.cron, {
|
||||
throwExceptionOnParseError: false,
|
||||
verbose: false,
|
||||
use24HourTimeFormat: true,
|
||||
}),
|
||||
properties: [{ key: "Cron Expression", value: source.cron }],
|
||||
};
|
||||
}
|
||||
}
|
||||
case "CUSTOM_EVENT":
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Custom event",
|
||||
title: `on: ${workflow.eventNames.join(", ")}`,
|
||||
};
|
||||
case "SLACK_INTERACTION": {
|
||||
if (!internalSource) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: "on: Slack interaction",
|
||||
};
|
||||
}
|
||||
|
||||
const slackSource = SlackInteractionSourceSchema.safeParse(
|
||||
internalSource.source
|
||||
);
|
||||
|
||||
if (!slackSource.success) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: "on: Slack interaction",
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
slackSource.data.type === "block_action"
|
||||
? `block_id = ${slackSource.data.blockId}`
|
||||
: `callback_id = ${slackSource.data.callbackIds.join(", ")}`;
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: title,
|
||||
properties:
|
||||
slackSource.data.type === "block_action" &&
|
||||
slackSource.data.actionIds.length > 0
|
||||
? [
|
||||
{
|
||||
key: "Action ID",
|
||||
value: slackSource.data.actionIds.join(", "),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: triggerLabel(workflow.type),
|
||||
title: workflow.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getIntegrationMetadataByService } from "~/models/integrations.server";
|
||||
import { renderMarkdown } from "~/services/renderMarkdown.server";
|
||||
import { TemplateListItem } from "./templateListPresenter.server";
|
||||
|
||||
export class WorkflowStartPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data({
|
||||
organizationSlug,
|
||||
userId,
|
||||
templateId,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
templateId?: string;
|
||||
}) {
|
||||
const appAuthorizations =
|
||||
await this.#prismaClient.gitHubAppAuthorization.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountName: true,
|
||||
installationId: true,
|
||||
permissions: true,
|
||||
repositorySelection: true,
|
||||
},
|
||||
});
|
||||
|
||||
const template = await this.#getTemplate(templateId);
|
||||
|
||||
const templates = await this.#prismaClient.template.findMany({
|
||||
orderBy: {
|
||||
priority: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
appAuthorizations,
|
||||
templates,
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
async #getTemplate(
|
||||
templateId: string | undefined
|
||||
): Promise<TemplateListItem | undefined> {
|
||||
if (!templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await this.#prismaClient.template.findUnique({
|
||||
where: {
|
||||
id: templateId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...template,
|
||||
services: template.services.map(getIntegrationMetadataByService),
|
||||
docsHTML: renderMarkdown(template.markdownDocs),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { SchedulerSource, InternalSource } from ".prisma/client";
|
||||
import {
|
||||
ScheduleSourceSchema,
|
||||
SlackInteractionSourceSchema,
|
||||
} from "@trigger.dev/common-schemas";
|
||||
import cronstrue from "cronstrue";
|
||||
import type { DisplayProperties } from "@trigger.dev/integration-sdk";
|
||||
import * as github from "@trigger.dev/github/internal";
|
||||
import invariant from "tiny-invariant";
|
||||
import { triggerLabel } from "~/components/triggers/triggerLabel";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma, Prisma } from "~/db.server";
|
||||
import {
|
||||
getIntegrationMetadata,
|
||||
getIntegrations,
|
||||
} from "../models/integrations.server";
|
||||
import type { ExternalSource, Workflow } from "../models/workflow.server";
|
||||
|
||||
export type WorkflowListItem = Awaited<
|
||||
ReturnType<WorkflowsPresenter["data"]>
|
||||
>[number];
|
||||
|
||||
export class WorkflowsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async data(whereInput: Prisma.WorkflowWhereInput, environmentId: string) {
|
||||
const workflows = await getWorkflows(
|
||||
this.#prismaClient,
|
||||
whereInput,
|
||||
environmentId
|
||||
);
|
||||
const integrations = getIntegrations(true);
|
||||
|
||||
return workflows.map((workflow) => {
|
||||
const lastRun =
|
||||
workflow.runs[0] === undefined
|
||||
? undefined
|
||||
: {
|
||||
finishedAt: workflow.runs[0].finishedAt,
|
||||
status: workflow.runs[0].status,
|
||||
};
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
title: workflow.title,
|
||||
slug: workflow.slug,
|
||||
status: workflow.status,
|
||||
trigger: triggerProperties(
|
||||
workflow,
|
||||
workflow.externalSource ?? undefined,
|
||||
workflow.schedulerSources[0] ?? undefined,
|
||||
workflow.internalSources[0] ?? undefined
|
||||
),
|
||||
integrations: {
|
||||
source: workflow.service
|
||||
? getIntegrationMetadata(integrations, workflow.service)
|
||||
: undefined,
|
||||
services: workflow.externalServices.map((service) =>
|
||||
getIntegrationMetadata(integrations, service.service)
|
||||
),
|
||||
},
|
||||
lastRun,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkflows(
|
||||
prismaClient: PrismaClient,
|
||||
whereInput: Prisma.WorkflowWhereInput,
|
||||
environmentId: string
|
||||
) {
|
||||
return prismaClient.workflow.findMany({
|
||||
where: whereInput,
|
||||
include: {
|
||||
externalServices: {
|
||||
select: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
externalSource: {
|
||||
select: {
|
||||
service: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
schedulerSources: {
|
||||
select: {
|
||||
schedule: true,
|
||||
},
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
internalSources: {
|
||||
select: {
|
||||
source: true,
|
||||
type: true,
|
||||
},
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
finishedAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: { finishedAt: { sort: "desc", nulls: "last" } },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ disabledAt: { sort: "asc", nulls: "first" } },
|
||||
{ title: "asc" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function triggerProperties(
|
||||
workflow: Pick<Workflow, "type" | "eventNames">,
|
||||
externalSource?: Pick<ExternalSource, "service" | "source">,
|
||||
schedulerSource?: Pick<SchedulerSource, "schedule">,
|
||||
internalSource?: Pick<InternalSource, "type" | "source">
|
||||
): {
|
||||
type: Workflow["type"];
|
||||
typeTitle: string;
|
||||
title: string;
|
||||
properties?: DisplayProperties["properties"];
|
||||
} {
|
||||
switch (workflow.type) {
|
||||
case "WEBHOOK": {
|
||||
invariant(externalSource, "External source is required for webhook");
|
||||
|
||||
let displayProperties: DisplayProperties;
|
||||
switch (externalSource.service) {
|
||||
case "github":
|
||||
if (github.internalIntegration.webhooks) {
|
||||
displayProperties =
|
||||
github.internalIntegration.webhooks?.displayProperties(
|
||||
externalSource.source
|
||||
);
|
||||
} else {
|
||||
displayProperties = {
|
||||
title: externalSource.service,
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
displayProperties = {
|
||||
title: externalSource.service,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Webhook",
|
||||
title: displayProperties.title,
|
||||
properties: displayProperties.properties,
|
||||
};
|
||||
}
|
||||
case "SCHEDULE": {
|
||||
if (!schedulerSource) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: "Not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const source = ScheduleSourceSchema.parse(schedulerSource.schedule);
|
||||
|
||||
if ("rateOf" in source) {
|
||||
const unit =
|
||||
"minutes" in source.rateOf
|
||||
? source.rateOf.minutes > 1
|
||||
? "minutes"
|
||||
: "minute"
|
||||
: "hours" in source.rateOf
|
||||
? source.rateOf.hours > 1
|
||||
? "hours"
|
||||
: "hour"
|
||||
: source.rateOf.days > 1
|
||||
? "days"
|
||||
: "day";
|
||||
|
||||
const value =
|
||||
"minutes" in source.rateOf
|
||||
? source.rateOf.minutes
|
||||
: "hours" in source.rateOf
|
||||
? source.rateOf.hours
|
||||
: source.rateOf.days;
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: `Every ${value} ${unit}`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Schedule",
|
||||
title: cronstrue.toString(source.cron, {
|
||||
throwExceptionOnParseError: false,
|
||||
verbose: false,
|
||||
use24HourTimeFormat: true,
|
||||
}),
|
||||
properties: [{ key: "Cron Expression", value: source.cron }],
|
||||
};
|
||||
}
|
||||
}
|
||||
case "CUSTOM_EVENT":
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Custom event",
|
||||
title: `on: ${workflow.eventNames.join(", ")}`,
|
||||
};
|
||||
case "SLACK_INTERACTION": {
|
||||
if (!internalSource) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: "on: Slack interaction",
|
||||
};
|
||||
}
|
||||
|
||||
const slackSource = SlackInteractionSourceSchema.safeParse(
|
||||
internalSource.source
|
||||
);
|
||||
|
||||
if (!slackSource.success) {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: "on: Slack interaction",
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
slackSource.data.type === "block_action"
|
||||
? `block_id = ${slackSource.data.blockId}`
|
||||
: `callback_id = ${slackSource.data.callbackIds.join(", ")}`;
|
||||
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: "Slack interaction",
|
||||
title: title,
|
||||
properties:
|
||||
slackSource.data.type === "block_action" &&
|
||||
slackSource.data.actionIds.length > 0
|
||||
? [
|
||||
{
|
||||
key: "Action ID",
|
||||
value: slackSource.data.actionIds.join(", "),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: workflow.type,
|
||||
typeTitle: triggerLabel(workflow.type),
|
||||
title: workflow.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { NoMobileOverlay } from "~/components/NoMobileOverlay";
|
||||
import { IntercomProvider, useIntercom } from "react-use-intercom";
|
||||
import { useEffect } from "react";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import {
|
||||
clearCurrentTemplate,
|
||||
commitCurrentTemplateSession,
|
||||
} from "~/services/currentTemplate.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -23,9 +27,15 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
impersonationId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(await clearRedirectTo(request)),
|
||||
},
|
||||
headers: [
|
||||
["Set-Cookie", await commitSession(await clearRedirectTo(request))],
|
||||
[
|
||||
"Set-Cookie",
|
||||
await commitCurrentTemplateSession(
|
||||
await clearCurrentTemplate(request)
|
||||
),
|
||||
],
|
||||
],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ import { Outlet } from "@remix-run/react";
|
||||
import { getOrganizationFromSlug } from "~/models/organization.server";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
commitCurrentOrgSession,
|
||||
setCurrentOrg,
|
||||
} from "~/services/currentOrganization.server";
|
||||
import { analytics } from "~/services/analytics.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
@@ -34,13 +38,22 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await setCurrentOrg(organization.slug, request);
|
||||
|
||||
analytics.environment.identify({ environment: currentEnvironment });
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
currentEnvironment,
|
||||
currentEnvironmentSlug,
|
||||
});
|
||||
return typedjson(
|
||||
{
|
||||
organization,
|
||||
currentEnvironment,
|
||||
currentEnvironmentSlug,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentOrgSession(session),
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default function Organization() {
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import classNames from "classnames";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
|
||||
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { List } from "~/components/layout/List";
|
||||
import { PanelInfo } from "~/components/layout/PanelInfo";
|
||||
import { PrimaryLink } from "~/components/primitives/Buttons";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header2, Header3 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import { runStatusLabel } from "~/components/runs/runStatus";
|
||||
import { TriggerTypeIcon } from "~/components/triggers/TriggerIcons";
|
||||
import { WorkflowList } from "~/components/workflows/workflowList";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import type { WorkflowListItem } from "~/models/workflowListPresenter.server";
|
||||
import { WorkflowListPresenter } from "~/models/workflowListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatDateTime } from "~/utils";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
await requireUserId(request);
|
||||
@@ -81,163 +68,3 @@ export default function Page() {
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowList({
|
||||
workflows,
|
||||
currentOrganizationSlug,
|
||||
}: {
|
||||
workflows: WorkflowListItem[];
|
||||
currentOrganizationSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<List>
|
||||
{workflows.map((workflow) => {
|
||||
return (
|
||||
<li key={workflow.id}>
|
||||
<Link
|
||||
to={`/orgs/${currentOrganizationSlug}/workflows/${workflow.slug}`}
|
||||
className={classNames(
|
||||
"relative block overflow-hidden transition hover:bg-slate-850/40",
|
||||
workflow.status === "DISABLED" ? workflowDisabled : ""
|
||||
)}
|
||||
>
|
||||
{workflow.lastRun === undefined && (
|
||||
<div className="absolute top-2 -right-8 rotate-45 bg-green-700 px-8 py-0.5 text-xs font-semibold uppercase tracking-wide text-green-200 shadow-md">
|
||||
New
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="relative flex items-center">
|
||||
{workflow.status === "CREATED" && (
|
||||
<ExclamationTriangleIcon className="absolute -top-1.5 -left-1.5 h-6 w-6 text-amber-400" />
|
||||
)}
|
||||
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
|
||||
<TriggerTypeIcon
|
||||
type={workflow.trigger.type}
|
||||
provider={workflow.integrations.source}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-1 flex flex-col gap-1 truncate">
|
||||
<Header2
|
||||
size="regular"
|
||||
className="truncate text-slate-200"
|
||||
>
|
||||
{workflow.title}
|
||||
</Header2>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<PillLabel label={workflow.trigger.typeTitle} />
|
||||
<Header3
|
||||
size="extra-small"
|
||||
className="truncate text-slate-400"
|
||||
>
|
||||
{workflow.trigger.title}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
{workflow.trigger.properties &&
|
||||
workflow.trigger.properties.map((property) => (
|
||||
<WorkflowProperty
|
||||
key={property.key}
|
||||
label={property.key}
|
||||
content={`${property.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="ml-5 h-5 w-5 shrink-0 text-slate-400 lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center lg:flex-grow-0">
|
||||
<div className="mt-2 flex w-full flex-wrap-reverse items-center justify-between gap-3 lg:mt-0 lg:justify-end">
|
||||
<div className="flex flex-col text-left lg:text-right">
|
||||
<Body size="extra-small" className="text-slate-500">
|
||||
Last run: {lastRunDescription(workflow.lastRun)}
|
||||
</Body>
|
||||
<Body size="extra-small" className="text-slate-500">
|
||||
{workflow.slug}
|
||||
</Body>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow.integrations.source && (
|
||||
<ApiLogoIcon
|
||||
integration={workflow.integrations.source}
|
||||
size="regular"
|
||||
/>
|
||||
)}
|
||||
{workflow.integrations.services.map((service) => {
|
||||
if (service === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ApiLogoIcon
|
||||
size="regular"
|
||||
key={service.service}
|
||||
integration={service}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="ml-5 hidden h-5 w-5 shrink-0 text-slate-400 lg:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function lastRunDescription(lastRun: WorkflowListItem["lastRun"]) {
|
||||
if (lastRun === null || lastRun === undefined) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
if (lastRun.status === "SUCCESS") {
|
||||
if (lastRun.finishedAt) {
|
||||
return formatDateTime(lastRun.finishedAt);
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return runStatusLabel(lastRun.status);
|
||||
}
|
||||
|
||||
function PillLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="rounded bg-slate-700 px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowProperty({
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
label: string;
|
||||
content: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-x-1">
|
||||
<Body size="extra-small" className="uppercase text-slate-500">
|
||||
{label}
|
||||
</Body>
|
||||
<Body size="small" className="truncate text-slate-400">
|
||||
{content}
|
||||
</Body>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workflowDisabled = "opacity-30";
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { FolderIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useTransition } from "@remix-run/react";
|
||||
import { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
redirect,
|
||||
typedjson,
|
||||
useTypedActionData,
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { PanelWarning } from "~/components/layout/PanelWarning";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import { PrimaryButton, PrimaryLink } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Select } from "~/components/primitives/Select";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import { TemplateCard } from "~/components/templates/TemplateCard";
|
||||
import { WorkflowStartPresenter } from "~/presenters/workflowStartPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { AddTemplateService } from "~/services/templates/addTemplate.server";
|
||||
import { ConnectedToGithub, DeployBlankState } from "./templates/$templateId";
|
||||
|
||||
export async function loader({ params, request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = z
|
||||
.object({ organizationSlug: z.string() })
|
||||
.parse(params);
|
||||
|
||||
const { templateId } = z
|
||||
.object({ templateId: z.string().optional() })
|
||||
.parse(Object.fromEntries(new URL(request.url).searchParams));
|
||||
|
||||
const presenter = new WorkflowStartPresenter();
|
||||
|
||||
return typedjson(
|
||||
await presenter.data({ organizationSlug, userId, templateId })
|
||||
);
|
||||
}
|
||||
|
||||
export async function action({ params, request }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = z
|
||||
.object({ organizationSlug: z.string() })
|
||||
.parse(params);
|
||||
const payload = Object.fromEntries(await request.formData());
|
||||
|
||||
const service = new AddTemplateService();
|
||||
|
||||
const validation = service.validate(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
return typedjson(
|
||||
{
|
||||
type: "validationError" as const,
|
||||
errors: validation.error.issues,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await service.call({
|
||||
data: validation.data,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (result.type === "error") {
|
||||
return typedjson(
|
||||
{
|
||||
type: "serviceError" as const,
|
||||
message: result.message,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
return redirect(`/orgs/${organizationSlug}/templates/${result.template.id}`);
|
||||
}
|
||||
|
||||
export default function AddTemplatePage() {
|
||||
const { appAuthorizations, templates, template } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
const actionData = useTypedActionData<typeof action>();
|
||||
const transition = useTransition();
|
||||
|
||||
const isSubmittingOrLoading =
|
||||
(transition.state === "submitting" &&
|
||||
transition.type === "actionSubmission") ||
|
||||
(transition.state === "loading" && transition.type === "actionRedirect");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="grid w-full grid-cols-3 gap-8">
|
||||
<Form method="post" className="col-span-2 max-w-4xl">
|
||||
<Title>You're almost done</Title>
|
||||
|
||||
{!isSubmittingOrLoading && actionData?.type === "serviceError" ? (
|
||||
<PanelWarning
|
||||
message={actionData.message}
|
||||
className="mb-4"
|
||||
></PanelWarning>
|
||||
) : !isSubmittingOrLoading &&
|
||||
actionData?.type === "validationError" ? (
|
||||
<PanelWarning
|
||||
message="There was a problem with your submission."
|
||||
className="mb-4"
|
||||
></PanelWarning>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{appAuthorizations.length === 0 ? (
|
||||
<>
|
||||
<ConnectToGithub templateId={template?.id} />
|
||||
<ConfigureGithub />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ConnectedToGithub templateId={template?.id} />
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="2" />
|
||||
Where should we create the new repository?
|
||||
</SubTitle>
|
||||
<Panel className="!p-4">
|
||||
<div className="mb-3 grid grid-cols-2 gap-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor="appAuthorizationId">
|
||||
Select a GitHub account
|
||||
</Label>
|
||||
<Select
|
||||
disabled={isSubmittingOrLoading}
|
||||
name="appAuthorizationId"
|
||||
required
|
||||
>
|
||||
{appAuthorizations.map((appAuthorization) => (
|
||||
<option
|
||||
value={appAuthorization.id}
|
||||
key={appAuthorization.id}
|
||||
>
|
||||
{appAuthorization.accountName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{!isSubmittingOrLoading &&
|
||||
actionData?.type === "validationError" && (
|
||||
<FormError
|
||||
errors={actionData.errors}
|
||||
path={["appAuthorizationId"]}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{template ? (
|
||||
<input
|
||||
type="hidden"
|
||||
name="templateId"
|
||||
value={template.id}
|
||||
/>
|
||||
) : (
|
||||
<InputGroup>
|
||||
<Label htmlFor="templateId">Choose a template</Label>
|
||||
|
||||
<Select
|
||||
disabled={isSubmittingOrLoading}
|
||||
name="templateId"
|
||||
required
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<option value={template.id} key={template.id}>
|
||||
{template.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{!isSubmittingOrLoading &&
|
||||
actionData?.type === "validationError" && (
|
||||
<FormError
|
||||
errors={actionData.errors}
|
||||
path={["templateId"]}
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<Label htmlFor="name">
|
||||
Enter a repository name (required)
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={`e.g. ${
|
||||
template
|
||||
? `trigger.dev-${template.slug}`
|
||||
: `my-trigger.dev-workflows`
|
||||
}`}
|
||||
spellCheck={false}
|
||||
className=""
|
||||
disabled={isSubmittingOrLoading}
|
||||
/>
|
||||
|
||||
{!isSubmittingOrLoading &&
|
||||
actionData?.type === "validationError" && (
|
||||
<FormError errors={actionData.errors} path={["name"]} />
|
||||
)}
|
||||
</InputGroup>
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-slate-500">
|
||||
Set the repo as private
|
||||
</p>
|
||||
<div className="flex w-full items-center rounded bg-black/20 px-3 py-2.5">
|
||||
<Label
|
||||
htmlFor="private"
|
||||
className="flex cursor-pointer items-center gap-2 text-sm text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="private"
|
||||
id="private"
|
||||
className="border-3 h-4 w-4 cursor-pointer rounded border-black bg-slate-500 transition hover:bg-slate-300 focus:outline-none"
|
||||
disabled={isSubmittingOrLoading}
|
||||
/>
|
||||
Private repo
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{isSubmittingOrLoading ? (
|
||||
<PrimaryButton disabled>Creating repo...</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton type="submit">Create Repo</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DeployBlankState />
|
||||
</Form>
|
||||
<div className="w-full">
|
||||
{template && (
|
||||
<TemplateCard
|
||||
template={template}
|
||||
className="sticky top-0 mt-[60px] w-[300px] justify-self-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectToGithub({ templateId }: { templateId?: string }) {
|
||||
return (
|
||||
<>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="1" />
|
||||
Login with GitHub to get started
|
||||
</SubTitle>
|
||||
<Panel className="mb-6 flex h-56 items-center justify-center">
|
||||
<PrimaryLink
|
||||
size="large"
|
||||
to={`../apps/github${templateId ? `?templateId=${templateId}` : ``}`}
|
||||
>
|
||||
<OctoKitty className="mr-1 h-5 w-5" />
|
||||
Continue with GitHub
|
||||
</PrimaryLink>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigureGithub() {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber stepNumber="2" />
|
||||
Create your GitHub repository from a template
|
||||
</SubTitle>
|
||||
<Panel className="flex h-56 w-full max-w-4xl items-center justify-center gap-6">
|
||||
<OctoKitty className="h-10 w-10 text-slate-600" />
|
||||
<div className="h-[1px] w-16 border border-dashed border-slate-600"></div>
|
||||
<FolderIcon className="h-10 w-10 text-slate-600" />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
CloudIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import type { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEventSource } from "remix-utils";
|
||||
import { CopyTextButton } from "~/components/CopyTextButton";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { PanelInfo } from "~/components/layout/PanelInfo";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import {
|
||||
PrimaryButton,
|
||||
TertiaryA,
|
||||
TertiaryLink,
|
||||
} from "~/components/primitives/Buttons";
|
||||
import { StyledDialog } from "~/components/primitives/Dialog";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Select } from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header1, Header3 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { TemplateCard } from "~/components/templates/TemplateCard";
|
||||
import { WorkflowList } from "~/components/workflows/workflowList";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import { OrganizationTemplatePresenter } from "~/presenters/organizationTemplatePresenter.server";
|
||||
|
||||
export async function loader({ params, request }: LoaderArgs) {
|
||||
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
|
||||
|
||||
const presenter = new OrganizationTemplatePresenter();
|
||||
|
||||
return typedjson(
|
||||
await presenter.data(params.templateId as string, currentEnv)
|
||||
);
|
||||
}
|
||||
|
||||
type LoaderData = UseDataFunctionReturn<typeof loader>;
|
||||
|
||||
export default function TemplatePage() {
|
||||
const loaderData = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const events = useEventSource(
|
||||
`/resources/organizationTemplates/${loaderData.organizationTemplate.id}`
|
||||
);
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (events !== null) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const organizationTemplateByStatus = (
|
||||
<OrganizationTemplateByStatus {...loaderData} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="col-span-2">
|
||||
<Header1>You're almost done</Header1>
|
||||
{organizationTemplateByStatus}
|
||||
</div>
|
||||
<TemplateCard
|
||||
template={loaderData.template}
|
||||
className="sticky top-0 mt-12 max-w-[300px] justify-self-start"
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationTemplateByStatus(loaderData: LoaderData) {
|
||||
if (
|
||||
loaderData.organizationTemplate.status === "PENDING" ||
|
||||
loaderData.organizationTemplate.status === "CREATED"
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<ConnectedToGithub />
|
||||
<div className="mb-2 ml-1 flex max-w-4xl items-center gap-4">
|
||||
<Spinner />
|
||||
<SubTitle className="mb-0">
|
||||
Cloning the template repo into your GitHub account...
|
||||
</SubTitle>
|
||||
</div>
|
||||
<ConfiguringGithubState
|
||||
githubAccount={
|
||||
loaderData.organizationTemplate.authorization.accountName
|
||||
}
|
||||
isPrivate={loaderData.organizationTemplate.private}
|
||||
repositoryName={loaderData.repositoryName}
|
||||
/>
|
||||
<DeployBlankState />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrganizationTemplateReady {...loaderData} />;
|
||||
}
|
||||
|
||||
function OrganizationTemplateReady(loaderData: LoaderData) {
|
||||
const githubConfigured = (
|
||||
<GitHubConfigured
|
||||
githubAccount={loaderData.organizationTemplate.authorization.accountName}
|
||||
isPrivate={loaderData.organizationTemplate.private}
|
||||
repositoryName={loaderData.repositoryName}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loaderData.organizationTemplate.status === "READY_TO_DEPLOY" ? (
|
||||
<>
|
||||
<ConnectedToGithub />
|
||||
{githubConfigured}
|
||||
<div className="mt-4 mb-2 flex max-w-4xl items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<StepNumber active stepNumber="3" />
|
||||
<SubTitle className="mb-0">Your template is ready</SubTitle>
|
||||
</div>
|
||||
<TertiaryA
|
||||
target="_blank"
|
||||
href={loaderData.organizationTemplate.repositoryUrl}
|
||||
>
|
||||
<OctoKitty className="mr-0.5 h-4 w-4" />
|
||||
{loaderData.organizationTemplate.repositoryUrl.replace(
|
||||
"https://github.com/",
|
||||
""
|
||||
)}
|
||||
</TertiaryA>
|
||||
</div>
|
||||
<DeploySection {...loaderData} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ConnectedToGithub />
|
||||
{githubConfigured}
|
||||
<div className="mt-4 mb-2 flex max-w-4xl items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<StepNumber active stepNumber="3" />
|
||||
<SubTitle className="mb-0">Your workflow has connected!</SubTitle>
|
||||
</div>
|
||||
<TertiaryA
|
||||
target="_blank"
|
||||
href={loaderData.organizationTemplate.repositoryUrl}
|
||||
>
|
||||
<OctoKitty className="mr-0.5 h-4 w-4" />
|
||||
{loaderData.organizationTemplate.repositoryUrl.replace(
|
||||
"https://github.com/",
|
||||
""
|
||||
)}
|
||||
</TertiaryA>
|
||||
</div>
|
||||
<DeploySection {...loaderData} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploySection({
|
||||
organizationTemplate,
|
||||
developmentApiKey,
|
||||
liveApiKey,
|
||||
workflows,
|
||||
runLocalDocsHTML,
|
||||
}: {
|
||||
organizationTemplate: LoaderData["organizationTemplate"];
|
||||
developmentApiKey?: string;
|
||||
liveApiKey?: string;
|
||||
workflows: LoaderData["workflows"];
|
||||
runLocalDocsHTML: LoaderData["runLocalDocsHTML"];
|
||||
}) {
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
let [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!currentOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (organizationTemplate.status === "READY_TO_DEPLOY") {
|
||||
return (
|
||||
<>
|
||||
<StyledDialog.Dialog
|
||||
onClose={(e) => setIsOpen(false)}
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<StyledDialog.Panel className="mx-auto flex max-w-3xl items-start gap-2 overflow-hidden">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-md bg-slate-800 text-left">
|
||||
<div className="relative flex flex-col items-center justify-between gap-5 overflow-hidden border-b border-slate-850/80 bg-blue-400 px-4 py-12">
|
||||
<CloudIcon className="absolute top-2 -left-4 h-28 w-28 text-white/50" />
|
||||
<HomeIcon className="absolute bottom-0 right-[calc(50%-2rem)] h-16 w-16 text-stone-900" />
|
||||
<CloudIcon className="absolute top-4 right-6 h-16 w-16 text-white/50" />
|
||||
<div className="absolute -bottom-[150px] h-40 w-[20rem] rounded-full bg-green-700"></div>
|
||||
<Header3 className="mb-6 font-semibold">
|
||||
Run your repository locally
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="flex rounded bg-slate-900/75 p-4">
|
||||
<div
|
||||
className="prose prose-invert [&>pre]:bg-[rgb(17,23,41)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: runLocalDocsHTML,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="sticky top-0 text-slate-600 transition hover:text-slate-500"
|
||||
>
|
||||
<XCircleIcon className="h-10 w-10" />
|
||||
</button>
|
||||
</StyledDialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog.Dialog>
|
||||
<Panel className="max-w-4xl !p-4">
|
||||
<div className="grid grid-cols-[minmax(0,_1fr)_4rem_minmax(0,_1fr)]">
|
||||
<div className="">
|
||||
<SubTitle className="mb-3 flex items-center">
|
||||
Run locally
|
||||
</SubTitle>
|
||||
<Label className="text-sm text-slate-500">
|
||||
Development API key
|
||||
</Label>
|
||||
<div className="flex items-center justify-between rounded bg-black/20 py-2.5 px-3 text-slate-300">
|
||||
<span className="select-all truncate text-slate-300">
|
||||
{developmentApiKey ?? "missing api key"}
|
||||
</span>
|
||||
<CopyTextButton
|
||||
variant="text"
|
||||
value={developmentApiKey ?? "missing api key"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full justify-end">
|
||||
<PrimaryButton onClick={(e) => setIsOpen(true)}>
|
||||
<HomeIcon className="h-5 w-5 text-slate-200" />
|
||||
Run locally
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="h-full border-l border-slate-700"></div>
|
||||
<Body size="small" className="uppercase text-slate-500">
|
||||
or
|
||||
</Body>
|
||||
<div className="h-full border-l border-slate-700"></div>
|
||||
</div>
|
||||
<div>
|
||||
<SubTitle className="mb-3 flex items-center">
|
||||
Deploy to
|
||||
<a
|
||||
href="https://render.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1.5 underline decoration-slate-500 underline-offset-2 transition hover:text-white"
|
||||
>
|
||||
Render
|
||||
</a>
|
||||
</SubTitle>
|
||||
<Label className="text-sm text-slate-500">Live API key</Label>
|
||||
<div className="flex items-center justify-between overflow-hidden rounded bg-black/20 py-2.5 px-3 text-slate-300">
|
||||
<span className="select-all truncate text-slate-300">
|
||||
{liveApiKey ?? "Missing api key"}
|
||||
</span>
|
||||
<CopyTextButton
|
||||
variant="text"
|
||||
value={liveApiKey ?? "Missing api key"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full items-center justify-end">
|
||||
<a
|
||||
href={`https://render.com/deploy?repo=${organizationTemplate.repositoryUrl}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src="https://render.com/images/deploy-to-render-button.svg"
|
||||
alt="Deploy to Render"
|
||||
className="h-[36px]"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<div className="mt-3 flex w-full max-w-4xl items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="small" className="text-slate-500">
|
||||
Waiting for your workflow to connect…
|
||||
</Body>
|
||||
</div>
|
||||
<TertiaryA href="mailto:help@trigger.dev" className="text-xs">
|
||||
Having issues?
|
||||
</TertiaryA>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="relative rounded-lg bg-slate-850">
|
||||
<div className="absolute -top-1 right-1 -left-1 bottom-1 z-0 h-[calc(100%+8px)] w-[calc(100%+8px)] animate-pulse rounded-md bg-gradient-to-r from-indigo-500 to-pink-500 blur-sm"></div>
|
||||
<WorkflowList
|
||||
className="relative z-50 !mb-0"
|
||||
workflows={workflows}
|
||||
currentOrganizationSlug={currentOrganization.slug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ConfiguringGithubState({
|
||||
githubAccount,
|
||||
isPrivate,
|
||||
repositoryName,
|
||||
}: {
|
||||
githubAccount: string;
|
||||
isPrivate: boolean;
|
||||
repositoryName: string;
|
||||
}) {
|
||||
return (
|
||||
<Panel className="pointer-events-none relative max-w-4xl overflow-hidden !p-4">
|
||||
<div className="absolute top-0 left-0 flex h-full w-full items-center justify-center bg-slate-850/70">
|
||||
<PanelInfo message="This can take up to 30 seconds" className="w-max" />
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor="appAuthorizationId">Select a GitHub account</Label>
|
||||
<Select name="appAuthorizationId" required>
|
||||
<option>{githubAccount}</option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor="name">Choose a name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
spellCheck={false}
|
||||
value={repositoryName}
|
||||
disabled
|
||||
/>
|
||||
</InputGroup>
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-slate-500">Set the repo as private</p>
|
||||
<div className="flex w-full items-center rounded bg-black/20 px-3 py-2.5">
|
||||
<Label
|
||||
htmlFor="private"
|
||||
className="flex h-5 cursor-pointer items-center gap-2 text-sm text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="private"
|
||||
id="private"
|
||||
className="border-3 h-4 w-4 cursor-pointer rounded border-black bg-slate-200 transition hover:bg-slate-300 focus:outline-none"
|
||||
checked={isPrivate}
|
||||
disabled
|
||||
/>
|
||||
Private repo
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton disabled type="submit">
|
||||
Adding Template…
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton and completed states
|
||||
export function ConnectedToGithub({ templateId }: { templateId?: string }) {
|
||||
return (
|
||||
<div className="mt-6 flex max-w-4xl items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
GitHub connected
|
||||
</SubTitle>
|
||||
<TertiaryLink
|
||||
to={`../apps/github${templateId ? `?templateId=${templateId}` : ""}`}
|
||||
>
|
||||
Add another connection
|
||||
</TertiaryLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubConfigured({
|
||||
githubAccount,
|
||||
isPrivate,
|
||||
repositoryName,
|
||||
}: {
|
||||
githubAccount: string;
|
||||
isPrivate: boolean;
|
||||
repositoryName: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
GitHub repository created
|
||||
</SubTitle>
|
||||
<Panel className="pointer-events-none relative max-w-4xl overflow-hidden !p-4">
|
||||
<div className="absolute top-0 left-0 flex h-full w-full flex-col items-center justify-center gap-4 bg-slate-850/40"></div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor="appAuthorizationId">GitHub account</Label>
|
||||
<Select name="appAuthorizationId" required>
|
||||
<option>{githubAccount}</option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor="name">Repo name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
spellCheck={false}
|
||||
value={repositoryName}
|
||||
disabled
|
||||
/>
|
||||
</InputGroup>
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-slate-500">Repo</p>
|
||||
<div className="flex w-full items-center rounded bg-black/20 px-3 py-2.5">
|
||||
<Label
|
||||
htmlFor="private"
|
||||
className="flex h-5 cursor-pointer items-center gap-2 text-sm text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="private"
|
||||
id="private"
|
||||
className="border-3 h-4 w-4 cursor-pointer rounded border-black bg-slate-200 transition hover:bg-slate-300 focus:outline-none"
|
||||
checked={isPrivate}
|
||||
disabled
|
||||
/>
|
||||
Private repo
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeployBlankState() {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber stepNumber="3" />
|
||||
Run your workflow
|
||||
</SubTitle>
|
||||
<Panel className="flex h-56 w-full max-w-4xl items-center justify-center gap-6">
|
||||
<FolderIcon className="h-10 w-10 text-slate-600" />
|
||||
<div className="h-[1px] w-16 border border-dashed border-slate-600"></div>
|
||||
<CloudIcon className="h-10 w-10 text-slate-600" />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { Tab } from "@headlessui/react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import classNames from "classnames";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import CodeBlock from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { InstallPackages } from "~/components/CreateNewWorkflow";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import {
|
||||
PrimaryButton,
|
||||
PrimaryLink,
|
||||
TertiaryA,
|
||||
} from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
LargeBox,
|
||||
LargeBoxList,
|
||||
Underlined,
|
||||
UnderlinedList,
|
||||
} from "~/components/primitives/Tabs";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header4 } from "~/components/primitives/text/Headers";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import {
|
||||
exampleProjects,
|
||||
fromScratchProjects,
|
||||
} from "~/components/samples/samplesList";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { getOrganizationFromSlug } from "~/models/organization.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const urlSearchParamsSchema = z.object({
|
||||
date: z.coerce.number().transform((value) => new Date(value)),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
await requireUserId(request);
|
||||
|
||||
//add the date to the url so we can tell if a workflow is new
|
||||
const url = new URL(request.url);
|
||||
const searchObject = Object.fromEntries(url.searchParams ?? {});
|
||||
const result = urlSearchParamsSchema.safeParse(searchObject);
|
||||
if (!result.success) {
|
||||
url.searchParams.set("date", new Date().getTime().toString());
|
||||
throw redirect(url.toString());
|
||||
}
|
||||
|
||||
return typedjson({});
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
invariant(organizationSlug, "organizationSlug is required");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const result = urlSearchParamsSchema.safeParse(
|
||||
Object.fromEntries(url.searchParams)
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("workflows.new action: Invalid date");
|
||||
return typedjson({ hasNewWorkflows: false, newWorkflow: undefined });
|
||||
}
|
||||
|
||||
const organization = await getOrganizationFromSlug({
|
||||
slug: organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
const newWorkflow = organization?.workflows.find((workflow) => {
|
||||
return workflow.createdAt > result.data.date;
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
hasNewWorkflows: newWorkflow ? true : false,
|
||||
newWorkflow,
|
||||
});
|
||||
};
|
||||
|
||||
const maxWidth = "flex max-w-4xl";
|
||||
const subTitle = "text-slate-200 font-semibold mb-3";
|
||||
const carousel = "-ml-[26px] overflow-hidden overflow-x-auto pl-[1.5rem]";
|
||||
|
||||
export default function NewWorkflowPage() {
|
||||
const environment = useCurrentEnvironment();
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
invariant(currentOrganization, "Organization must be defined");
|
||||
invariant(environment, "Environment must be defined");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>Create a new workflow</Title>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="1" drawLine />
|
||||
<div className="mb-6 w-full">
|
||||
<Header4 size="regular" className={subTitle}>
|
||||
Install the Trigger.dev package
|
||||
</Header4>
|
||||
<InstallPackages packages={"@trigger.dev/sdk"} />
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="2" drawLine />
|
||||
<div className="mb-6 w-full pr-10">
|
||||
<Header4 size="regular" className={classNames(subTitle)}>
|
||||
Create your workflow
|
||||
</Header4>
|
||||
<UnderlinedList>
|
||||
<Underlined>Start from an example</Underlined>
|
||||
<Underlined>Start from scratch</Underlined>
|
||||
</UnderlinedList>
|
||||
<Tab.Panels className="flex-grow pt-4">
|
||||
<Tab.Panel className="relative h-full">
|
||||
{/* Example projects tabs */}
|
||||
<Tab.Group>
|
||||
<div
|
||||
className={classNames(
|
||||
carousel,
|
||||
"border-r border-slate-700"
|
||||
)}
|
||||
>
|
||||
<LargeBoxList>
|
||||
{exampleProjects.map((project) => {
|
||||
return (
|
||||
<LargeBox key={project.name}>
|
||||
{project.icon}
|
||||
<Body>{project.name}</Body>
|
||||
</LargeBox>
|
||||
);
|
||||
})}
|
||||
</LargeBoxList>
|
||||
</div>
|
||||
{/* Example projects content */}
|
||||
<Tab.Panels className={classNames("flex-grow pt-4")}>
|
||||
{exampleProjects.map((project) => {
|
||||
return (
|
||||
<Tab.Panel
|
||||
key={project.name}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div className="">
|
||||
<div className="mb-4 mt-4 flex items-center gap-2">
|
||||
{project.icon}
|
||||
<Header4
|
||||
size="small"
|
||||
className="font-semibold text-slate-300"
|
||||
>
|
||||
{project.title}
|
||||
</Header4>
|
||||
</div>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-4 text-slate-400"
|
||||
>
|
||||
{project.description}
|
||||
</Body>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 text-slate-400"
|
||||
>
|
||||
Install these additional API integration packages:
|
||||
</Body>
|
||||
<InstallPackages
|
||||
packages={project.requiredPackages}
|
||||
/>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 mt-4 text-slate-400"
|
||||
>
|
||||
Copy this example code into your project. Your API
|
||||
key has already been inserted.
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={project.code(environment.apiKey)}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="relative h-full">
|
||||
<Tab.Group>
|
||||
{/* From scratch projects titles */}
|
||||
<div className={classNames(carousel)}>
|
||||
<LargeBoxList>
|
||||
{fromScratchProjects.map((project) => {
|
||||
return (
|
||||
<LargeBox key={project.name}>{project.name}</LargeBox>
|
||||
);
|
||||
})}
|
||||
</LargeBoxList>
|
||||
</div>
|
||||
{/* From scratch projects content */}
|
||||
<Tab.Panels className={classNames("flex-grow pt-4")}>
|
||||
{fromScratchProjects.map((project) => {
|
||||
return (
|
||||
<Tab.Panel
|
||||
key={project.name}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div className="">
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-4 text-slate-400"
|
||||
>
|
||||
{project.description}
|
||||
</Body>
|
||||
<ul className="ml-[17px] list-disc text-slate-400 marker:text-indigo-400">
|
||||
{project.bulletPoint1 ? (
|
||||
<li>{project.bulletPoint1}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{project.bulletPoint2 ? (
|
||||
<li>{project.bulletPoint2}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{project.bulletPoint3 ? (
|
||||
<li>{project.bulletPoint3}</li>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</ul>
|
||||
<Body
|
||||
size="regular"
|
||||
className="mb-2 mt-4 text-slate-400"
|
||||
>
|
||||
Use this example code in your project to get
|
||||
started. Or learn more about {project.name}s in
|
||||
the{" "}
|
||||
<TertiaryA
|
||||
href={project.docsLink}
|
||||
target={"_blank"}
|
||||
className="!text-base text-slate-400 underline decoration-green-500 underline-offset-2 hover:text-white hover:decoration-green-400"
|
||||
>
|
||||
docs
|
||||
</TertiaryA>
|
||||
.
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={project.code(environment.apiKey)}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Group>
|
||||
<div className={classNames(maxWidth)}>
|
||||
<StepNumber stepNumber="3" />
|
||||
<div className="w-full">
|
||||
<Header4 size="regular" className={subTitle}>
|
||||
Run your web server
|
||||
</Header4>
|
||||
<Body size="regular" className="mb-4 text-slate-400">
|
||||
Run your server as you typically do, e.g.{" "}
|
||||
<InlineCode>npm run dev</InlineCode>. This will connect your
|
||||
workflow to Trigger.dev, so we can start sending you events. You
|
||||
should see some log messages in your server console (tip: you can
|
||||
turn these off by removing the{" "}
|
||||
<InlineCode>logLevel: "info"</InlineCode> from the code above).
|
||||
</Body>
|
||||
<CheckForWorkflows />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function StepNumber({
|
||||
stepNumber,
|
||||
drawLine,
|
||||
}: {
|
||||
stepNumber: string;
|
||||
drawLine?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mr-3 flex flex-col items-center justify-center">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded border border-slate-700 bg-slate-800 py-1 text-sm text-green-400 shadow">
|
||||
{stepNumber}
|
||||
</span>
|
||||
{drawLine ? (
|
||||
<div className="h-full border-l border-slate-700"></div>
|
||||
) : (
|
||||
<div className="h-full"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckForWorkflows() {
|
||||
const fetchWorkflowCount = useTypedFetcher<typeof action>();
|
||||
|
||||
if (fetchWorkflowCount.state !== "idle") {
|
||||
return (
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect...
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton>Connecting…</PrimaryButton>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchWorkflowCount.data === undefined) {
|
||||
return (
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect…
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</Panel>
|
||||
</fetchWorkflowCount.Form>
|
||||
);
|
||||
} else {
|
||||
if (fetchWorkflowCount.data.hasNewWorkflows) {
|
||||
return (
|
||||
<div>
|
||||
<Panel>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400" />
|
||||
<Body size="regular" className="font-semibold text-slate-300">
|
||||
Great, "{fetchWorkflowCount.data.newWorkflow?.title}" is
|
||||
connected!
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryLink
|
||||
to={`../workflows/${fetchWorkflowCount.data.newWorkflow?.slug}`}
|
||||
>
|
||||
View workflow
|
||||
</PrimaryLink>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Panel>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-amber-400" />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
It doesn't seem like your workflow has connected yet. Check your
|
||||
server is running and try again.
|
||||
</Body>
|
||||
</div>
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</fetchWorkflowCount.Form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
|
||||
export default function NewWorkflowPage() {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Create a new workflow</Title>
|
||||
<Outlet />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
|
||||
import classNames from "classnames";
|
||||
import type { Dispatch, Reducer } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import CodeBlock from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { InstallPackages } from "~/components/CreateNewWorkflow";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { BackToStep1, BackToStep2 } from "~/components/onboarding/BackToSteps";
|
||||
import { onboarding } from "~/components/onboarding/classNames";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import {
|
||||
PrimaryButton,
|
||||
PrimaryLink,
|
||||
TertiaryButton,
|
||||
} from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import type { ExampleProject } from "~/components/samples/samplesList";
|
||||
import {
|
||||
ExampleOverview,
|
||||
FromScratchOverview,
|
||||
} from "~/components/templates/ExampleOverview";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
import { getIntegrationMetadatas } from "~/models/integrations.server";
|
||||
import { getWorkflowsCreatedSinceDate } from "~/models/organization.server";
|
||||
import {
|
||||
commitOnboardingSession,
|
||||
getWorkflowDate,
|
||||
setWorkflowDate,
|
||||
} from "~/services/onboardingSession.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const providers = getIntegrationMetadatas(false);
|
||||
|
||||
const onboardingSession = await setWorkflowDate(new Date(), request);
|
||||
|
||||
return typedjson(
|
||||
{ providers },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitOnboardingSession(onboardingSession),
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
invariant(organizationSlug, "organizationSlug is required");
|
||||
|
||||
const workflowDate = await getWorkflowDate(request);
|
||||
|
||||
if (!workflowDate) {
|
||||
console.error("workflows.new action: Invalid date");
|
||||
return typedjson({ hasNewWorkflows: false, newWorkflow: undefined });
|
||||
}
|
||||
|
||||
const workflows = await getWorkflowsCreatedSinceDate(
|
||||
userId,
|
||||
organizationSlug,
|
||||
workflowDate
|
||||
);
|
||||
|
||||
return typedjson({
|
||||
hasNewWorkflows: workflows.length > 0,
|
||||
newWorkflow: workflows[0],
|
||||
});
|
||||
};
|
||||
|
||||
type ExistingRepoState =
|
||||
| { step: "choose-example"; selectedProject: null }
|
||||
| {
|
||||
step: "install-packages";
|
||||
selectedProject: ExampleProject;
|
||||
}
|
||||
| { step: "copy-example-code"; selectedProject: ExampleProject }
|
||||
| { step: "import-code"; selectedProject: ExampleProject }
|
||||
| {
|
||||
step: "run-code";
|
||||
selectedProject: ExampleProject;
|
||||
}
|
||||
| { step: "done"; selectedProject: ExampleProject };
|
||||
|
||||
type ExistingRepoAction =
|
||||
| { type: "clear-choice" }
|
||||
| { type: "example-chosen"; payload: ExampleProject }
|
||||
| { type: "packages-installed" }
|
||||
| { type: "code-copied" }
|
||||
| { type: "code-imported" }
|
||||
| { type: "code-run" };
|
||||
|
||||
const reducer = (
|
||||
state: ExistingRepoState,
|
||||
action: ExistingRepoAction
|
||||
): ExistingRepoState => {
|
||||
switch (action.type) {
|
||||
case "clear-choice":
|
||||
return {
|
||||
...state,
|
||||
selectedProject: null,
|
||||
step: "choose-example",
|
||||
};
|
||||
case "example-chosen":
|
||||
return {
|
||||
selectedProject: action.payload,
|
||||
step: "install-packages",
|
||||
};
|
||||
case "packages-installed":
|
||||
if (state.step === "choose-example") {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
step: "copy-example-code",
|
||||
};
|
||||
case "code-copied":
|
||||
if (state.step === "choose-example") {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
step: "import-code",
|
||||
};
|
||||
case "code-imported":
|
||||
if (state.step === "choose-example") {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
step: "run-code",
|
||||
};
|
||||
case "code-run":
|
||||
if (state.step === "choose-example") {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
step: "done",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default function Step3ExistingRepo1() {
|
||||
const environment = useCurrentEnvironment();
|
||||
invariant(environment, "Environment must be defined");
|
||||
|
||||
const [state, dispatch] = useReducer<
|
||||
Reducer<ExistingRepoState, ExistingRepoAction>
|
||||
>(reducer, {
|
||||
step: "choose-example",
|
||||
selectedProject: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("flex flex-col", onboarding.maxWidth)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<BackToStep1 />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<BackToStep2 text="I'll use an existing repo" />
|
||||
</div>
|
||||
{state.step === "choose-example" ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="3" />
|
||||
Choose an example
|
||||
</SubTitle>
|
||||
<Panel className="px-4 py-4">
|
||||
<SubTitle>Browse examples to use as a starting point</SubTitle>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<ExampleOverview
|
||||
onSelectedProject={(project) =>
|
||||
dispatch({ payload: project, type: "example-chosen" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SubTitle className="mt-6">Or start from scratch</SubTitle>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<FromScratchOverview
|
||||
onSelectedProject={(project) =>
|
||||
dispatch({ payload: project, type: "example-chosen" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
) : state.step === "install-packages" ? (
|
||||
<>
|
||||
<ChosenExample
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<div>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="4" />
|
||||
Install the Trigger.dev packages for this example
|
||||
</SubTitle>
|
||||
</div>
|
||||
<Panel className="px-4 py-4">
|
||||
<InstallPackages
|
||||
packages={state.selectedProject.requiredPackages}
|
||||
/>
|
||||
<div className="flex w-full justify-end">
|
||||
<PrimaryButton
|
||||
className="mt-2"
|
||||
onClick={() => dispatch({ type: "packages-installed" })}
|
||||
>
|
||||
Continue
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : state.step === "copy-example-code" ? (
|
||||
<>
|
||||
<ChosenExample
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<InstalledPackages
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="5" />
|
||||
Copy the example code into your project
|
||||
</SubTitle>
|
||||
<Panel className="px-4 py-4">
|
||||
<Body size="regular" className="mb-2 text-slate-400">
|
||||
Your API key has already been inserted.
|
||||
</Body>
|
||||
<CodeBlock
|
||||
code={state.selectedProject.code(environment.apiKey)}
|
||||
language="tsx"
|
||||
align="top"
|
||||
maxHeight="600px"
|
||||
/>
|
||||
<div className="flex w-full justify-end">
|
||||
<PrimaryButton
|
||||
className="mt-2"
|
||||
onClick={() => dispatch({ type: "code-copied" })}
|
||||
>
|
||||
Continue
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : state.step === "import-code" ? (
|
||||
<>
|
||||
<>
|
||||
<ChosenExample
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<InstalledPackages
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<AddedCode dispatch={dispatch} />
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="6" />
|
||||
Import the example code to make sure it's included
|
||||
</SubTitle>
|
||||
<Panel className="px-4 py-4">
|
||||
<Body size="regular" className="mb-2 text-slate-400">
|
||||
If you've put the code in a standalone file then you'll need
|
||||
to import it into a file that is being run on your server
|
||||
(e.g.
|
||||
<InlineCode>src/index.ts</InlineCode>
|
||||
).
|
||||
</Body>
|
||||
<Body size="regular" className="text-slate-400">
|
||||
You can do that by importing your workflow file into a file
|
||||
being run by your server, like this:
|
||||
</Body>
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
code={`import "./path/to/your/workflow/file";`}
|
||||
align="top"
|
||||
/>
|
||||
<div className="flex w-full justify-end">
|
||||
<PrimaryButton
|
||||
className="mt-2"
|
||||
onClick={() => dispatch({ type: "code-imported" })}
|
||||
>
|
||||
Continue
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
</>
|
||||
) : state.step === "run-code" ? (
|
||||
<>
|
||||
<ChosenExample
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<InstalledPackages
|
||||
project={state.selectedProject}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<AddedCode dispatch={dispatch} />
|
||||
<CodeImported dispatch={dispatch} />
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="7" />
|
||||
Lastly, run your web server
|
||||
</SubTitle>
|
||||
<Panel className="px-4 py-4">
|
||||
<Body size="regular" className="mb-4 text-slate-400">
|
||||
Run your server as you typically do, e.g.{" "}
|
||||
<InlineCode>npm run dev</InlineCode>. This will connect your
|
||||
workflow to Trigger.dev, so we can start sending you events. You
|
||||
should see some log messages in your server console.
|
||||
</Body>
|
||||
|
||||
<CheckForWorkflows />
|
||||
</Panel>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChosenExample({
|
||||
project,
|
||||
dispatch,
|
||||
}: {
|
||||
project: ExampleProject;
|
||||
dispatch: Dispatch<ExistingRepoAction>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<button
|
||||
className="transition hover:text-slate-300"
|
||||
onClick={() => dispatch({ type: "clear-choice" })}
|
||||
>
|
||||
I've chosen the example: {project.name}
|
||||
</button>
|
||||
</SubTitle>
|
||||
<TertiaryButton onClick={() => dispatch({ type: "clear-choice" })}>
|
||||
Change answer
|
||||
</TertiaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPackages({
|
||||
dispatch,
|
||||
project,
|
||||
}: {
|
||||
dispatch: Dispatch<ExistingRepoAction>;
|
||||
project: ExampleProject;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<button
|
||||
className="transition hover:text-slate-300"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "example-chosen",
|
||||
payload: project,
|
||||
})
|
||||
}
|
||||
>
|
||||
I've installed the packages
|
||||
</button>
|
||||
</SubTitle>
|
||||
<TertiaryButton
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "example-chosen",
|
||||
payload: project,
|
||||
})
|
||||
}
|
||||
>
|
||||
Change answer
|
||||
</TertiaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeImported({
|
||||
dispatch,
|
||||
}: {
|
||||
dispatch: Dispatch<ExistingRepoAction>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<button
|
||||
className="transition hover:text-slate-300"
|
||||
onClick={() => dispatch({ type: "code-copied" })}
|
||||
>
|
||||
I've imported the code into my project
|
||||
</button>
|
||||
</SubTitle>
|
||||
<TertiaryButton onClick={() => dispatch({ type: "code-copied" })}>
|
||||
Change answer
|
||||
</TertiaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddedCode({ dispatch }: { dispatch: Dispatch<ExistingRepoAction> }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<button
|
||||
className="transition hover:text-slate-300"
|
||||
onClick={() => dispatch({ type: "packages-installed" })}
|
||||
>
|
||||
I've added the example code to my project
|
||||
</button>
|
||||
</SubTitle>
|
||||
<TertiaryButton onClick={() => dispatch({ type: "packages-installed" })}>
|
||||
Change answer
|
||||
</TertiaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckForWorkflows() {
|
||||
const fetchWorkflowCount = useTypedFetcher<typeof action>();
|
||||
|
||||
if (fetchWorkflowCount.state !== "idle") {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded bg-slate-850 p-3 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect...
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton>Connecting…</PrimaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchWorkflowCount.data === undefined) {
|
||||
return (
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<div className="flex items-center justify-between rounded bg-slate-850 p-3 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
Waiting for your workflow to connect…
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</fetchWorkflowCount.Form>
|
||||
);
|
||||
} else {
|
||||
if (fetchWorkflowCount.data.hasNewWorkflows) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between rounded bg-slate-850 p-3 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-400" />
|
||||
<Body size="regular" className="text-slate-300">
|
||||
"{fetchWorkflowCount.data.newWorkflow?.title}" has connected!
|
||||
</Body>
|
||||
</div>
|
||||
<PrimaryLink
|
||||
to={`../../workflows/${fetchWorkflowCount.data.newWorkflow?.slug}`}
|
||||
>
|
||||
View workflow
|
||||
</PrimaryLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded bg-slate-850 p-3 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-amber-400" />
|
||||
<div>
|
||||
<Body size="regular" className="text-slate-300">
|
||||
It doesn't seem like your workflow has connected yet.
|
||||
</Body>
|
||||
<Body>Check your server is running and try again.</Body>
|
||||
</div>
|
||||
</div>
|
||||
<fetchWorkflowCount.Form method="post">
|
||||
<PrimaryButton type="submit">
|
||||
Check my workflow connection
|
||||
</PrimaryButton>
|
||||
</fetchWorkflowCount.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import CheckIcon from "@heroicons/react/20/solid/CheckIcon";
|
||||
import {
|
||||
CloudIcon,
|
||||
HomeIcon,
|
||||
RocketLaunchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import XCircleIcon from "@heroicons/react/24/solid/XCircleIcon";
|
||||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import classNames from "classnames";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { onboarding } from "~/components/onboarding/classNames";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import { PrimaryButton } from "~/components/primitives/Buttons";
|
||||
import { StyledDialog } from "~/components/primitives/Dialog";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header3 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
|
||||
export default function NewWorkflowStep1Page() {
|
||||
return <Step1 />;
|
||||
}
|
||||
|
||||
function Step1() {
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
let [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "submitting") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [fetcher.state, setIsOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledDialog.Dialog
|
||||
onClose={(e) => setIsOpen(false)}
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<StyledDialog.Panel className="mx-auto flex max-w-xl items-start gap-2 overflow-hidden">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-md bg-slate-800 text-left">
|
||||
<div className="relative flex flex-col items-center justify-between gap-5 overflow-hidden border-b border-slate-850/80 bg-blue-400 px-4 py-12">
|
||||
<CloudIcon className="absolute top-2 -left-4 h-28 w-28 animate-pulse text-white/70" />
|
||||
<CloudIcon className="absolute top-16 right-16 h-16 w-16 animate-pulse text-white/70" />
|
||||
<RocketLaunchIcon className="h-20 w-20 animate-[float_3s_ease-in-out_infinite] text-slate-800" />
|
||||
<Header3 className="font-semibold">
|
||||
Cloud hosting coming soon…
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<Body className="mb-4 text-slate-400">
|
||||
We're preparing to launch a cloud hosting service for your
|
||||
Trigger.dev workflows that will make it as easy to deploy
|
||||
your workflows as a git push.
|
||||
</Body>
|
||||
<div className="flex w-full justify-end">
|
||||
<fetcher.Form
|
||||
action="/resources/cloud-waitlist"
|
||||
method="post"
|
||||
>
|
||||
{user.isOnCloudWaitlist ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="mt-2 w-full"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon className="-m-1 h-4 w-4 text-green-500" />
|
||||
Already on the waitlist
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton type="submit" className="mt-2 w-full">
|
||||
Notify me when it's ready
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="sticky top-0 text-slate-300 transition hover:text-slate-200"
|
||||
>
|
||||
<XCircleIcon className="h-10 w-10" />
|
||||
</button>
|
||||
</StyledDialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog.Dialog>
|
||||
<div className={classNames(onboarding.maxWidth, "mb-6")}>
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="1" />
|
||||
Where do you want your workflow hosted?
|
||||
</SubTitle>
|
||||
<Panel className="flex w-full items-center justify-between">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<Link to="step2" className={onboarding.buttonStyles}>
|
||||
<HomeIcon className="h-10 w-10 text-green-400" />
|
||||
<Header3>I'll host the workflow myself</Header3>
|
||||
<Body size="small" className="text-slate-400">
|
||||
I will deploy the code to my own servers.
|
||||
</Body>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => setIsOpen(true)}
|
||||
className={onboarding.buttonStyles}
|
||||
>
|
||||
<CloudIcon className="h-10 w-10 text-blue-400" />
|
||||
<Header3>Host the workflow for me in the cloud</Header3>
|
||||
<Body size="small" className="text-slate-400">
|
||||
Trigger.dev can host and handle the servers for me.
|
||||
</Body>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import classNames from "classnames";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BackToStep1, BackToStep2 } from "~/components/onboarding/BackToSteps";
|
||||
import { onboarding } from "~/components/onboarding/classNames";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { TemplatesGrid } from "~/components/templates/TemplatesGrid";
|
||||
import { TemplateListPresenter } from "~/presenters/templateListPresenter.server";
|
||||
|
||||
export const loader = async () => {
|
||||
const presenter = new TemplateListPresenter();
|
||||
|
||||
return typedjson(await presenter.data());
|
||||
};
|
||||
|
||||
export default function Step3NewRepo1() {
|
||||
const { templates } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className={classNames(onboarding.maxWidth)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<BackToStep1 />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<BackToStep2 text="I'll start with a template" />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="3" />
|
||||
Which template would you like to use?
|
||||
</SubTitle>
|
||||
<TemplatesGrid templates={templates} openInNewPage={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CubeIcon, CubeTransparentIcon } from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import classNames from "classnames";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { onboarding } from "~/components/onboarding/classNames";
|
||||
import { StepNumber } from "~/components/onboarding/StepNumber";
|
||||
import { TertiaryLink } from "~/components/primitives/Buttons";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header3 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
|
||||
export default function NewWorkflowStep2Page() {
|
||||
return <Step2 />;
|
||||
}
|
||||
|
||||
function Step2() {
|
||||
return (
|
||||
<div className={classNames(onboarding.maxWidth, "flex flex-col")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber complete />
|
||||
<Link to=".." className="transition hover:text-slate-300">
|
||||
I'll host the workflow myself
|
||||
</Link>
|
||||
</SubTitle>
|
||||
<TertiaryLink to="..">Change answer</TertiaryLink>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<SubTitle className="flex items-center">
|
||||
<StepNumber active stepNumber="2" />
|
||||
Would you like to create a new GitHub repository?
|
||||
</SubTitle>
|
||||
<Panel className="flex w-full items-center justify-between">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<Link to="../newRepo" className={onboarding.buttonStyles}>
|
||||
<div
|
||||
className={classNames("bg-green-400", onboarding.labelStyles)}
|
||||
>
|
||||
Easy (2 mins)
|
||||
</div>
|
||||
<CubeTransparentIcon className="h-10 w-10 text-indigo-400" />
|
||||
<Header3>I want to create a new repo</Header3>
|
||||
<Body size="small" className="text-center text-slate-400">
|
||||
We'll setup a new GitHub repository and install your template.
|
||||
</Body>
|
||||
</Link>
|
||||
<Link to="../existingRepo" className={onboarding.buttonStyles}>
|
||||
<CubeIcon className="h-10 w-10 text-orange-400" />
|
||||
<Header3>I want to use an existing repo</Header3>
|
||||
<Body size="small" className="text-slate-400">
|
||||
I want my workflow to be alongside my existing code.
|
||||
</Body>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LoaderArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { StartAppInstallation } from "~/services/github/startAppInstallation.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = ParamsSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const queryParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const service = new StartAppInstallation();
|
||||
|
||||
const redirectTo = await service.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
templateId: queryParams.templateId,
|
||||
});
|
||||
|
||||
return redirect(redirectTo ?? `/orgs/${organizationSlug}`);
|
||||
}
|
||||
+114
-6
@@ -4,6 +4,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import CodeBlock from "~/components/code/CodeBlock";
|
||||
import { CopyTextButton } from "~/components/CopyTextButton";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { WorkflowConnections } from "~/components/integrations/WorkflowConnections";
|
||||
import { Panel } from "~/components/layout/Panel";
|
||||
import { PanelHeader } from "~/components/layout/PanelHeader";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "~/components/primitives/Buttons";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Body } from "~/components/primitives/text/Body";
|
||||
import { Header2 } from "~/components/primitives/text/Headers";
|
||||
import { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||
import { Title } from "~/components/primitives/text/Title";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
@@ -26,6 +28,7 @@ import { useConnectionSlots } from "~/hooks/useConnectionSlots";
|
||||
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentWorkflow } from "~/hooks/useWorkflows";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||
import { WorkflowRunListPresenter } from "~/models/workflowRunListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
@@ -77,16 +80,49 @@ export default function Page() {
|
||||
const apiConnectionCount =
|
||||
connectionSlots.services.length + (connectionSlots.source ? 1 : 0);
|
||||
|
||||
//if the workflow isn't connected in this environment, show a warning and help message
|
||||
if (!eventRule) {
|
||||
return (
|
||||
<>
|
||||
<Title>Overview</Title>
|
||||
<PanelWarning
|
||||
className="mb-6"
|
||||
message={`This workflow hasn't been connected in the ${environment.slug} environment yet.`}
|
||||
></PanelWarning>
|
||||
{environment.slug === "development" ? (
|
||||
<ConnectToDevelopmentInstructions environment={environment} />
|
||||
) : (
|
||||
<ConnectToLiveInstructions environment={environment} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Title>Overview</Title>
|
||||
<Body className="text-slate-400">
|
||||
<span className="mr-1.5 text-xs tracking-wide text-slate-500">
|
||||
ID
|
||||
</span>
|
||||
{workflow.slug}
|
||||
</Body>
|
||||
<div className="flex items-center gap-4">
|
||||
{workflow.organizationTemplate && (
|
||||
<a
|
||||
href={workflow.organizationTemplate.repositoryUrl}
|
||||
className="flex items-center gap-1 text-sm text-slate-400"
|
||||
target="_blank"
|
||||
>
|
||||
<OctoKitty className="mr-0.5 h-4 w-4" />
|
||||
{workflow.organizationTemplate.repositoryUrl.replace(
|
||||
"https://github.com/",
|
||||
""
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
<Body size="small" className="text-slate-400">
|
||||
<span className="mr-1.5 text-xs tracking-wide text-slate-500">
|
||||
ID
|
||||
</span>
|
||||
{workflow.slug}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
{workflow.status === "CREATED" && (
|
||||
<>
|
||||
@@ -244,3 +280,75 @@ export default function Page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectToLiveInstructions({
|
||||
environment,
|
||||
}: {
|
||||
environment: RuntimeEnvironment;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header2>Deploying your workflow to Live</Header2>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Body>
|
||||
Deploying your code to a server is different for each hosting
|
||||
provider. We have a quick start guide for{" "}
|
||||
<a
|
||||
href="https://docs.trigger.dev/quickstarts/render"
|
||||
className="underline"
|
||||
>
|
||||
how to do this with Render
|
||||
</a>
|
||||
, but you can use any hosting provider.
|
||||
</Body>
|
||||
|
||||
<Body>
|
||||
When you fill in the environment variables for your server(s) use the
|
||||
following settings:
|
||||
</Body>
|
||||
<div className="flex w-full items-stretch justify-items-stretch gap-2">
|
||||
<div className="flex-grow">
|
||||
<Body className="font-bold">Key</Body>
|
||||
<CodeBlock
|
||||
code="TRIGGER_API_KEY"
|
||||
showLineNumbers={false}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Body className="font-bold">Value</Body>
|
||||
<CodeBlock
|
||||
code={environment.apiKey}
|
||||
showLineNumbers={false}
|
||||
align="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectToDevelopmentInstructions({
|
||||
environment,
|
||||
}: {
|
||||
environment: RuntimeEnvironment;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header2>Running your workflow locally</Header2>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Body>
|
||||
Follow our{" "}
|
||||
<a
|
||||
href="https://docs.trigger.dev/getting-started"
|
||||
className="underline"
|
||||
>
|
||||
quick start guide
|
||||
</a>{" "}
|
||||
for running your workflow locally.
|
||||
</Body>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -783,7 +783,7 @@ function IntegrationRequestStep({
|
||||
<div className="mb-2 mt-3 flex gap-2 ">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500" />
|
||||
<Body size="small" className="text-rose-500">
|
||||
Failed with error:
|
||||
{request.service.integration.name} responded with error:
|
||||
</Body>
|
||||
</div>
|
||||
<CodeBlock
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import {
|
||||
AppBody,
|
||||
AppLayout,
|
||||
PublicAppLayout,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Footer } from "~/components/layout/Footer";
|
||||
import { Header } from "~/components/layout/Header";
|
||||
import { MarketingHeader } from "~/components/layout/MarketingHeader";
|
||||
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
|
||||
import { getOrganizations } from "~/models/organization.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const userId = await getUserId(request);
|
||||
|
||||
if (!userId) {
|
||||
return typedjson({
|
||||
userId: undefined,
|
||||
organizations: [],
|
||||
impersonationId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const organizations = await getOrganizations({ userId });
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
|
||||
return typedjson({
|
||||
userId,
|
||||
organizations,
|
||||
impersonationId,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Public() {
|
||||
const loaderData = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const LayoutComponent = loaderData.userId ? AppLayout : PublicAppLayout;
|
||||
|
||||
return (
|
||||
<LayoutComponent>
|
||||
{loaderData.userId ? <Header /> : <MarketingHeader />}
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<Footer />
|
||||
</LayoutComponent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { Container } from "~/components/layout/Container";
|
||||
|
||||
export default function TemplatesLayout() {
|
||||
return (
|
||||
<Container>
|
||||
<Outlet />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { TemplateOverview } from "~/components/templates/TemplateOverview";
|
||||
import { TemplatePresenter } from "~/presenters/templatePresenter.server";
|
||||
|
||||
export async function loader({ params }: LoaderArgs) {
|
||||
const { slug } = params;
|
||||
invariant(typeof slug === "string", "Slug must be a string");
|
||||
|
||||
const presenter = new TemplatePresenter();
|
||||
|
||||
return typedjson(await presenter.data({ slug }));
|
||||
}
|
||||
|
||||
export default function TemplateSlugPage() {
|
||||
const { template } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
if (!template) {
|
||||
return <div>Template not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1188px]">
|
||||
<Link
|
||||
to="/templates"
|
||||
className="mb-4 -ml-1 flex w-max items-center justify-start gap-2 text-sm text-slate-500 transition hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
Choose a different Template
|
||||
</Link>
|
||||
<TemplateOverview template={template} className="-ml-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LoaderArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectWithErrorMessage,
|
||||
} from "~/models/message.server";
|
||||
import { getCurrentOrg } from "~/services/currentOrganization.server";
|
||||
import {
|
||||
commitCurrentTemplateSession,
|
||||
setCurrentTemplate,
|
||||
} from "~/services/currentTemplate.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const templateId = url.searchParams.get("templateId");
|
||||
|
||||
if (!templateId) {
|
||||
return redirectWithErrorMessage(
|
||||
"/templates",
|
||||
request,
|
||||
"No template ID provided"
|
||||
);
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
|
||||
if (userId) {
|
||||
const currentOrg = await getCurrentOrg(request);
|
||||
|
||||
if (!currentOrg) {
|
||||
const firstOrg = await prisma.organization.findFirst({
|
||||
where: {
|
||||
users: {
|
||||
some: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!firstOrg) {
|
||||
return redirectWithErrorMessage(
|
||||
"/templates",
|
||||
request,
|
||||
"Could not find an organization for this user"
|
||||
);
|
||||
}
|
||||
|
||||
return redirect(
|
||||
`/orgs/${firstOrg.slug}/templates/add?templateId=${templateId}`
|
||||
);
|
||||
}
|
||||
|
||||
return redirect(
|
||||
`/orgs/${currentOrg}/templates/add?templateId=${templateId}`
|
||||
);
|
||||
}
|
||||
|
||||
const session = await setCurrentTemplate(templateId, request);
|
||||
|
||||
const searchParams = new URLSearchParams([
|
||||
["redirectTo", `${url.pathname}${url.search}`],
|
||||
]);
|
||||
|
||||
return redirect(`/login?${searchParams}`, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentTemplateSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { Header2 } from "~/components/primitives/text/Headers";
|
||||
import { TemplatesGrid } from "~/components/templates/TemplatesGrid";
|
||||
import { TemplateListPresenter } from "~/presenters/templateListPresenter.server";
|
||||
|
||||
export const loader = async () => {
|
||||
const presenter = new TemplateListPresenter();
|
||||
|
||||
return typedjson(await presenter.data());
|
||||
};
|
||||
|
||||
export default function TemplateList() {
|
||||
const { templates } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-4 flex max-w-6xl flex-col lg:mt-6">
|
||||
<h1 className="mb-6 text-center font-title text-5xl font-semibold text-slate-200">
|
||||
Choose your Template
|
||||
</h1>
|
||||
<Header2 size="small" className="mb-8 text-center text-slate-400">
|
||||
Quickly get started with your workflow by using a pre-built example.
|
||||
</Header2>
|
||||
<TemplatesGrid templates={templates} openInNewPage={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { HomeIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { UserCircleIcon } from "@heroicons/react/24/solid";
|
||||
@@ -9,6 +8,9 @@ import { Fragment, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { getUser, requireUserId } from "~/services/session.server";
|
||||
import { UserCircleIcon } from "@heroicons/react/24/solid";
|
||||
import classNames from "classnames";
|
||||
import type { User } from "~/models/user.server";
|
||||
|
||||
const navigation = [{ name: "Home", href: "/admin", icon: HomeIcon }];
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { EmitterWebhookEventName } from "@octokit/webhooks";
|
||||
import { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { webhooks } from "~/services/github/githubApp.server";
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
if (!webhooks) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
|
||||
const payload = await request.text();
|
||||
const headers = Object.fromEntries(request.headers.entries());
|
||||
|
||||
const id = headers["x-github-delivery"];
|
||||
const name = headers["x-github-event"];
|
||||
const signature = headers["x-hub-signature"];
|
||||
|
||||
await webhooks.verifyAndReceive({
|
||||
id,
|
||||
name: name as EmitterWebhookEventName,
|
||||
payload,
|
||||
signature,
|
||||
});
|
||||
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { AppInstallationCallback } from "~/services/github/appInstallationCallback.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const ParamSchema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
installation_id: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
await requireUserId(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const service = new AppInstallationCallback();
|
||||
|
||||
const parsedParams = ParamSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
console.error(
|
||||
`[github.callback] Invalid params`,
|
||||
params,
|
||||
parsedParams.error
|
||||
);
|
||||
throw new Response("Failed to connect to GitHub", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await service.call(parsedParams.data);
|
||||
|
||||
if (result) {
|
||||
const { authorization, templateId } = result;
|
||||
|
||||
return redirect(
|
||||
`/orgs/${authorization.organization.slug}/templates/add${
|
||||
templateId ? `?templateId=${templateId}` : ""
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
return redirect(`/`);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { InboxArrowDownIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, Link, useLoaderData, useTransition } from "@remix-run/react";
|
||||
import { Form, Link, useTransition } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { LoginPromoPanel } from "~/components/LoginPromoPanel";
|
||||
import { LogoSvg } from "~/components/Logo";
|
||||
import { PrimaryButton } from "~/components/primitives/Buttons";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { TemplatePresenter } from "~/presenters/templatePresenter.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { getCurrentTemplate } from "~/services/currentTemplate.server";
|
||||
import {
|
||||
commitSession,
|
||||
getUserSession,
|
||||
} from "~/services/sessionStorage.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { InboxArrowDownIcon } from "@heroicons/react/24/outline";
|
||||
import { z } from "zod";
|
||||
import { LoginPromoPanel } from "~/components/LoginPromoPanel";
|
||||
import { LogoSvg } from "~/components/Logo";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { PrimaryButton } from "~/components/primitives/Buttons";
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
await authenticator.isAuthenticated(request, {
|
||||
@@ -21,7 +24,16 @@ export async function loader({ request }: LoaderArgs) {
|
||||
|
||||
const session = await getUserSession(request);
|
||||
|
||||
return { magicLinkSent: session.has("triggerdotdev:magiclink") };
|
||||
const templateId = await getCurrentTemplate(request);
|
||||
|
||||
const templateData = templateId
|
||||
? await new TemplatePresenter().data({ id: templateId })
|
||||
: null;
|
||||
|
||||
return typedjson({
|
||||
magicLinkSent: session.has("triggerdotdev:magiclink"),
|
||||
template: templateData?.template,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
@@ -59,12 +71,12 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export default function LoginMagicLinkPage() {
|
||||
const { magicLinkSent } = useLoaderData<typeof loader>();
|
||||
const { magicLinkSent, template } = useTypedLoaderData<typeof loader>();
|
||||
const transition = useTransition();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen justify-between overflow-y-scroll bg-slate-900">
|
||||
<LoginPromoPanel />
|
||||
<LoginPromoPanel template={template} />
|
||||
<div className="bg-gradient-background flex h-full w-full grow items-center justify-center p-4">
|
||||
<div className="flex min-h-[430px] w-full max-w-xl flex-col justify-between rounded-lg bg-slate-850 shadow-md">
|
||||
<Form className="flex h-full flex-grow flex-col" method="post">
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, Link, useLoaderData } from "@remix-run/react";
|
||||
import type { LoaderArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Form, Link } from "@remix-run/react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { GitHubLoginButton } from "~/components/GitHubLoginButton";
|
||||
import { LoginPromoPanel } from "~/components/LoginPromoPanel";
|
||||
import { LogoSvg } from "~/components/Logo";
|
||||
import { TemplatePresenter } from "~/presenters/templatePresenter.server";
|
||||
import { getCurrentTemplate } from "~/services/currentTemplate.server";
|
||||
import { commitSession, setRedirectTo } from "~/services/redirectTo.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
|
||||
type LoaderData = {
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await getUserId(request);
|
||||
if (userId) return redirect("/");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
|
||||
const templateId = await getCurrentTemplate(request);
|
||||
|
||||
const templateData = templateId
|
||||
? await new TemplatePresenter().data({ id: templateId })
|
||||
: null;
|
||||
|
||||
if (redirectTo) {
|
||||
const session = await setRedirectTo(request, redirectTo);
|
||||
|
||||
return json<LoaderData>(
|
||||
{ redirectTo },
|
||||
return typedjson(
|
||||
{ redirectTo, template: templateData?.template },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
@@ -31,9 +35,9 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return json({});
|
||||
return typedjson({ template: templateData?.template, redirectTo: null });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
@@ -42,10 +46,11 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen justify-between overflow-y-scroll bg-slate-900">
|
||||
<LoginPromoPanel />
|
||||
<LoginPromoPanel template={data.template} />
|
||||
<div className="flex h-full w-full grow items-center justify-center p-4">
|
||||
<div className="flex min-h-[430px] w-full max-w-xl flex-col justify-between rounded-lg bg-slate-850 shadow-md">
|
||||
<Form
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { jsonWithSuccessMessage } from "~/models/message.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isOnCloudWaitlist: true },
|
||||
});
|
||||
|
||||
return jsonWithSuccessMessage(
|
||||
user,
|
||||
request,
|
||||
"We'll let you know when it's ready!"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { createEventEmitter } from "~/services/messageBroker.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { id } = z.object({ id: z.string() }).parse(params);
|
||||
|
||||
const eventEmitter = await createEventEmitter({
|
||||
id: `${id}-${userId}`,
|
||||
filter: {
|
||||
"x-organization-template-id": id,
|
||||
},
|
||||
});
|
||||
|
||||
return eventStream(request.signal, (send) => {
|
||||
eventEmitter.on("organization-template.updated", (data) => {
|
||||
send({ data: JSON.stringify(data) });
|
||||
});
|
||||
|
||||
const timer = setInterval(() => {
|
||||
send({ event: "ping", data: new Date().toISOString() });
|
||||
}, 1000);
|
||||
|
||||
return function clear() {
|
||||
eventEmitter.removeAllListeners();
|
||||
clearInterval(timer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface SendFunctionArgs {
|
||||
/**
|
||||
* @default "message"
|
||||
*/
|
||||
event?: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface SendFunction {
|
||||
(args: SendFunctionArgs): void;
|
||||
}
|
||||
|
||||
interface CleanupFunction {
|
||||
(): void;
|
||||
}
|
||||
|
||||
interface InitFunction {
|
||||
(send: SendFunction): CleanupFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response holper to use Server Sent Events server-side
|
||||
* @param signal The AbortSignal used to close the stream
|
||||
* @param init The function that will be called to initialize the stream, here you can subscribe to your events
|
||||
* @returns A Response object that can be returned from a loader
|
||||
*/
|
||||
export function eventStream(signal: AbortSignal, init: InitFunction) {
|
||||
let stream = new ReadableStream({
|
||||
start(controller) {
|
||||
let encoder = new TextEncoder();
|
||||
|
||||
function send({ event = "message", data }: SendFunctionArgs) {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\n`));
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
}
|
||||
|
||||
let cleanup = init(send);
|
||||
|
||||
let closed = false;
|
||||
|
||||
function close() {
|
||||
if (closed) return;
|
||||
cleanup();
|
||||
closed = true;
|
||||
signal.removeEventListener("abort", close);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", close);
|
||||
|
||||
if (signal.aborted) return close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,19 @@ class BehaviouralAnalytics {
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
if (isNewUser) {
|
||||
this.#capture({
|
||||
userId: user.id,
|
||||
event: "user created",
|
||||
eventProperties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createCookieSessionStorage, Session } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const currentOrgSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__organization", // use any name you want here
|
||||
sameSite: "lax", // this helps with CSRF
|
||||
path: "/", // remember to add this so the cookie will work in all routes
|
||||
httpOnly: true, // for security reasons, make this cookie http only
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
});
|
||||
|
||||
export function getCurrentOrgSession(request: Request) {
|
||||
return currentOrgSessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export function commitCurrentOrgSession(session: Session) {
|
||||
return currentOrgSessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function getCurrentOrg(
|
||||
request: Request
|
||||
): Promise<string | undefined> {
|
||||
const session = await getCurrentOrgSession(request);
|
||||
|
||||
return session.get("currentOrg");
|
||||
}
|
||||
|
||||
export async function setCurrentOrg(slug: string, request: Request) {
|
||||
const session = await getCurrentOrgSession(request);
|
||||
|
||||
session.set("currentOrg", slug);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearCurrentOrg(request: Request) {
|
||||
const session = await getCurrentOrgSession(request);
|
||||
|
||||
session.unset("currentOrg");
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createCookieSessionStorage, Session } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const currentTemplateSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__template", // use any name you want here
|
||||
sameSite: "lax", // this helps with CSRF
|
||||
path: "/", // remember to add this so the cookie will work in all routes
|
||||
httpOnly: true, // for security reasons, make this cookie http only
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
});
|
||||
|
||||
export function getCurrentTemplateSession(request: Request) {
|
||||
return currentTemplateSessionStorage.getSession(
|
||||
request.headers.get("Cookie")
|
||||
);
|
||||
}
|
||||
|
||||
export function commitCurrentTemplateSession(session: Session) {
|
||||
return currentTemplateSessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function getCurrentTemplate(
|
||||
request: Request
|
||||
): Promise<string | undefined> {
|
||||
const session = await getCurrentTemplateSession(request);
|
||||
|
||||
return session.get("currentTemplate");
|
||||
}
|
||||
|
||||
export async function setCurrentTemplate(id: string, request: Request) {
|
||||
const session = await getCurrentTemplateSession(request);
|
||||
|
||||
session.set("currentTemplate", id);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearCurrentTemplate(request: Request) {
|
||||
const session = await getCurrentTemplateSession(request);
|
||||
|
||||
session.unset("currentTemplate");
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
getAppInstallation,
|
||||
oauthApp,
|
||||
octokit,
|
||||
} from "~/services/github/githubApp.server";
|
||||
|
||||
export class AppInstallationCallback {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
code,
|
||||
state,
|
||||
installation_id,
|
||||
}: {
|
||||
code: string;
|
||||
state: string;
|
||||
installation_id: string;
|
||||
}) {
|
||||
if (!oauthApp || !octokit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt =
|
||||
await this.#prismaClient.gitHubAppAuthorizationAttempt.findUnique({
|
||||
where: {
|
||||
id: state,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installation = await getAppInstallation({
|
||||
installation_id: Number(installation_id),
|
||||
});
|
||||
|
||||
if (!installation || !installation.account || !installation.account.login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { authentication } = await oauthApp.createToken({ code, state });
|
||||
|
||||
const authorization =
|
||||
await this.#prismaClient.gitHubAppAuthorization.create({
|
||||
data: {
|
||||
user: {
|
||||
connect: {
|
||||
id: attempt.userId,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
connect: {
|
||||
id: attempt.organizationId,
|
||||
},
|
||||
},
|
||||
token: authentication.token,
|
||||
// @ts-ignore
|
||||
tokenExpiresAt: new Date(authentication.expiresAt),
|
||||
// @ts-ignore
|
||||
refreshToken: authentication.refreshToken,
|
||||
// @ts-ignore
|
||||
refreshTokenExpiresAt: new Date(authentication.refreshTokenExpiresAt),
|
||||
installationId: installation.id,
|
||||
account: installation.account,
|
||||
accountName: installation.account.login,
|
||||
permissions: installation.permissions,
|
||||
repositorySelection: installation.repository_selection,
|
||||
accessTokensUrl: installation.access_tokens_url,
|
||||
repositoriesUrl: installation.repositories_url,
|
||||
htmlUrl: installation.html_url,
|
||||
events: installation.events,
|
||||
accountType:
|
||||
installation.account?.type === "User" ? "USER" : "ORGANIZATION",
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.gitHubAppAuthorizationAttempt.delete({
|
||||
where: {
|
||||
id: attempt.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { authorization, templateId: attempt.templateId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { createUnauthenticatedAuth } from "@octokit/auth-unauthenticated";
|
||||
import { OAuthApp } from "@octokit/oauth-app";
|
||||
import { Options } from "@octokit/oauth-app/dist-types/types";
|
||||
import { RequestError } from "@octokit/request-error";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Endpoints } from "@octokit/types";
|
||||
import { EmitterWebhookEvent, Webhooks } from "@octokit/webhooks";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { taskQueue } from "../messageBroker.server";
|
||||
|
||||
export const octokit = env.GITHUB_APP_PRIVATE_KEY
|
||||
? new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: env.GITHUB_APP_ID,
|
||||
privateKey: Buffer.from(env.GITHUB_APP_PRIVATE_KEY, "base64").toString(
|
||||
"utf8"
|
||||
),
|
||||
clientId: env.GITHUB_APP_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_APP_CLIENT_SECRET,
|
||||
},
|
||||
log: {
|
||||
debug: console.log,
|
||||
info: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
export const oauthApp = createOauthApp();
|
||||
|
||||
export const webhooks = createWebhooks();
|
||||
|
||||
declare global {
|
||||
var __github_webhooks__:
|
||||
| Webhooks<EmitterWebhookEvent & { octokit: Octokit }>
|
||||
| undefined;
|
||||
var __github_oauth_app__: OAuthApp<Options<"github-app">> | undefined;
|
||||
}
|
||||
|
||||
function createOauthApp() {
|
||||
if (typeof global.__github_oauth_app__ !== "undefined") {
|
||||
return global.__github_oauth_app__;
|
||||
}
|
||||
|
||||
if (typeof env.GITHUB_APP_CLIENT_ID === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof env.GITHUB_APP_CLIENT_SECRET === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
global.__github_oauth_app__ = new OAuthApp({
|
||||
clientId: env.GITHUB_APP_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_APP_CLIENT_SECRET,
|
||||
clientType: "github-app",
|
||||
Octokit: Octokit,
|
||||
});
|
||||
|
||||
global.__github_oauth_app__.on("token", async (event) => {});
|
||||
|
||||
return __github_oauth_app__;
|
||||
}
|
||||
|
||||
function createWebhooks() {
|
||||
if (typeof global.__github_webhooks__ !== "undefined") {
|
||||
return global.__github_webhooks__;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof env.GITHUB_APP_WEBHOOK_SECRET === "undefined" ||
|
||||
typeof octokit === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
global.__github_webhooks__ = __webhooks(
|
||||
octokit,
|
||||
env.GITHUB_APP_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
global.__github_webhooks__.on("push", ({ octokit, payload }) => {});
|
||||
global.__github_webhooks__.on(
|
||||
"installation.deleted",
|
||||
async ({ octokit, payload }) => {
|
||||
await taskQueue.publish("GITHUB_APP_INSTALLATION_DELETED", {
|
||||
id: payload.installation.id,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
global.__github_webhooks__.on(
|
||||
"installation_repositories.added",
|
||||
async ({ octokit, payload }) => {
|
||||
for (const addedRepo of payload.repositories_added) {
|
||||
await taskQueue.publish(
|
||||
"GITHUB_APP_REPOSITORY_CREATED",
|
||||
{
|
||||
id: addedRepo.id,
|
||||
},
|
||||
{},
|
||||
{ deliverAfter: 1000 * 15 }
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
global.__github_webhooks__.onAny(({ id, name, payload }) => {
|
||||
console.log(
|
||||
`[github-webhook] ${name} event received: ${id}`,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
});
|
||||
|
||||
return global.__github_webhooks__;
|
||||
}
|
||||
|
||||
function __webhooks(
|
||||
appOctokit: Octokit,
|
||||
secret: string
|
||||
// Explict return type for better debugability and performance,
|
||||
// see https://github.com/octokit/app.js/pull/201
|
||||
): Webhooks<EmitterWebhookEvent & { octokit: Octokit }> {
|
||||
return new Webhooks({
|
||||
secret,
|
||||
transform: async (event) => {
|
||||
if (
|
||||
!("installation" in event.payload) ||
|
||||
typeof event.payload.installation !== "object"
|
||||
) {
|
||||
const octokit = new (appOctokit.constructor as typeof Octokit)({
|
||||
authStrategy: createUnauthenticatedAuth,
|
||||
auth: {
|
||||
reason: `"installation" key missing in webhook event payload`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...event,
|
||||
octokit,
|
||||
};
|
||||
}
|
||||
|
||||
const installationId = event.payload.installation.id;
|
||||
const octokit = (await appOctokit.auth({
|
||||
type: "installation",
|
||||
installationId,
|
||||
factory(auth: any) {
|
||||
return new auth.octokit.constructor({
|
||||
...auth.octokitOptions,
|
||||
authStrategy: createAppAuth,
|
||||
...{
|
||||
auth: {
|
||||
...auth,
|
||||
installationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
})) as Octokit;
|
||||
|
||||
// set `x-github-delivery` header on all requests sent in response to the current
|
||||
// event. This allows GitHub Support to correlate the request with the event.
|
||||
// This is not documented and not considered public API, the header may change.
|
||||
// Once we document this as best practice on https://docs.github.com/en/rest/guides/best-practices-for-integrators
|
||||
// we will make it official
|
||||
/* istanbul ignore next */
|
||||
octokit.hook.before("request", (options) => {
|
||||
options.headers["x-github-delivery"] = event.id;
|
||||
});
|
||||
|
||||
return {
|
||||
...event,
|
||||
octokit,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type GetAppInstallationEndpoint =
|
||||
Endpoints["GET /app/installations/{installation_id}"];
|
||||
|
||||
export async function getAppInstallation({
|
||||
installation_id,
|
||||
}: GetAppInstallationEndpoint["parameters"]) {
|
||||
if (typeof octokit === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await octokit.request(
|
||||
"GET /app/installations/{installation_id}",
|
||||
{
|
||||
installation_id,
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
type CreateRepositoryFromTemplateEndpoint =
|
||||
Endpoints["POST /repos/{template_owner}/{template_repo}/generate"];
|
||||
|
||||
export async function createRepositoryFromTemplate(
|
||||
parameters: CreateRepositoryFromTemplateEndpoint["parameters"],
|
||||
{ installationId }: { installationId?: number }
|
||||
): Promise<
|
||||
| {
|
||||
status: "success";
|
||||
data: CreateRepositoryFromTemplateEndpoint["response"]["data"];
|
||||
}
|
||||
| { status: "error"; message: string }
|
||||
> {
|
||||
if (typeof octokit === "undefined") {
|
||||
return { status: "error", message: "Octokit not initialized" };
|
||||
}
|
||||
|
||||
const kit = installationId ? await getOctokit(installationId) : octokit;
|
||||
try {
|
||||
const response = await kit.request(
|
||||
"POST /repos/{template_owner}/{template_repo}/generate",
|
||||
parameters
|
||||
);
|
||||
|
||||
return { status: "success", data: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) {
|
||||
return { status: "error", message: error.message };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CreateOrgRepositoryEndpoint = Endpoints["POST /orgs/{org}/repos"];
|
||||
|
||||
export async function createOrgRepository(
|
||||
parameters: CreateOrgRepositoryEndpoint["parameters"],
|
||||
options: ExistingGitHubAppExpiringTokenOptions
|
||||
): Promise<
|
||||
| {
|
||||
status: "success";
|
||||
data: CreateOrgRepositoryEndpoint["response"]["data"];
|
||||
}
|
||||
| { status: "error"; message: string }
|
||||
> {
|
||||
if (typeof octokit === "undefined") {
|
||||
return { status: "error", message: "Octokit not initialized" };
|
||||
}
|
||||
|
||||
const kit = await getOauthOctokit(options);
|
||||
|
||||
try {
|
||||
const response = await kit.request("POST /orgs/{org}/repos", parameters);
|
||||
|
||||
return { status: "success", data: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) {
|
||||
return { status: "error", message: error.message };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CreateUserRepositoryEndpoint = Endpoints["POST /user/repos"];
|
||||
|
||||
export async function createUserRepository(
|
||||
parameters: CreateUserRepositoryEndpoint["parameters"],
|
||||
options: ExistingGitHubAppExpiringTokenOptions
|
||||
): Promise<
|
||||
| {
|
||||
status: "success";
|
||||
data: CreateUserRepositoryEndpoint["response"]["data"];
|
||||
}
|
||||
| { status: "error"; message: string }
|
||||
> {
|
||||
if (typeof octokit === "undefined") {
|
||||
return { status: "error", message: "Octokit not initialized" };
|
||||
}
|
||||
|
||||
const kit = await getOauthOctokit(options);
|
||||
try {
|
||||
const response = await kit.request("POST /user/repos", parameters);
|
||||
|
||||
return { status: "success", data: response.data };
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) {
|
||||
return { status: "error", message: error.message };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOctokitRest(installationId: number) {
|
||||
const installationKit = await getOctokit(installationId);
|
||||
|
||||
return installationKit.rest;
|
||||
}
|
||||
|
||||
export async function getOauthOctokitRest(
|
||||
options: ExistingGitHubAppExpiringTokenOptions
|
||||
) {
|
||||
const oauthKit = await getOauthOctokit(options);
|
||||
|
||||
return oauthKit.rest;
|
||||
}
|
||||
|
||||
export type ExistingGitHubAppExpiringTokenOptions = {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
};
|
||||
|
||||
// WARNING: If tokens are refreshed using this method, the new tokens will not be stored in the db and stuff will break!
|
||||
async function getOauthOctokit(
|
||||
options: ExistingGitHubAppExpiringTokenOptions
|
||||
): Promise<Octokit> {
|
||||
const userOctokit = global.__github_oauth_app__!.getUserOctokit(
|
||||
// @ts-ignore
|
||||
options
|
||||
) as Promise<Octokit>;
|
||||
|
||||
return userOctokit;
|
||||
}
|
||||
|
||||
async function getOctokit(installationId: number): Promise<Octokit> {
|
||||
return octokit!.auth({
|
||||
type: "installation",
|
||||
installationId,
|
||||
factory(auth: any) {
|
||||
return new auth.octokit.constructor({
|
||||
...auth.octokitOptions,
|
||||
authStrategy: createAppAuth,
|
||||
...{
|
||||
auth: {
|
||||
...auth,
|
||||
installationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}) as Promise<Octokit>;
|
||||
}
|
||||
|
||||
export const AccountSchema = z.object({
|
||||
login: z.string(),
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional().nullable(),
|
||||
avatar_url: z.string(),
|
||||
gravatar_id: z.string(),
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
followers_url: z.string(),
|
||||
following_url: z.string(),
|
||||
gists_url: z.string(),
|
||||
starred_url: z.string(),
|
||||
subscriptions_url: z.string(),
|
||||
organizations_url: z.string(),
|
||||
repos_url: z.string(),
|
||||
events_url: z.string(),
|
||||
received_events_url: z.string(),
|
||||
type: z.union([
|
||||
z.literal("Bot"),
|
||||
z.literal("User"),
|
||||
z.literal("Organization"),
|
||||
]),
|
||||
site_admin: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { GitHubAppAuthorization } from ".prisma/client";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { oauthApp } from "../github/githubApp.server";
|
||||
|
||||
export class RefreshAppAuthorizationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(appAuthorization: GitHubAppAuthorization) {
|
||||
if (!oauthApp) {
|
||||
return appAuthorization;
|
||||
}
|
||||
|
||||
// If tokenExpiresAt is within 30 minutes of expiring, refresh it
|
||||
if (
|
||||
appAuthorization.tokenExpiresAt.getTime() - Date.now() >
|
||||
30 * 60 * 1000
|
||||
) {
|
||||
return appAuthorization;
|
||||
}
|
||||
|
||||
const refreshedToken = await oauthApp.refreshToken({
|
||||
refreshToken: appAuthorization.refreshToken,
|
||||
});
|
||||
|
||||
const updatedAppAuthorization =
|
||||
await this.#prismaClient.gitHubAppAuthorization.update({
|
||||
where: {
|
||||
id: appAuthorization.id,
|
||||
},
|
||||
data: {
|
||||
token: refreshedToken.authentication.token,
|
||||
tokenExpiresAt: refreshedToken.authentication.expiresAt,
|
||||
refreshToken: refreshedToken.authentication.refreshToken,
|
||||
refreshTokenExpiresAt:
|
||||
refreshedToken.authentication.refreshTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedAppAuthorization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getOauthOctokitRest } from "~/services/github/githubApp.server";
|
||||
import { appEventPublisher } from "../messageBroker.server";
|
||||
import fs from "node:fs/promises";
|
||||
import tar from "tar";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { RefreshAppAuthorizationService } from "./refreshAppAuthorization.server";
|
||||
|
||||
export class GithubRepositoryCreated {
|
||||
#prismaClient: PrismaClient;
|
||||
#refreshAppAuthorizationService: RefreshAppAuthorizationService =
|
||||
new RefreshAppAuthorizationService();
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: number) {
|
||||
const organizationTemplate =
|
||||
await this.#prismaClient.organizationTemplate.findUnique({
|
||||
where: {
|
||||
repositoryId: id,
|
||||
},
|
||||
include: {
|
||||
authorization: true,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organizationTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appAuthorization = await this.#refreshAppAuthorizationService.call(
|
||||
organizationTemplate.authorization
|
||||
);
|
||||
|
||||
const octokit = await getOauthOctokitRest({
|
||||
token: appAuthorization.token,
|
||||
refreshToken: appAuthorization.refreshToken,
|
||||
expiresAt: appAuthorization.tokenExpiresAt.toISOString(),
|
||||
refreshTokenExpiresAt:
|
||||
appAuthorization.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const sourceRepositoryUrl = new URL(
|
||||
organizationTemplate.template.repositoryUrl
|
||||
);
|
||||
const targetRepositoryUrl = new URL(organizationTemplate.repositoryUrl);
|
||||
|
||||
// Get the owner and repo from the url, e.g. https://github.com/triggerdotdev/basic-starter -> triggerdotdev is the owner and basic-starter is the repo
|
||||
const [targetOwner, targetRepo] = targetRepositoryUrl.pathname
|
||||
.split("/")
|
||||
.slice(1);
|
||||
|
||||
const [sourceOwner, sourceRepo] = sourceRepositoryUrl.pathname
|
||||
.split("/")
|
||||
.slice(1);
|
||||
|
||||
// Get the latest commit hash to main
|
||||
const sourceBranchMain = await octokit.repos.getBranch({
|
||||
owner: sourceOwner,
|
||||
repo: sourceRepo,
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
if (!sourceBranchMain.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceArchiveLink = await octokit.repos.downloadTarballArchive({
|
||||
owner: sourceOwner,
|
||||
repo: sourceRepo,
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
// Download the source repository as a tarball
|
||||
const sourceArchive = await fetch(sourceArchiveLink.url);
|
||||
|
||||
// Extract the tarball
|
||||
const sourceArchiveBuffer = await sourceArchive.arrayBuffer();
|
||||
|
||||
// Create temporary directory and write the tarball to it
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "trigger-"));
|
||||
|
||||
const tarballPath = `${tempDir}/source.tar.gz`;
|
||||
|
||||
await fs.writeFile(tarballPath, Buffer.from(sourceArchiveBuffer));
|
||||
|
||||
const destinationPath = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "triggerd-")
|
||||
);
|
||||
|
||||
// Extract the files
|
||||
await tar.extract({
|
||||
file: tarballPath,
|
||||
cwd: destinationPath,
|
||||
});
|
||||
|
||||
// The root of the extracted tarball is destinationPath/sourceOwner-sourceRepo-<first 7 characters of the commit hash>
|
||||
const destinationRepoPath = path.join(
|
||||
destinationPath,
|
||||
`${sourceOwner}-${sourceRepo}-${sourceBranchMain.data.commit.sha.slice(
|
||||
0,
|
||||
7
|
||||
)}`
|
||||
);
|
||||
|
||||
// Read the files from the extracted tarball
|
||||
const entries = await readDirectoryRecursively(destinationRepoPath);
|
||||
|
||||
const commitFiles = entries.map((entry) => {
|
||||
const relativePath = path.relative(destinationRepoPath, entry.filePath);
|
||||
|
||||
if (relativePath === "README.md") {
|
||||
// Replace all occurrences of the template repository url with the new repository url
|
||||
const readmeContent = replaceReadmeContents(
|
||||
organizationTemplate.repositoryUrl,
|
||||
organizationTemplate.template.repositoryUrl,
|
||||
organizationTemplate.name,
|
||||
organizationTemplate.template.slug,
|
||||
entry.fileContents
|
||||
);
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
content: readmeContent,
|
||||
mode: "100644" as const,
|
||||
type: "commit" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.relative(destinationRepoPath, entry.filePath),
|
||||
content: entry.fileContents,
|
||||
mode: "100644" as const,
|
||||
type: "commit" as const,
|
||||
};
|
||||
});
|
||||
|
||||
// Get the latest commit hash to main
|
||||
const targetBranchMain = await octokit.repos.getBranch({
|
||||
owner: targetOwner,
|
||||
repo: targetRepo,
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
// Create a tree with the files
|
||||
const tree = await octokit.git.createTree({
|
||||
owner: targetOwner,
|
||||
repo: targetRepo,
|
||||
tree: commitFiles,
|
||||
base_tree: targetBranchMain.data.commit.sha,
|
||||
});
|
||||
|
||||
// Create the commit
|
||||
const commit = await octokit.git.createCommit({
|
||||
owner: targetOwner,
|
||||
repo: targetRepo,
|
||||
message: "Initial commit",
|
||||
tree: tree.data.sha,
|
||||
parents: [],
|
||||
});
|
||||
|
||||
// Update the ref to point to the new commit
|
||||
await octokit.git.updateRef({
|
||||
owner: targetOwner,
|
||||
repo: targetRepo,
|
||||
ref: "heads/main",
|
||||
sha: commit.data.sha,
|
||||
force: true,
|
||||
});
|
||||
|
||||
await this.#prismaClient.organizationTemplate.update({
|
||||
where: {
|
||||
id: organizationTemplate.id,
|
||||
},
|
||||
data: {
|
||||
status: "READY_TO_DEPLOY",
|
||||
},
|
||||
});
|
||||
|
||||
await appEventPublisher.publish(
|
||||
"organization-template.updated",
|
||||
{
|
||||
id: organizationTemplate.id,
|
||||
status: "READY_TO_DEPLOY",
|
||||
},
|
||||
{
|
||||
"x-organization-template-id": organizationTemplate.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readDirectoryRecursively(
|
||||
directoryPath: string
|
||||
): Promise<{ filePath: string; fileContents: string }[]> {
|
||||
const result: { filePath: string; fileContents: string }[] = [];
|
||||
|
||||
// Read the contents of the directory
|
||||
const files = await fs.readdir(directoryPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
// Iterate over the files and subdirectories in the directory
|
||||
for (const file of files) {
|
||||
const filePath = path.join(directoryPath, file.name);
|
||||
|
||||
// If the item is a file, read its contents and add to the result array
|
||||
if (file.isFile()) {
|
||||
const fileContents = await fs.readFile(filePath, "utf8");
|
||||
result.push({ filePath, fileContents });
|
||||
}
|
||||
|
||||
// If the item is a directory, recursively read its contents and add to the result array
|
||||
if (file.isDirectory()) {
|
||||
const directoryContents = await readDirectoryRecursively(filePath);
|
||||
result.push(...directoryContents);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceReadmeContents(
|
||||
finalRepoUrl: string,
|
||||
templateRepoUrl: string,
|
||||
finalRepoName: string,
|
||||
templateRepoName: string,
|
||||
readme: string
|
||||
) {
|
||||
// Replace all instances (not just the first) of the templateRepoUrl with the finalRepoUrl
|
||||
const finalRepoUrlRegex = new RegExp(templateRepoUrl, "g");
|
||||
let finalDocs = readme.replace(finalRepoUrlRegex, finalRepoUrl);
|
||||
|
||||
// Replace all instances (not just the first) of the templateRepoName with the finalRepoName
|
||||
const finalRepoNameRegex = new RegExp(`cd ${templateRepoName}`, "g");
|
||||
finalDocs = finalDocs.replace(finalRepoNameRegex, `cd ${finalRepoName}`);
|
||||
|
||||
return finalDocs;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export class StartAppInstallation {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
templateId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
templateId?: string;
|
||||
}) {
|
||||
if (!env.GITHUB_APP_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await this.#prismaClient.organization.findUnique({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = await prisma.gitHubAppAuthorizationAttempt.create({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
userId,
|
||||
templateId,
|
||||
},
|
||||
});
|
||||
|
||||
return `https://github.com/apps/${env.GITHUB_APP_NAME}/installations/new?state=${attempt.id}`;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
ScheduledEventPayloadSchema,
|
||||
} from "@trigger.dev/common-schemas";
|
||||
import { DeliverEmailSchema } from "emails";
|
||||
import type {
|
||||
import {
|
||||
CommandCatalog,
|
||||
CommandResponseCatalog,
|
||||
TriggerCatalog,
|
||||
ZodEventPublisher,
|
||||
} from "internal-platform";
|
||||
import {
|
||||
commandCatalog,
|
||||
@@ -55,6 +56,8 @@ import { omit } from "~/utils/objects";
|
||||
import { findWorkflowStepById } from "~/models/workflowRunStep.server";
|
||||
import { InitializeRunOnce } from "./runOnce/initializeRunOnce.server";
|
||||
import { CompleteRunOnce } from "./runOnce/completeRunOnce.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { GithubRepositoryCreated } from "./github/repositoryCreated.server";
|
||||
import { OrganizationCreatedEvent } from "./analyticsEvents/organizationCreated.server";
|
||||
import { WorkflowCreatedEvent } from "./analyticsEvents/workflowCreated.server";
|
||||
import { WorkflowRunCreatedEvent } from "./analyticsEvents/workflowRunCreated.server";
|
||||
@@ -65,6 +68,7 @@ let commandResponsePublisher: ZodPublisher<CommandResponseCatalog>;
|
||||
let commandSubscriber: ZodSubscriber<CommandCatalog>;
|
||||
let taskQueue: ZodPubSub<typeof taskQueueCatalog>;
|
||||
let requestTaskQueue: ZodPubSub<typeof RequestCatalog>;
|
||||
let appEventPublisher: ZodEventPublisher;
|
||||
|
||||
declare global {
|
||||
var __pulsar_client__: typeof pulsarClient;
|
||||
@@ -73,6 +77,7 @@ declare global {
|
||||
var __command_response_publisher__: typeof commandResponsePublisher;
|
||||
var __task_queue__: typeof taskQueue;
|
||||
var __request_task_queue__: typeof requestTaskQueue;
|
||||
var __app_event_publisher__: typeof appEventPublisher;
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
@@ -135,11 +140,21 @@ export async function init() {
|
||||
requestTaskQueue = global.__request_task_queue__;
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === "production") {
|
||||
appEventPublisher = createAppEventPublisher();
|
||||
} else {
|
||||
if (!global.__app_event_publisher__) {
|
||||
global.__app_event_publisher__ = createAppEventPublisher();
|
||||
}
|
||||
appEventPublisher = global.__app_event_publisher__;
|
||||
}
|
||||
|
||||
await commandResponsePublisher.initialize();
|
||||
await triggerPublisher.initialize();
|
||||
await taskQueue.initialize();
|
||||
await requestTaskQueue.initialize();
|
||||
await commandSubscriber.initialize();
|
||||
await appEventPublisher.initialize();
|
||||
}
|
||||
|
||||
function createClient() {
|
||||
@@ -463,6 +478,14 @@ const taskQueueCatalog = {
|
||||
data: z.object({ stepId: z.string(), hasRun: z.boolean() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
GITHUB_APP_INSTALLATION_DELETED: {
|
||||
data: z.object({ id: z.number() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
GITHUB_APP_REPOSITORY_CREATED: {
|
||||
data: z.object({ id: z.number() }),
|
||||
properties: z.object({}),
|
||||
},
|
||||
ORGANIZATION_CREATED: {
|
||||
data: z.object({ id: z.string() }),
|
||||
properties: z.object({}),
|
||||
@@ -797,6 +820,40 @@ function createTaskQueue() {
|
||||
|
||||
return true;
|
||||
},
|
||||
GITHUB_APP_INSTALLATION_DELETED: async (
|
||||
id,
|
||||
data,
|
||||
properties,
|
||||
attributes
|
||||
) => {
|
||||
if (attributes.redeliveryCount >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await prisma.gitHubAppAuthorization.deleteMany({
|
||||
where: {
|
||||
installationId: data.id,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
GITHUB_APP_REPOSITORY_CREATED: async (
|
||||
id,
|
||||
data,
|
||||
properties,
|
||||
attributes
|
||||
) => {
|
||||
if (attributes.redeliveryCount >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const service = new GithubRepositoryCreated();
|
||||
|
||||
await service.call(data.id);
|
||||
|
||||
return true;
|
||||
},
|
||||
ORGANIZATION_CREATED: async (id, data, properties, attributes) => {
|
||||
if (attributes.redeliveryCount >= 4) {
|
||||
return true;
|
||||
@@ -810,8 +867,15 @@ function createTaskQueue() {
|
||||
return true;
|
||||
}
|
||||
|
||||
const service = new WorkflowCreatedEvent();
|
||||
return service.call(data.id);
|
||||
const service = new WorkflowCreated();
|
||||
|
||||
await service.call(data.id);
|
||||
|
||||
const analyticsService = new WorkflowCreatedEvent();
|
||||
|
||||
await analyticsService.call(data.id);
|
||||
|
||||
return true;
|
||||
},
|
||||
WORKFLOW_RUN_CREATED: async (id, data, properties, attributes) => {
|
||||
if (attributes.redeliveryCount >= 4) {
|
||||
@@ -827,4 +891,53 @@ function createTaskQueue() {
|
||||
return taskQueue;
|
||||
}
|
||||
|
||||
export { taskQueue, requestTaskQueue };
|
||||
function createAppEventPublisher() {
|
||||
return new ZodEventPublisher({
|
||||
client: pulsarClient,
|
||||
config: {
|
||||
topic: Topics.appEventQueue,
|
||||
batchingEnabled: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { taskQueue, requestTaskQueue, appEventPublisher };
|
||||
|
||||
import { ZodEventSubscriber } from "internal-platform";
|
||||
import { EventEmitter } from "stream";
|
||||
import { WorkflowCreated } from "./workflows/events/workflowCreated.server";
|
||||
|
||||
export async function createEventEmitter({
|
||||
id,
|
||||
filter,
|
||||
}: {
|
||||
id: string;
|
||||
filter: Record<string, string>;
|
||||
}) {
|
||||
const eventEmitter = new EventEmitter();
|
||||
|
||||
const eventSubscriber = new ZodEventSubscriber({
|
||||
client: pulsarClient,
|
||||
config: {
|
||||
subscription: `webapp-${id}`,
|
||||
topic: Topics.appEventQueue,
|
||||
},
|
||||
handler: async (id, name, data, properties, attributes) => {
|
||||
if (attributes.redeliveryCount >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
eventEmitter.emit(name, data);
|
||||
return true;
|
||||
},
|
||||
filter,
|
||||
});
|
||||
|
||||
await eventSubscriber.initialize();
|
||||
|
||||
eventEmitter.on("removeListener", async () => {
|
||||
await eventSubscriber.close();
|
||||
});
|
||||
|
||||
return eventEmitter;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createCookieSessionStorage, Session } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const onboardingSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__onboarding", // use any name you want here
|
||||
sameSite: "lax", // this helps with CSRF
|
||||
path: "/", // remember to add this so the cookie will work in all routes
|
||||
httpOnly: true, // for security reasons, make this cookie http only
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
});
|
||||
|
||||
export function getOnboardingSession(request: Request) {
|
||||
return onboardingSessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export function commitOnboardingSession(session: Session) {
|
||||
return onboardingSessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function getWorkflowDate(request: Request) {
|
||||
const session = await getOnboardingSession(request);
|
||||
|
||||
const rawWorkflowDate = session.get("workflowDate");
|
||||
|
||||
if (rawWorkflowDate) {
|
||||
return new Date(rawWorkflowDate);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setWorkflowDate(date: Date, request: Request) {
|
||||
const session = await getOnboardingSession(request);
|
||||
|
||||
session.set("workflowDate", date.toISOString());
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearWorkflowDate(request: Request) {
|
||||
const session = await getOnboardingSession(request);
|
||||
|
||||
session.unset("workflowDate");
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import prism from "prismjs";
|
||||
import "prismjs/components/prism-typescript";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/components/prism-bash";
|
||||
import "prismjs/plugins/line-numbers/prism-line-numbers";
|
||||
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
|
||||
import { marked } from "marked";
|
||||
|
||||
export function renderMarkdown(markdown: string) {
|
||||
const html = marked(markdown, {
|
||||
highlight: function (code, lang) {
|
||||
if (prism.languages[lang]) {
|
||||
return prism.highlight(code, prism.languages[lang], lang);
|
||||
}
|
||||
|
||||
return code;
|
||||
},
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { GitHubAppAuthorization } from ".prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
AccountSchema,
|
||||
createOrgRepository,
|
||||
createUserRepository,
|
||||
} from "../github/githubApp.server";
|
||||
import { RefreshAppAuthorizationService } from "../github/refreshAppAuthorization.server";
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(3).max(100),
|
||||
templateId: z.string(),
|
||||
private: z.literal("on").optional(),
|
||||
appAuthorizationId: z.string(),
|
||||
});
|
||||
|
||||
export class AddTemplateService {
|
||||
#prismaClient: PrismaClient;
|
||||
#refreshAppAuthorizationService: RefreshAppAuthorizationService =
|
||||
new RefreshAppAuthorizationService();
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public validate(payload: unknown) {
|
||||
return FormSchema.safeParse(payload);
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
data,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
data: z.infer<typeof FormSchema>;
|
||||
}) {
|
||||
const appAuthorization =
|
||||
await this.#prismaClient.gitHubAppAuthorization.findUnique({
|
||||
where: {
|
||||
id: data.appAuthorizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appAuthorization) {
|
||||
return {
|
||||
type: "error" as const,
|
||||
message: "App authorization not found",
|
||||
};
|
||||
}
|
||||
|
||||
const refreshedAppAuthorization =
|
||||
await this.#refreshAppAuthorizationService.call(appAuthorization);
|
||||
|
||||
const template = await this.#prismaClient.template.findUnique({
|
||||
where: {
|
||||
id: data.templateId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return {
|
||||
type: "error" as const,
|
||||
message: "Template not found",
|
||||
};
|
||||
}
|
||||
|
||||
const account = AccountSchema.safeParse(refreshedAppAuthorization.account);
|
||||
|
||||
if (!account.success) {
|
||||
return {
|
||||
type: "error" as const,
|
||||
message: "Account not found",
|
||||
};
|
||||
}
|
||||
|
||||
const createdGithubRepo = await this.#createGitHubRepository(
|
||||
refreshedAppAuthorization,
|
||||
data
|
||||
);
|
||||
|
||||
if (createdGithubRepo.status === "error") {
|
||||
return {
|
||||
type: "error" as const,
|
||||
message: createdGithubRepo.message,
|
||||
};
|
||||
}
|
||||
|
||||
const githubRepository = createdGithubRepo.data;
|
||||
|
||||
const organizationTemplate =
|
||||
await this.#prismaClient.organizationTemplate.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
status: "CREATED",
|
||||
repositoryId: githubRepository.id,
|
||||
repositoryUrl: githubRepository.html_url,
|
||||
repositoryData: githubRepository,
|
||||
template: {
|
||||
connect: {
|
||||
id: data.templateId,
|
||||
},
|
||||
},
|
||||
private: data.private === "on",
|
||||
organization: {
|
||||
connect: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
},
|
||||
authorization: {
|
||||
connect: {
|
||||
id: data.appAuthorizationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
template: organizationTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
async #createGitHubRepository(
|
||||
authorization: GitHubAppAuthorization,
|
||||
data: z.infer<typeof FormSchema>
|
||||
) {
|
||||
if (authorization.accountType === "USER") {
|
||||
return createUserRepository(
|
||||
{
|
||||
name: data.name,
|
||||
private: data.private === "on",
|
||||
auto_init: true,
|
||||
},
|
||||
{
|
||||
token: authorization.token,
|
||||
refreshToken: authorization.refreshToken,
|
||||
expiresAt: authorization.tokenExpiresAt.toISOString(),
|
||||
refreshTokenExpiresAt:
|
||||
authorization.refreshTokenExpiresAt.toISOString(),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return createOrgRepository(
|
||||
{
|
||||
org: authorization.accountName,
|
||||
name: data.name,
|
||||
private: data.private === "on",
|
||||
auto_init: true,
|
||||
},
|
||||
{
|
||||
token: authorization.token,
|
||||
refreshToken: authorization.refreshToken,
|
||||
expiresAt: authorization.tokenExpiresAt.toISOString(),
|
||||
refreshTokenExpiresAt:
|
||||
authorization.refreshTokenExpiresAt.toISOString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { env } from "process";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { IngestCustomEvent } from "~/services/events/ingestCustomEvent.server";
|
||||
import { appEventPublisher } from "~/services/messageBroker.server";
|
||||
|
||||
export class WorkflowCreated {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const workflow = await this.#prismaClient.workflow.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#sendInternalEvent(workflow.id);
|
||||
|
||||
const orgTemplates = await this.#prismaClient.organizationTemplate.findMany(
|
||||
{
|
||||
where: {
|
||||
organizationId: workflow.organizationId,
|
||||
template: {
|
||||
workflowIds: {
|
||||
has: workflow.slug,
|
||||
},
|
||||
},
|
||||
status: "READY_TO_DEPLOY",
|
||||
},
|
||||
include: {
|
||||
template: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const firstOrgTemplate = orgTemplates[0];
|
||||
|
||||
if (firstOrgTemplate) {
|
||||
await this.#prismaClient.workflow.update({
|
||||
where: { id: workflow.id },
|
||||
data: {
|
||||
organizationTemplateId: firstOrgTemplate.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const orgTemplate of orgTemplates) {
|
||||
await this.#prismaClient.organizationTemplate.update({
|
||||
where: { id: orgTemplate.id },
|
||||
data: {
|
||||
status: "DEPLOYED",
|
||||
},
|
||||
});
|
||||
|
||||
await appEventPublisher.publish(
|
||||
"organization-template.updated",
|
||||
{
|
||||
id: orgTemplate.id,
|
||||
status: "DEPLOYED",
|
||||
},
|
||||
{
|
||||
"x-organization-template-id": orgTemplate.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #sendInternalEvent(id: string) {
|
||||
if (!env.INTERNAL_TRIGGER_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ingestEventService = new IngestCustomEvent();
|
||||
|
||||
await ingestEventService.call({
|
||||
id,
|
||||
event: { name: "workflow.created", payload: { id: id } },
|
||||
apiKey: env.INTERNAL_TRIGGER_API_KEY,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { prisma } from "~/db.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { Workflow } from "~/models/workflow.server";
|
||||
import { taskQueue } from "../messageBroker.server";
|
||||
import { appEventPublisher, taskQueue } from "../messageBroker.server";
|
||||
|
||||
export class RegisterWorkflow {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -166,12 +166,8 @@ export class RegisterWorkflow {
|
||||
});
|
||||
|
||||
if (!existingWorkflow) {
|
||||
await taskQueue.publish("SEND_INTERNAL_EVENT", {
|
||||
await taskQueue.publish("WORKFLOW_CREATED", {
|
||||
id: workflow.id,
|
||||
name: "workflow.created",
|
||||
payload: {
|
||||
id: workflow.id,
|
||||
},
|
||||
});
|
||||
await taskQueue.publish("WORKFLOW_CREATED", {
|
||||
id: workflow.id,
|
||||
|
||||
+28
-13
@@ -28,6 +28,7 @@
|
||||
"generate": "prisma generate",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"generate:sourcemaps": "remix build --sourcemap",
|
||||
"sentry-upload": "pnpm run generate:sourcemaps && sentry-upload-sourcemaps",
|
||||
"clean:sourcemaps": "run-s clean:sourcemaps:*",
|
||||
@@ -59,13 +60,21 @@
|
||||
"@lezer/highlight": "^1.1.2",
|
||||
"@nangohq/pizzly-frontend": "^0.3.7",
|
||||
"@nangohq/pizzly-node": "^0.4.1",
|
||||
"@octokit/app": "^13.1.2",
|
||||
"@octokit/auth-app": "^4.0.9",
|
||||
"@octokit/auth-oauth-user": "^2.1.1",
|
||||
"@octokit/auth-unauthenticated": "^3.0.4",
|
||||
"@octokit/core": "^4.2.0",
|
||||
"@octokit/oauth-app": "^4.2.0",
|
||||
"@octokit/request-error": "^3.0.3",
|
||||
"@octokit/rest": "^19.0.7",
|
||||
"@octokit/webhooks": "^10.4.0",
|
||||
"@prisma/client": "^4.3.0",
|
||||
"@remix-run/express": "^1.7.2",
|
||||
"@remix-run/node": "^1.7.2",
|
||||
"@remix-run/react": "^1.7.2",
|
||||
"@remix-run/server-runtime": "^1.7.0",
|
||||
"@sentry/remix": "^7.36.0",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@remix-run/express": "v1.10.1",
|
||||
"@remix-run/node": "v1.10.1",
|
||||
"@remix-run/react": "v1.10.1",
|
||||
"@remix-run/server-runtime": "v1.10.1",
|
||||
"@sentry/remix": "^7.37.2",
|
||||
"@tanstack/react-table": "^8.0.0-alpha.87",
|
||||
"@trigger.dev/common-schemas": "workspace:*",
|
||||
"@trigger.dev/github": "workspace:*",
|
||||
@@ -106,8 +115,8 @@
|
||||
"nanoid": "^3.3.4",
|
||||
"openapi-types": "^12.0.0",
|
||||
"postcss-import": "^14.1.0",
|
||||
"posthog-js": "^1.31.0",
|
||||
"posthog-node": "^2.4.0",
|
||||
"posthog-js": "^1.45.1",
|
||||
"posthog-node": "^2.5.1",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -125,9 +134,10 @@
|
||||
"remix-auth-email-link": "^1.4.2",
|
||||
"remix-auth-github": "^1.1.1",
|
||||
"remix-typedjson": "~0.1.3",
|
||||
"remix-utils": "^3.4.0",
|
||||
"remix-utils": "^6.0.0",
|
||||
"slug": "^6.0.0",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"tar": "^6.1.13",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"tsx": "^3.4.3",
|
||||
"ulid": "^2.3.0",
|
||||
@@ -136,11 +146,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@remix-run/dev": "^1.7.2",
|
||||
"@remix-run/eslint-config": "^1.7.2",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"@remix-run/dev": "v1.10.1",
|
||||
"@remix-run/eslint-config": "v1.10.1",
|
||||
"@swc/core": "^1.3.4",
|
||||
"@swc/helpers": "^0.4.11",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@testing-library/cypress": "^8.0.3",
|
||||
"@testing-library/dom": "^8.18.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
@@ -166,8 +178,10 @@
|
||||
"@types/react-date-range": "^1.4.3",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/slug": "^5.0.3",
|
||||
"@types/tar": "^6.1.4",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"@vitest/coverage-c8": "^0.23.4",
|
||||
"auth-unauthenticated": "link:@types/@octokit/auth-unauthenticated",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"c8": "^7.11.3",
|
||||
"cli-ux": "^6.0.9",
|
||||
@@ -196,7 +210,8 @@
|
||||
"typescript": "^4.8.4",
|
||||
"vite": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^3.5.1",
|
||||
"vitest": "^0.23.4"
|
||||
"vitest": "^0.23.4",
|
||||
"webhooks": "link:@types/@octokit/webhooks"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GitHubAppAuthorization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"tokenExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"refreshToken" TEXT NOT NULL,
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GitHubAppAuthorization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GitHubAppAuthorization" ADD CONSTRAINT "GitHubAppAuthorization_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GitHubAppAuthorization" ADD CONSTRAINT "GitHubAppAuthorization_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GitHubAppAuthorizationStatus" AS ENUM ('PENDING', 'AUTHORIZED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitHubAppAuthorization" ADD COLUMN "status" "GitHubAppAuthorizationStatus" NOT NULL DEFAULT 'PENDING';
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `status` on the `GitHubAppAuthorization` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitHubAppAuthorization" DROP COLUMN "status";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "GitHubAppAuthorizationStatus";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GitHubAppAuthorizationAttempt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "GitHubAppAuthorizationAttempt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[installationId]` on the table `GitHubAppAuthorization` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `accessTokensUrls` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `account` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `htmlUrl` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `installationId` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `permissions` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `repositoriesUrl` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `repositorySelection` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitHubAppAuthorization" ADD COLUMN "accessTokensUrls" TEXT NOT NULL,
|
||||
ADD COLUMN "account" JSONB NOT NULL,
|
||||
ADD COLUMN "events" TEXT[],
|
||||
ADD COLUMN "htmlUrl" TEXT NOT NULL,
|
||||
ADD COLUMN "installationId" INTEGER NOT NULL,
|
||||
ADD COLUMN "permissions" JSONB NOT NULL,
|
||||
ADD COLUMN "repositoriesUrl" TEXT NOT NULL,
|
||||
ADD COLUMN "repositorySelection" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GitHubAppAuthorization_installationId_key" ON "GitHubAppAuthorization"("installationId");
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `accessTokensUrls` on the `GitHubAppAuthorization` table. All the data in the column will be lost.
|
||||
- Added the required column `accessTokensUrl` to the `GitHubAppAuthorization` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitHubAppAuthorization" DROP COLUMN "accessTokensUrls",
|
||||
ADD COLUMN "accessTokensUrl" TEXT NOT NULL;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"repositoryUrl" TEXT NOT NULL,
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"templateId" TEXT NOT NULL,
|
||||
"authorizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"repositoryUrl" TEXT NOT NULL,
|
||||
"private" BOOLEAN NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationTemplate" ADD CONSTRAINT "OrganizationTemplate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationTemplate" ADD CONSTRAINT "OrganizationTemplate_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationTemplate" ADD CONSTRAINT "OrganizationTemplate_authorizationId_fkey" FOREIGN KEY ("authorizationId") REFERENCES "GitHubAppAuthorization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `name` on the `Template` table. All the data in the column will be lost.
|
||||
- Added the required column `description` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `imageUrl` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `shortTitle` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `title` to the `Template` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" DROP COLUMN "name",
|
||||
ADD COLUMN "description" TEXT NOT NULL,
|
||||
ADD COLUMN "imageUrl" TEXT NOT NULL,
|
||||
ADD COLUMN "services" TEXT[],
|
||||
ADD COLUMN "shortTitle" TEXT NOT NULL,
|
||||
ADD COLUMN "title" TEXT NOT NULL,
|
||||
ADD COLUMN "workflowIds" TEXT[];
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `repositoryData` to the `OrganizationTemplate` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationTemplate" ADD COLUMN "repositoryData" JSONB NOT NULL;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitHubAppAuthorizationAttempt" ADD COLUMN "templateId" TEXT;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[repositoryId]` on the table `OrganizationTemplate` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `repositoryId` to the `OrganizationTemplate` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationTemplate" ADD COLUMN "repositoryId" INTEGER NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationTemplate_repositoryId_key" ON "OrganizationTemplate"("repositoryId");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user