diff --git a/.changeset/spotty-walls-flow.md b/.changeset/spotty-walls-flow.md new file mode 100644 index 00000000000..5122f128c68 --- /dev/null +++ b/.changeset/spotty-walls-flow.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Automatically pickup on the TRIGGER_WSS_URL for the wss endpoint diff --git a/README.md b/README.md index 22482562908..e75024b20f7 100644 --- a/README.md +++ b/README.md @@ -172,7 +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. -![Viewing runs](https://mintlify.s3-us-west-1.amazonaws.com/api-hero/images/run-succeeded.png) +![Viewing runs](https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/7343f3b2-2342-493b-5007-78f60ba5b700/public)   diff --git a/apps/webapp/Dockerfile b/apps/webapp/Dockerfile index c93fc6f5f31..fbad25cd232 100644 --- a/apps/webapp/Dockerfile +++ b/apps/webapp/Dockerfile @@ -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 diff --git a/apps/webapp/app/components/LoginPromoPanel.tsx b/apps/webapp/app/components/LoginPromoPanel.tsx index 6363c26b538..d0acf36801a 100644 --- a/apps/webapp/app/components/LoginPromoPanel.tsx +++ b/apps/webapp/app/components/LoginPromoPanel.tsx @@ -4,67 +4,99 @@ import { CloudArrowUpIcon, WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; +import { TemplateListItem } from "~/presenters/templateListPresenter.server"; +import { Panel } from "./layout/Panel"; +import { Body } from "./primitives/text/Body"; +import { Header2, Header3 } from "./primitives/text/Headers"; -export function LoginPromoPanel() { +export function LoginPromoPanel({ template }: { template?: TemplateListItem }) { return (
- + )}
); } diff --git a/apps/webapp/app/components/integrations/AddApiKeyButton.tsx b/apps/webapp/app/components/integrations/AddApiKeyButton.tsx index a9365900b0d..769c6fd945c 100644 --- a/apps/webapp/app/components/integrations/AddApiKeyButton.tsx +++ b/apps/webapp/app/components/integrations/AddApiKeyButton.tsx @@ -68,14 +68,14 @@ export function AddApiKeyButton({ Add your {integration.name} API keys -
-
+
+
Instructions
-

+ {children} +

+ ); +} + export function AppBody({ children, className = "bg-slate-850", diff --git a/apps/webapp/app/components/layout/List.tsx b/apps/webapp/app/components/layout/List.tsx index bc3e512cf17..99a2da122b0 100644 --- a/apps/webapp/app/components/layout/List.tsx +++ b/apps/webapp/app/components/layout/List.tsx @@ -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 ( -
+
    {children}
); diff --git a/apps/webapp/app/components/layout/MarketingHeader.tsx b/apps/webapp/app/components/layout/MarketingHeader.tsx new file mode 100644 index 00000000000..3ecc9fb7a2b --- /dev/null +++ b/apps/webapp/app/components/layout/MarketingHeader.tsx @@ -0,0 +1,162 @@ +import { Popover, Transition } from "@headlessui/react"; +import { Link, NavLink } from "@remix-run/react"; +import { Fragment } from "react"; +import { Logo } from "../Logo"; +import { PrimaryLink, ToxicA, ToxicLink } from "../primitives/Buttons"; +import { MobileNavIcon, MobileNavLink } from "../primitives/NavLink"; + +function MobileNavigation() { + return ( + + + {({ open }) => } + + + + + + + + + Sign up + + + Docs + + + + Examples + + + Pricing + + + GitHub + + Login + + + + + ); +} + +export function MarketingHeader() { + return ( + <> +
+ +
+ + ); +} diff --git a/apps/webapp/app/components/onboarding/BackToSteps.tsx b/apps/webapp/app/components/onboarding/BackToSteps.tsx new file mode 100644 index 00000000000..482e1d5a77e --- /dev/null +++ b/apps/webapp/app/components/onboarding/BackToSteps.tsx @@ -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 ( + <> + + + + I'll host the workflow myself + + + Change answer + + ); +} + +export function BackToStep2() { + return ( + <> + + + + I'll start with a template + + + Change answer + + ); +} diff --git a/apps/webapp/app/components/onboarding/StepNumber.tsx b/apps/webapp/app/components/onboarding/StepNumber.tsx new file mode 100644 index 00000000000..e82235d61e8 --- /dev/null +++ b/apps/webapp/app/components/onboarding/StepNumber.tsx @@ -0,0 +1,31 @@ +export function StepNumber({ + stepNumber, + drawLine, + active = false, + complete = false, +}: { + stepNumber?: string; + drawLine?: boolean; + active?: boolean; + complete?: boolean; +}) { + return ( +
+ {active ? ( + + {stepNumber} + + ) : ( + + {complete ? "✓" : stepNumber} + + )} + + {drawLine ? ( +
+ ) : ( +
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/onboarding/classNames.tsx b/apps/webapp/app/components/onboarding/classNames.tsx new file mode 100644 index 00000000000..a3e8f84aafc --- /dev/null +++ b/apps/webapp/app/components/onboarding/classNames.tsx @@ -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", +}; diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index c4f0d835923..6bf36e940c0 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -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 ->; +> & { + size?: Size; +}; -type LinkProps = Parameters[0]; +type LinkProps = Parameters[0] & { + size?: Size; +}; type AProps = React.DetailedHTMLProps< React.AnchorHTMLAttributes, HTMLAnchorElement ->; +> & { + size?: Size; +}; -export function PrimaryButton({ children, className, ...props }: ButtonProps) { +export function PrimaryButton({ + children, + size = "regular", + className, + ...props +}: ButtonProps) { return ( - ); @@ -42,35 +72,69 @@ export function PrimaryButton({ children, className, ...props }: ButtonProps) { export function SecondaryButton({ children, + size = "regular", className, ...props }: ButtonProps) { return ( - ); } -export function TertiaryButton({ children, className, ...props }: ButtonProps) { +export function TertiaryButton({ + children, + size = "regular", + className, + ...props +}: ButtonProps) { return ( - ); } -export function DangerButton({ children, className, ...props }: ButtonProps) { +export function DangerButton({ + children, + size = "regular", + className, + ...props +}: ButtonProps) { return ( - ); } -export function PrimaryLink({ children, className, to, ...props }: LinkProps) { +export function PrimaryLink({ + children, + size = "regular", + className, + to, + ...props +}: LinkProps) { return ( - + {children} ); @@ -79,13 +143,18 @@ export function PrimaryLink({ children, className, to, ...props }: LinkProps) { export function SecondaryLink({ children, className, + size = "regular", to, ...props }: LinkProps) { return ( {children} @@ -93,27 +162,111 @@ export function SecondaryLink({ ); } -export function TertiaryLink({ children, className, to, ...props }: LinkProps) { +export function TertiaryLink({ + children, + className, + size = "regular", + to, + ...props +}: LinkProps) { return ( - + {children} ); } -export function PrimaryA({ children, className, href, ...props }: AProps) { +export function DangerLink({ + children, + className, + size = "regular", + to, + ...props +}: LinkProps) { return ( - + + {children} + + ); +} + +export function ToxicLink({ + children, + className, + size = "regular", + to, + ...props +}: LinkProps) { + return ( + + {children} + + ); +} + +export function PrimaryA({ + children, + className, + size = "regular", + href, + ...props +}: AProps) { + return ( + + {children} + + ); +} + +export function SecondaryA({ + children, + className, + size = "regular", + href, + ...props +}: AProps) { + return ( + {children} ); } -export function SecondaryA({ children, className, href, ...props }: AProps) { +export function TertiaryA({ + children, + className, + size = "regular", + href, + ...props +}: AProps) { return ( {children} @@ -121,11 +274,17 @@ export function SecondaryA({ children, className, href, ...props }: AProps) { ); } -export function TertiaryA({ children, className, href, ...props }: AProps) { +export function ToxicA({ + children, + className, + size = "regular", + href, + ...props +}: AProps) { return ( {children} diff --git a/apps/webapp/app/components/primitives/InputGroup.tsx b/apps/webapp/app/components/primitives/InputGroup.tsx index 724dbf7f73f..7ac6d76252f 100644 --- a/apps/webapp/app/components/primitives/InputGroup.tsx +++ b/apps/webapp/app/components/primitives/InputGroup.tsx @@ -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 (
diff --git a/apps/webapp/app/components/primitives/NavLink.tsx b/apps/webapp/app/components/primitives/NavLink.tsx new file mode 100644 index 00000000000..412402ad891 --- /dev/null +++ b/apps/webapp/app/components/primitives/NavLink.tsx @@ -0,0 +1,77 @@ +import { Link } from "@remix-run/react"; +import classnames from "classnames"; + +type NavLinkProps = Parameters[0]; + +export function NavLink({ + to, + children, + target, + className, + onClick, + ...props +}: NavLinkProps) { + return ( + + {children} + + ); +} + +export function MobileNavLink({ + to, + children, + className, + onClick, + ...props +}: NavLinkProps) { + return ( + + {children} + + ); +} + +export function MobileNavIcon({ open }: { open: boolean }) { + return ( + + ); +} diff --git a/apps/webapp/app/components/primitives/text/Title.tsx b/apps/webapp/app/components/primitives/text/Title.tsx index 8b2ebff9a91..70a7f860281 100644 --- a/apps/webapp/app/components/primitives/text/Title.tsx +++ b/apps/webapp/app/components/primitives/text/Title.tsx @@ -1,8 +1,8 @@ import { Header1 } from "./Headers"; -export function Title({ children }: { children: string }) { +export function Title({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/apps/webapp/app/components/samples/samplesList.tsx b/apps/webapp/app/components/samples/samplesList.tsx index 93595852075..bb1024a6c60 100644 --- a/apps/webapp/app/components/samples/samplesList.tsx +++ b/apps/webapp/app/components/samples/samplesList.tsx @@ -5,6 +5,7 @@ import { ChatBubbleOvalLeftEllipsisIcon, UserIcon, DocumentTextIcon, + CubeTransparentIcon, } from "@heroicons/react/24/outline"; import { customEvent } from "./custom-event"; import { githubIssues } from "./github-issues"; @@ -14,21 +15,34 @@ 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: , 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 zod", code: githubStars, + type: "example", }, - { icon: , name: "New user → email", @@ -36,8 +50,10 @@ export const exampleProjects = [ "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", + requiredPackages: + "@trigger.dev/sdk @trigger.dev/resend @trigger.dev/slack zod", code: resendEmailDripCampaign, + type: "example", }, { icon: , @@ -45,8 +61,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 +72,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: , @@ -65,8 +83,10 @@ 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 zod", code: githubIssues, + type: "example", }, { @@ -75,13 +95,11 @@ export const exampleProjects = [ 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 zod", code: whatsappListenForMessageAndReply, packagesCopy: "WhatsApp", + type: "example", }, -]; - -export const fromScratchProjects = [ { name: "Webhook", requiredPackages: "@trigger.dev/sdk zod", @@ -89,12 +107,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,9 +121,7 @@ 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)", @@ -114,9 +130,7 @@ export const fromScratchProjects = [ 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)", @@ -125,8 +139,14 @@ export const fromScratchProjects = [ 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" +); diff --git a/apps/webapp/app/components/templates/ExampleOverview.tsx b/apps/webapp/app/components/templates/ExampleOverview.tsx new file mode 100644 index 00000000000..2e65e37c25e --- /dev/null +++ b/apps/webapp/app/components/templates/ExampleOverview.tsx @@ -0,0 +1,202 @@ +import { XCircleIcon } from "@heroicons/react/24/solid"; +import React, { Fragment, useState } from "react"; +import invariant from "tiny-invariant"; +import { + ExampleProject, + exampleProjects, + fromScratchProjects +} from "~/components/samples/samplesList"; +import { useCurrentEnvironment } from "~/hooks/useEnvironments"; +import CodeBlock from "../code/CodeBlock"; +import { PrimaryButton, 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-4 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(null); + const isOpen = !!openProject; + + return ( + <> + {openProject && ( + setOpenProject(null)} + appear + show={isOpen} + as={Fragment} + > + +
+
+
+ +
+
+ + + { + setOpenProject(null); + onSelectedProject(openProject); + }} + > + Use this template + + +
+ {openProject.icon} + + {openProject.name} + +
+ + {openProject.title} + + {openProject.description} +
+
+
+ +
+
+ )} + <> + {exampleProjects.map((project) => ( + + ))} + + + ); +} + +export function FromScratchOverview({ + onSelectedProject, +}: { + onSelectedProject: (project: ExampleProject) => void; +}) { + const environment = useCurrentEnvironment(); + invariant(environment, "No environment selected"); + + const [openProject, setOpenProject] = useState(null); + const isOpen = openProject !== null; + + return ( + <> + {openProject && ( + setOpenProject(null)} + appear + show={isOpen} + as={Fragment} + > +
+
+ +
+
+
+ +
+ +
+ + + { + setOpenProject(null); + onSelectedProject(openProject); + }} + > + Use this template + + + + + {openProject.name} + + + {openProject.description} + {openProject.bulletPoints && ( +
    + {openProject.bulletPoints.map((point, i) => ( +
  • {point}
  • + ))} +
+ )} +
+
+
+ +
+
+
+
+ )} + <> + {fromScratchProjects.map((project) => ( + + + + ))} + + + ); +} diff --git a/apps/webapp/app/components/templates/TemplateCard.tsx b/apps/webapp/app/components/templates/TemplateCard.tsx new file mode 100644 index 00000000000..3f108cf373d --- /dev/null +++ b/apps/webapp/app/components/templates/TemplateCard.tsx @@ -0,0 +1,42 @@ +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 }: { template: TemplateListItem }) { + return ( +
+
+ template image +
+
+
+ + {template.title} + + + {template.description} + +
+
+ {template.services.map((service) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/webapp/app/components/templates/TemplateOverview.tsx b/apps/webapp/app/components/templates/TemplateOverview.tsx new file mode 100644 index 00000000000..bfc872978aa --- /dev/null +++ b/apps/webapp/app/components/templates/TemplateOverview.tsx @@ -0,0 +1,77 @@ +import { TemplateListItem } from "~/presenters/templateListPresenter.server"; +import { ApiLogoIcon } from "../code/ApiLogoIcon"; +import { OctoKitty } from "../GitHubLoginButton"; +import { PrimaryLink, TertiaryA, ToxicA } from "../primitives/Buttons"; +import { Body } from "../primitives/text/Body"; +import { Header2 } from "../primitives/text/Headers"; + +export function TemplateOverview({ template }: { template: TemplateListItem }) { + const { docsHTML, title, description, repositoryUrl, imageUrl, id } = + template; + + return ( +
+
+
+
+
+ +
{" "} +
+
+
+
+
+
+ + Use this template + + + + {title} + + {description} + +
+ {template.services.map((service) => ( +
+ +
+ ))} +
+ + + + + {repositoryUrl.replace("https://github.com/triggerdotdev", "")} + + +
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/templates/TemplatesData.tsx b/apps/webapp/app/components/templates/TemplatesData.tsx new file mode 100644 index 00000000000..069cdabbbfd --- /dev/null +++ b/apps/webapp/app/components/templates/TemplatesData.tsx @@ -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"], + }, +]; diff --git a/apps/webapp/app/components/templates/TemplatesGrid.tsx b/apps/webapp/app/components/templates/TemplatesGrid.tsx new file mode 100644 index 00000000000..beab3752d8b --- /dev/null +++ b/apps/webapp/app/components/templates/TemplatesGrid.tsx @@ -0,0 +1,118 @@ +import { XCircleIcon } from "@heroicons/react/24/solid"; +import { Link } from "@remix-run/react"; +import { Fragment, useState } from "react"; +import { 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; + openInNewPage: boolean; +}) { + const [openedTemplate, setOpenedTemplate] = useState( + null + ); + const isOpen = openedTemplate !== null; + + return ( + <> + setOpenedTemplate(null)} + appear + show={isOpen} + as={Fragment} + > + + {openedTemplate && } + + + +
+ {templates.map((template) => { + return ( + setOpenedTemplate(template)} + > +
+ +
+
+
+ + {template.title} + + + {template.description} + +
+
+ {template.services.map((service) => ( +
+ +
+ ))} +
+
+
+ ); + })} +
+ + ); +} + +function TemplateButtonOrLink({ + template, + openInNewPage, + onClick, + children, +}: { + template: TemplateListItem; + openInNewPage: boolean; + onClick: (e: React.MouseEvent) => void; + children: React.ReactNode; +}) { + const classNames = + "group flex h-full w-full flex-col overflow-hidden rounded-md border border-slate-700 bg-slate-800 text-left text-sm text-slate-200 shadow-md transition hover:cursor-pointer hover:border-slate-500 hover:bg-slate-700/30 disabled:opacity-50"; + + if (openInNewPage) { + return ( + + {children} + + ); + } else { + return ( + + ); + } +} diff --git a/apps/webapp/app/components/workflows/workflowList.tsx b/apps/webapp/app/components/workflows/workflowList.tsx new file mode 100644 index 00000000000..f053af96584 --- /dev/null +++ b/apps/webapp/app/components/workflows/workflowList.tsx @@ -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 ( + + {workflows.map((workflow) => { + return ( +
  • + + {workflow.lastRun === undefined && ( +
    + New +
    + )} + +
    +
    +
    + {workflow.status === "CREATED" && ( + + )} +
    + +
    +
    + + {workflow.title} + +
    + + + {workflow.trigger.title} + +
    +
    + {workflow.trigger.properties && + workflow.trigger.properties.map((property) => ( + + ))} +
    +
    +
    +
    +
    +
    +
    + + Last run: {lastRunDescription(workflow.lastRun)} + + + {workflow.slug} + +
    +
    + {workflow.integrations.source && ( + + )} + {workflow.integrations.services.map((service) => { + if (service === undefined) { + return null; + } + return ( + + ); + })} +
    +
    +
    +
    + +
  • + ); + })} +
    + ); +} + +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 ( + + {label} + + ); +} + +function WorkflowProperty({ + label, + content, +}: { + label: string; + content: string; +}) { + return ( +
    + + {label} + + + {content} + +
    + ); +} + +const workflowDisabled = "opacity-30"; diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 828e7d5f3e8..d554fbdc2dc 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -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; } diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 93399e5de82..2b31fa46316 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -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(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index 2aa1e337001..310bdaffefd 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -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["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["organizations"] + >(routeMatch.data.organizations); +} diff --git a/apps/webapp/app/models/integrations.server.ts b/apps/webapp/app/models/integrations.server.ts index f51f203961e..d4e2b74e1d3 100644 --- a/apps/webapp/app/models/integrations.server.ts +++ b/apps/webapp/app/models/integrations.server.ts @@ -1,5 +1,6 @@ import type { InternalIntegration } from "@trigger.dev/integration-sdk"; import { getIntegrations as getInternalIntegrations } from "integration-catalog"; +import invariant from "tiny-invariant"; export function getIntegrations(showAdminOnly: boolean) { return getInternalIntegrations(showAdminOnly); @@ -20,3 +21,13 @@ export function getIntegrationMetadata( const integration = integrations.find((i) => i.metadata.slug === name); return integration ? integration.metadata : undefined; } + +export function getIntegrationMetadataByService(service: string) { + const integrations = getIntegrationMetadatas(true); + + const metadata = integrations.find((i) => i.slug.includes(service)); + + invariant(metadata, `Integration not found for service ${service}`); + + return metadata; +} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 14b13230be2..7e108b9a35a 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -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 } } }, diff --git a/apps/webapp/app/models/workflowListPresenter.server.ts b/apps/webapp/app/models/workflowListPresenter.server.ts index 29f602e6569..d2a72f6227f 100644 --- a/apps/webapp/app/models/workflowListPresenter.server.ts +++ b/apps/webapp/app/models/workflowListPresenter.server.ts @@ -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 { getIntegrationMetadata, getIntegrations } from "./integrations.server"; +import { WorkflowsPresenter } from "~/presenters/workflowsPresenter.server"; import { getRuntimeEnvironment } from "./runtimeEnvironment.server"; -import type { ExternalSource, Workflow } from "./workflow.server"; export type WorkflowListItem = Awaited< ReturnType @@ -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 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, - 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, - externalSource?: Pick, - schedulerSource?: Pick, - internalSource?: Pick -): { - 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, - }; - } } } diff --git a/apps/webapp/app/presenters/organizationTemplatePresenter.server.ts b/apps/webapp/app/presenters/organizationTemplatePresenter.server.ts new file mode 100644 index 00000000000..558838e02ca --- /dev/null +++ b/apps/webapp/app/presenters/organizationTemplatePresenter.server.ts @@ -0,0 +1,101 @@ +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { getIntegrationMetadataByService } from "~/models/integrations.server"; +import { getRuntimeEnvironment } from "~/models/runtimeEnvironment.server"; +import { AccountSchema } from "~/services/github/githubApp.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, + }, + }); + + 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 githubAccount = AccountSchema.parse( + organizationTemplate.authorization.account + ); + + 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), + }; + + return { + template, + organizationTemplate, + apiKey: runtimeEnvironment.apiKey, + workflows, + runLocalDocsHTML: renderLocalDocsHTML( + organizationTemplate.repositoryUrl, + organizationTemplate.template.repositoryUrl, + runtimeEnvironment.apiKey, + organizationTemplate.template.runLocalDocs + ), + githubAccount, + repositoryName, + }; + } +} + +// Replace the templateRepoUrl in localDocs with the finalRepoUrl, and then renderMarkdown +function renderLocalDocsHTML( + finalRepoUrl: string, + templateRepoUrl: string, + apiKey: string, + localDocs: string +) { + const localDocsWithFinalRepoUrl = localDocs + .replace(templateRepoUrl, finalRepoUrl) + .replace("", apiKey) + .replace("", apiKey) + .replace("", apiKey); + + return renderMarkdown(localDocsWithFinalRepoUrl); +} diff --git a/apps/webapp/app/presenters/templateListPresenter.server.ts b/apps/webapp/app/presenters/templateListPresenter.server.ts new file mode 100644 index 00000000000..7f33ab61d67 --- /dev/null +++ b/apps/webapp/app/presenters/templateListPresenter.server.ts @@ -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 & { + services: Array; + docsHTML: string; +}; + +export class TemplateListPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + async data(): Promise<{ templates: Array }> { + 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 }; + } +} diff --git a/apps/webapp/app/presenters/templatePresenter.server.ts b/apps/webapp/app/presenters/templatePresenter.server.ts new file mode 100644 index 00000000000..ce12281edf5 --- /dev/null +++ b/apps/webapp/app/presenters/templatePresenter.server.ts @@ -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 }; + } +} diff --git a/apps/webapp/app/presenters/workflowStartPresenter.server.ts b/apps/webapp/app/presenters/workflowStartPresenter.server.ts new file mode 100644 index 00000000000..1954535cf09 --- /dev/null +++ b/apps/webapp/app/presenters/workflowStartPresenter.server.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { getIntegrationMetadataByService } from "~/models/integrations.server"; +import { AccountSchema } from "~/services/github/githubApp.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, + account: true, + installationId: true, + permissions: true, + repositorySelection: true, + }, + }); + + const authorizationsWithAccounts = appAuthorizations.map( + (appAuthorization) => { + const account = AccountSchema.parse(appAuthorization.account); + + return { + ...appAuthorization, + account, + }; + } + ); + + const template = await this.#getTemplate(templateId); + + const templates = await this.#prismaClient.template.findMany({ + orderBy: { + priority: "asc", + }, + }); + + return { + appAuthorizations: authorizationsWithAccounts, + templates, + template, + }; + } + + async #getTemplate( + templateId: string | undefined + ): Promise { + 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), + }; + } +} diff --git a/apps/webapp/app/presenters/workflowsPresenter.server.ts b/apps/webapp/app/presenters/workflowsPresenter.server.ts new file mode 100644 index 00000000000..6239fdddab6 --- /dev/null +++ b/apps/webapp/app/presenters/workflowsPresenter.server.ts @@ -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 +>[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, + externalSource?: Pick, + schedulerSource?: Pick, + internalSource?: Pick +): { + 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, + }; + } + } +} diff --git a/apps/webapp/app/routes/__app.tsx b/apps/webapp/app/routes/__app.tsx index 2190b844980..fdd7ecdad52 100644 --- a/apps/webapp/app/routes/__app.tsx +++ b/apps/webapp/app/routes/__app.tsx @@ -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) + ), + ], + ], } ); }; diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug.tsx index 6d7a514669a..9cd00e8e1b7 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug.tsx @@ -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() { diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/index.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/index.tsx index 99ecc6854a8..590eb6c2609 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/index.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/index.tsx @@ -1,44 +1,29 @@ -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 { getIntegrationMetadatas } from "~/models/integrations.server"; 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); invariant(params.organizationSlug, "Organization slug is required"); - const providers = getIntegrationMetadatas(false); const currentEnv = await getRuntimeEnvironmentFromRequest(request); const presenter = new WorkflowListPresenter(); try { const workflows = await presenter.data(params.organizationSlug, currentEnv); - return typedjson({ workflows, providers }); + return typedjson({ workflows }); } catch (error: any) { console.error(error); throw new Response("Error ", { status: 400 }); @@ -46,7 +31,7 @@ export const loader = async ({ request, params }: LoaderArgs) => { }; export default function Page() { - const { workflows, providers } = useTypedLoaderData(); + const { workflows } = useTypedLoaderData(); const currentOrganization = useCurrentOrganization(); if (currentOrganization === undefined) { return <>; @@ -83,163 +68,3 @@ export default function Page() { ); } - -function WorkflowList({ - workflows, - currentOrganizationSlug, -}: { - workflows: WorkflowListItem[]; - currentOrganizationSlug: string; -}) { - return ( - - {workflows.map((workflow) => { - return ( -
  • - - {workflow.lastRun === undefined && ( -
    - New -
    - )} - -
    -
    -
    - {workflow.status === "CREATED" && ( - - )} -
    - -
    -
    - - {workflow.title} - -
    - - - {workflow.trigger.title} - -
    -
    - {workflow.trigger.properties && - workflow.trigger.properties.map((property) => ( - - ))} -
    -
    -
    -
    -
    -
    -
    - - Last run: {lastRunDescription(workflow.lastRun)} - - - {workflow.slug} - -
    -
    - {workflow.integrations.source && ( - - )} - {workflow.integrations.services.map((service) => { - if (service === undefined) { - return null; - } - return ( - - ); - })} -
    -
    -
    -
    - -
  • - ); - })} -
    - ); -} - -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 ( - - {label} - - ); -} - -function WorkflowProperty({ - label, - content, -}: { - label: string; - content: string; -}) { - return ( -
    - - {label} - - - {content} - -
    - ); -} - -const workflowDisabled = "opacity-30"; diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates.add.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates.add.tsx new file mode 100644 index 00000000000..ca3644b2299 --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates.add.tsx @@ -0,0 +1,275 @@ +import { FolderIcon } from "@heroicons/react/24/solid"; +import { Form } from "@remix-run/react"; +import { ActionArgs, LoaderArgs } from "@remix-run/server-runtime"; +import { + redirect, + typedjson, + useTypedActionData, + useTypedLoaderData, +} from "remix-typedjson"; +import invariant from "tiny-invariant"; +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 { useCurrentEnvironment } from "~/hooks/useEnvironments"; +import { useCurrentOrganization } from "~/hooks/useOrganizations"; +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(); + const environment = useCurrentEnvironment(); + const currentOrganization = useCurrentOrganization(); + invariant(currentOrganization, "Organization must be defined"); + invariant(environment, "Environment must be defined"); + + const actionData = useTypedActionData(); + + return ( + +
    +
    + You're almost done + + {actionData?.type === "serviceError" ? ( + + ) : actionData?.type === "validationError" ? ( + + ) : ( + <> + )} + + {appAuthorizations.length === 0 ? ( + <> + + + + ) : ( + + )} + + {template ? ( + + + Where should we create the new repository? + + ) : ( + + + Where should we create the new repository? + + )} + +
    + + + + + {actionData?.type === "validationError" && ( + + )} + + + {template ? ( + + ) : ( + + + + + + {actionData?.type === "validationError" && ( + + )} + + )} +
    +
    + + + + + + {actionData?.type === "validationError" && ( + + )} + +
    +

    + Set the repo as private +

    +
    + +
    +
    +
    +
    + Add Template +
    +
    + + + {template && } +
    +
    + ); +} + +function ConnectToGithub() { + return ( + <> + + + Login with GitHub to get started + + + + + Continue with GitHub + + + + ); +} + +function ConfigureGithub() { + return ( + <> +
    + + + Configure GitHub + + + +
    + +
    +
    + + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates/$templateId.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates/$templateId.tsx new file mode 100644 index 00000000000..a922de7f941 --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/templates/$templateId.tsx @@ -0,0 +1,467 @@ +import { + ArrowTopRightOnSquareIcon, + ClockIcon, +} from "@heroicons/react/24/outline"; +import { + CloudIcon, + FolderIcon, + HomeIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; +import { useRevalidator } from "@remix-run/react"; +import { LoaderArgs } from "@remix-run/server-runtime"; +import { Fragment, useEffect, useState } from "react"; +import { + typedjson, + UseDataFunctionReturn, + useTypedLoaderData, +} from "remix-typedjson"; +import { useEventSource } from "remix-utils"; +import { CopyTextButton } from "~/components/CopyTextButton"; +import { Container } from "~/components/layout/Container"; +import { Panel } from "~/components/layout/Panel"; +import { StepNumber } from "~/components/onboarding/StepNumber"; +import { PrimaryButton, 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; + +export default function TemplatePage() { + const loaderData = useTypedLoaderData(); + + const events = useEventSource( + `/resources/organizationTemplates/${loaderData.organizationTemplate.id}` + ); + const revalidator = useRevalidator(); + + useEffect(() => { + if (events !== null) { + revalidator.revalidate(); + } + }, [events]); + + const organizationTemplateByStatus = ( + + ); + + return ( + +
    +
    + You're almost done + {organizationTemplateByStatus} +
    + +
    +
    + ); +} + +function OrganizationTemplateByStatus(loaderData: LoaderData) { + if ( + loaderData.organizationTemplate.status === "PENDING" || + loaderData.organizationTemplate.status === "CREATED" + ) { + return ( + <> + +
    + + + Cloning the template repo into your GitHub account... + +
    + + + + ); + } + + return ; +} + +function OrganizationTemplateReady(loaderData: LoaderData) { + const githubConfigured = ( + + ); + + return ( + <> + {loaderData.organizationTemplate.status === "READY_TO_DEPLOY" ? ( + <> + + {githubConfigured} +
    + + + {loaderData.organizationTemplate.template.title} template is ready + and waiting to deploy + +
    + + + + + + ) : ( + <> + + {githubConfigured} +
    + + + Template deployed + +
    + + + + + + )} + + ); +} + +function TemplateHeader({ + organizationTemplate, +}: { + organizationTemplate: LoaderData["organizationTemplate"]; +}) { + return ( + <> +
    + +
    + +
    + {organizationTemplate.private ? "Private repo" : "Public repo"} +
    +
    +
    + + ); +} + +function DeploySection({ + organizationTemplate, + apiKey, + workflows, + runLocalDocsHTML, +}: { + organizationTemplate: LoaderData["organizationTemplate"]; + apiKey: 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 ( + <> + setIsOpen(false)} + appear + show={isOpen} + as={Fragment} + > +
    +
    + +
    +
    + + + +
    + + Run your workflow locally + +
    +
    +
    +
    +
    +
    +
    + setIsOpen(false)} + className="mt-6 w-full" + > + Close + +
    +
    + + +
    +
    + +
    + +
    + {apiKey} + +
    +
    +
    + setIsOpen(true)}> + + Run locally + + + or + + + Deploy to Render + +
    + + ); + } else { + return ( +
    + +
    +
    + +
    +
    + ); + } +} + +function ConfiguringGithubState({ + githubAccount, + isPrivate, + repositoryName, +}: { + githubAccount: string; + isPrivate: boolean; + repositoryName: string; +}) { + return ( + +
    + + This can take up to 30 seconds +
    +
    + + + + +
    +
    + + + + +
    +

    Set the repo as private

    +
    + +
    +
    +
    +
    + + Adding Template… + +
    +
    + ); +} + +// Skeleton and completed states +export function ConnectedToGithub({ templateId }: { templateId?: string }) { + return ( +
    + + + GitHub connected 🔌 + {templateId && ( + + add another connection + + )} + +
    + ); +} + +function GitHubConfigured({ + githubAccount, + isPrivate, + repositoryName, +}: { + githubAccount: string; + isPrivate: boolean; + repositoryName: string; +}) { + return ( +
    + + + GitHub repository created 🚀 + + +
    +
    + + + + +
    +
    + + + + +
    +

    Repo

    +
    + +
    +
    +
    +
    +
    + ); +} + +export function DeployBlankState() { + return ( +
    + + + Deploy + + + +
    + +
    +
    + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx deleted file mode 100644 index 7e539f49a51..00000000000 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx +++ /dev/null @@ -1,387 +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 { getIntegrationMetadatas } from "~/models/integrations.server"; -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()); - } - - const providers = getIntegrationMetadatas(false); - - return typedjson({ providers }); -}; - -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 ( - - Create a new workflow -
    - -
    - - Install the Trigger.dev package - - -
    -
    - -
    - -
    - - Create your workflow - - - Start from an example - Start from scratch - - - - {/* Example projects tabs */} - -
    - - {exampleProjects.map((project) => { - return ( - - {project.icon} - {project.name} - - ); - })} - -
    - {/* Example projects content */} - - {exampleProjects.map((project) => { - return ( - -
    -
    - {project.icon} - - {project.title} - -
    - - {project.description} - - - Install these additional API integration packages: - - - - Copy this example code into your project. Your API - key has already been inserted. - - -
    -
    - ); - })} -
    -
    -
    - - - {/* From scratch projects titles */} -
    - - {fromScratchProjects.map((project) => { - return ( - {project.name} - ); - })} - -
    - {/* From scratch projects content */} - - {fromScratchProjects.map((project) => { - return ( - -
    - - {project.description} - -
      - {project.bulletPoint1 ? ( -
    • {project.bulletPoint1}
    • - ) : ( - "" - )} - {project.bulletPoint2 ? ( -
    • {project.bulletPoint2}
    • - ) : ( - "" - )} - {project.bulletPoint3 ? ( -
    • {project.bulletPoint3}
    • - ) : ( - "" - )} -
    - - Use this example code in your project to get - started. Or learn more about {project.name}s in - the{" "} - - docs - - . - - -
    -
    - ); - })} -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - Run your web server - - - Run your server as you typically do, e.g.{" "} - npm run dev. 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{" "} - logLevel: "info" from the code above). - - -
    -
    -
    - ); -} - -function StepNumber({ - stepNumber, - drawLine, -}: { - stepNumber: string; - drawLine?: boolean; -}) { - return ( -
    - - {stepNumber} - - {drawLine ? ( -
    - ) : ( -
    - )} -
    - ); -} - -function CheckForWorkflows() { - const fetchWorkflowCount = useTypedFetcher(); - - if (fetchWorkflowCount.state !== "idle") { - return ( - -
    - - - Waiting for your workflow to connect... - -
    - Connecting… -
    - ); - } - - if (fetchWorkflowCount.data === undefined) { - return ( - - -
    - - - Waiting for your workflow to connect… - -
    - - Check my workflow connection - -
    -
    - ); - } else { - if (fetchWorkflowCount.data.hasNewWorkflows) { - return ( -
    - -
    - - - Great, "{fetchWorkflowCount.data.newWorkflow?.title}" is - connected! - -
    - - View workflow - -
    -
    - ); - } else { - return ( - -
    - - - It doesn't seem like your workflow has connected yet. Check your - server is running and try again. - -
    - - - Check my workflow connection - - -
    - ); - } - } -} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new.tsx new file mode 100644 index 00000000000..12391cca6b8 --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new.tsx @@ -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 ( + + Create a new workflow + + + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/existingRepo.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/existingRepo.tsx new file mode 100644 index 00000000000..f198889920b --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/existingRepo.tsx @@ -0,0 +1,507 @@ +import { + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; +import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime"; +import classNames from "classnames"; +import { Dispatch, Reducer, 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 { + ExampleProject, + exampleProjects, +} 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 + >(reducer, { + step: "choose-example", + selectedProject: null, + }); + + return ( + <> +
    +
    + +
    +
    + +
    + {state.step === "choose-example" ? ( + <> +
    + + + Choose an example + + + + Browse examples to use as a starting point. (Opens in a modal) + +
    + + dispatch({ payload: project, type: "example-chosen" }) + } + /> +
    + Or start from scratch +
    + + dispatch({ payload: project, type: "example-chosen" }) + } + /> +
    +
    +
    + + ) : state.step === "install-packages" ? ( + <> + +
    + + + Install the Trigger.dev packages for this example + +
    + + + dispatch({ type: "packages-installed" })} + > + Continue + + + + ) : state.step === "copy-example-code" ? ( + <> + + + + + Copy the example code into your project + + + + Your API key has already been inserted. + + + dispatch({ type: "code-copied" })} + > + Continue + + + + ) : state.step === "import-code" ? ( + <> + <> + + + + + + Import the example code to make sure it's included + + + + If you've put the code in a standalone file (e.g. + + src/triggers.ts + + ), then you'll need to import it to make sure it runs: + + + dispatch({ type: "code-imported" })} + > + Continue + + + + + ) : state.step === "run-code" ? ( + <> + + + + + + + Lastly, run your web server + + + + Run your server as you typically do, e.g.{" "} + npm run dev. 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. + + + + + + ) : ( + <> + )} +
    + + ); +} + +function ChosenExample({ + project, + dispatch, +}: { + project: ExampleProject; + dispatch: Dispatch; +}) { + return ( +
    + + + + + dispatch({ type: "clear-choice" })}> + Change answer + +
    + ); +} + +function InstalledPackages({ + dispatch, + project, +}: { + dispatch: Dispatch; + project: ExampleProject; +}) { + return ( +
    + + + + + + dispatch({ + type: "example-chosen", + payload: project, + }) + } + > + Change answer + +
    + ); +} + +function CodeImported({ + dispatch, +}: { + dispatch: Dispatch; +}) { + return ( +
    + + + + + dispatch({ type: "code-copied" })}> + Change answer + +
    + ); +} + +function AddedCode({ dispatch }: { dispatch: Dispatch }) { + return ( +
    + + + + + dispatch({ type: "packages-installed" })}> + Change answer + +
    + ); +} + +function CheckForWorkflows() { + const fetchWorkflowCount = useTypedFetcher(); + + if (fetchWorkflowCount.state !== "idle") { + return ( +
    +
    + + + Waiting for your workflow to connect... + +
    + Connecting… +
    + ); + } + + if (fetchWorkflowCount.data === undefined) { + return ( + +
    +
    + + + Waiting for your workflow to connect… + +
    + + Check my workflow connection + +
    +
    + ); + } else { + if (fetchWorkflowCount.data.hasNewWorkflows) { + return ( +
    +
    +
    + + + Great, "{fetchWorkflowCount.data.newWorkflow?.title}" is + connected! + +
    + + View workflow + +
    +
    + ); + } else { + return ( +
    +
    + +
    + + It doesn't seem like your workflow has connected yet. + + Check your server is running and try again. +
    +
    + + + Check my workflow connection + + +
    + ); + } + } +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/index.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/index.tsx new file mode 100644 index 00000000000..b25cf52677e --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/index.tsx @@ -0,0 +1,97 @@ +import { + CloudIcon, + HomeIcon, + RocketLaunchIcon, + SunIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +import { Link } from "@remix-run/react"; +import classNames from "classnames"; +import { Fragment, 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"; + +export default function NewWorkflowStep1Page() { + return ; +} + +function Step1() { + let [isOpen, setIsOpen] = useState(false); + return ( + <> + setIsOpen(false)} + appear + show={isOpen} + as={Fragment} + > +
    +
    + +
    +
    + + + + + Cloud hosting coming soon… +
    +
    + + We're working hard to bring you a cloud hosted service. + + setIsOpen(false)} + className="mt-2 w-full" + > + Got it + +
    +
    + +
    +
    +
    +
    +
    + + + Where do you want your workflow hosted? + + +
    + + + I'll host the workflow myself + + I will deploy the code to my own servers. + + + +
    +
    +
    + + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/newRepo.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/newRepo.tsx new file mode 100644 index 00000000000..d49105ea390 --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/newRepo.tsx @@ -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(); + + return ( +
    +
    + +
    +
    + +
    +
    + + + Which template would you like to use? + + +
    +
    + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/step2.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/step2.tsx new file mode 100644 index 00000000000..ad96ff254ce --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows/new/step2.tsx @@ -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 ; +} + +function Step2() { + return ( +
    +
    + + + + I'll host the workflow myself + + + Change answer +
    +
    + + + Would you like to create a new GitHub repository? + + +
    + +
    + Easy (2 mins) +
    + + I want to create a new repo + + We'll setup a new GitHub repository and install your template. + + + + + I want to use an existing repo + + I want my workflow to be alongside my existing code. + + +
    +
    +
    +
    + ); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/apps.github.ts b/apps/webapp/app/routes/__app/orgs/$organizationSlug/apps.github.ts new file mode 100644 index 00000000000..a12a6345650 --- /dev/null +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/apps.github.ts @@ -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}`); +} diff --git a/apps/webapp/app/routes/__public.tsx b/apps/webapp/app/routes/__public.tsx new file mode 100644 index 00000000000..c8df332d917 --- /dev/null +++ b/apps/webapp/app/routes/__public.tsx @@ -0,0 +1,53 @@ +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(); + + const LayoutComponent = loaderData.userId ? AppLayout : PublicAppLayout; + + return ( + + + {loaderData.userId ?
    : } + + + +