HTTP endpoints, HTTP triggers and new sidebar (#720)

* Setup basic storybook page

* storybook preview pages don’t have padding

* Added a storybook story for the side menu

* WIP on a new side menu component

* New variant for the side menu button style

* Side menu items can now recieve a badge ‘count’

* Storybook side menu update

* Added a new font size

* you can now set the spacing between an icon and the text

* Moving the error and count badge to the right side of the button

* Added a menu sub item button style

* added sub items to the menu and all the rest of the items

* Proper background class used

* Added a side menu header and main org header

* Removed the no-mobile blocker overlay so you can use the site on a phone

* Better section name

* Created a cusom popover trigger that can take anything as children. Also the popover titles can now take a variant

* Added a popover for the user avatar menu item

* Removed the tool tip

* Arrow Popover can no also go full width

* neater conditiona;

* Removed unnessary div. And error icon can display an error message

* menu item buttons can take a variant

* Added placeholder items for the org dropdown

* add a max height to the org menu popover and handle scrollbars

* Added a border if the menu scrolls when you have lots of project items

* warning badges display tooltips if you give them some error text

* Removed the sticky header so the scrollbar looks better

* Fixed a layout issue from removing the sticky header

* Better spacing between lists

* Popover can take any react component icon and an icon classname

* Side menu headers now have a dropdown by clicking the ellipsis icon

* Side menu items can take an icon colour

* Updated styling for the dropdown Popover menus

* Installed Tabler icons

* Updated some icons to use Tabler

* Started defining the HttpTrigger class

* RequestFilterSchema is part of core

* Use the RequestFilterSchema from core

* EventSpecificationExample now derived from schema

* Need to define the class like EventTrigger

* Catalog reference that uses the httpTrigger

* The logic for the httpTrigger is in the separate file, just call it from the client using defineHttpTrigger

* Basic HttpTrigger

* Import fixes

* Transforming httpTrigger to the format we want

* Initial httpTrigger types working

* Added HTTP generic type

* Added some notes

* Rejigged some things to support verifying payloads

* Use new security function for verifying

* Added truncating text support if side menu names get long

* Renamed some stuff

* Removed test copy

* HttpEndpoin that isn’t a trigger

* Renamed hostname to source

* Created HttpEndpoint as a top-level concept, it produces triggers using onRequest

* Started working on registration of the httpEndpoints

* Removed the new TriggerMetadata for now, http will use static

* Added PayloadType to EventRecord. Default is JSON.

* The payload from an HTTPEndpoint is now a Request

* Initial TriggerHttpEndpoint migrations

* Initial server side work

* Migration to add shortcode to RuntimeEnvironment

* Added TriggerHttpEndpointEnvironment table

* Made the httptrigger catalog example runnable

* Progress creating/updating httpEndpoints

* HTTPEndpointEnvironment updating now works

* Added a note about EventRecord HttpEndpoint relationship

* When creating environments, create a shortcode

* Fix for secret not being retrieved correctly

* Request filtering

* Add DOM types to Core

* Early work dealing with a webhook arriving

* Added some todo notes to discuss tomorrow

* Added relations to the endpoint so we can call the user’s server

* Basic handling of immediate responses

* The immediate responses are now going back to the webhook provider

* Handling is now generating events and responding where relevant. Not yet matching filters and doing verification

* Added source to TriggerHttpEndpointEnvironment

* The event filter is now optional

* The source is passed through from the httpEndpointEnv

* Added optional payloadType when ingesting an event

* Added tabler

* Updated @remix-run/testing usage for remix 2

* Fixed org menu header width and added org and project types

* Added custom icons for Runs and Endpoints

* Updated the menu icons

* storybook side menu shows static project and org data

* Updated side menu

* Plumbed in paths

* Updated eventFilter to deal with undefined/null payloads

* Started work on filtering, but it’s not working

* Strict null

* async filtering it working

* Added environment shortcodes to the seedCloud script

* Don’t pass the secret back, added a verifyPayload function that gets called before runs start

* Fix for skipping triggering runs

* The verify callback is now passed through to the trigger so it can be called

* If verifying fails, have a reason which we can show to the user

* Don’t verify if it’s a test run

* Fixed the log in the example code

* Added loader and action to storybook preview

* Fix for svg attribute name issues in EndpointIcon

* Started work on the side menu

* This logic has moved to a separate file, in a different branch

* Don’t get the environments when getting orgs

* Lots of fixes for the new SideMenu

* Refactored some org/project things to fit the new structure better

* Some progress restructuring the app for the new sidebar

* More changes to data loading

* Deleted some old pages. The redirecting is working if you’re on the root but logged in

* Basic styling for the account page. We need a back button

* Fixes for the integrations pages

* Breadcrumb implemented

* Add a border to the bottom of the breadcrumb

* Feedback is working, not in Storybook

* Removed expandSidebar from the Handle

* Org level breadcrumbs

* Getting the project in the org route now works properly

* orgslug route before using the presenter

* More UI improvements

* Removed commented out line

* Use presenters for org and root

* Removed forceActive

* Split the project selector and user menu out

* The side menu items have correct active states

* Fixed the scrolling

* Put the org name in

* Improved the admin features

* Created the HTTP Endpoints list page

* Work on the HTTP endpoint page

* WIP on the HTTP Endpoint page

* Fix for response filter not showing

* Change page URLs to “http-endpoints” (from httpendpoints)

* Slugify the HTTP endpoint id

* Added todos for EventRecord associations

* Removed the Job/Trigger tab for now

* First draft of the help panels

* Rejigged the HTTP endpoint page so it splits out the deployed and not deployed environments

* Removed irrelevant columns

* Added a link to the deployment guide

* Removed the background image from the frameworks pages

Also…
- reordered the frameworks so supported ones come first in the list
- added logos to 3 framework pages that were missing them
- removed lots of un-used imports

* Fixed an issue where the breadcrumb could be hidden on small screens so broken the pages

The breadcrumb is always visible now

* Renamed ‘Onboarding’ to ‘Framework setup’

* Added a new http-endpoint icon to the side menu

* Side menu now has a nice looking scrollbar

* Scrollbar on the usage and billing page updated to the nice style

* Icon and colour updates to the side bar menu items

* Small copy improvement

* Fixed bug with the height of the org menu dropdown

* Page breadcrumbs now don’t include duplicate path links

* renamed uiBorder to ui-border

* Remove the padding from the small menu item if it’s used as a button

* Made the paragraph text a bit smaller

* EventRecord has relationships with TriggerHttpEndpoint/TriggerHttpEndpointEnvironment

* Improved the presenter used by the side menu. Also added the HTTP Endpoint badge count to the sidebar

* The dropdown org menu has the same badge as the side menu

* Improved the Projects page

* Fixed button style miss-match in the jobs table dropdown

* Made the popover dialogs support Headers and improved the regenerate API key popover

* Made the active status of a job more subtle

* Allow the Job to have a link on its Trigger

* Simplified loader because we ditched the Jobs menu. Also showing no runs help panel now

* Fixed type errors

* Pages that are org level can scroll if the content gets too long

* Fixed a redirect bug so you now redirect to the org page

* Updated the shortcode migration, to add additional letters to the end

* Removed the nav bar from the welcome onboarding page

* Only show the countBadge if the number is > 0

* Jobs icon now has a default colour the same as the nav menu

* Capital I for integration

* Clone the request before calling verify

* Separate the checks out so they can be easily logged

* Make the failure reason optional

* Made the verification error look better, if it’s provided

* Working WhatsApp example

* Can set the immediateResponseFilter to null in an update

* Deal with a null immediateResponseFilter

* Cal.com webhook working

* Header height reduced slightly

* Fixed button padding

* Removed un-used imports

* http endpoint table displays a message if you have no entries in the table

* Removed some duplicate text and swapped primary buttons for secondary style

* All ‘HTTP Endpoints’ now ‘HTTP endpoints’

* You have to use a RequestFilter when using HTTP Trigger

* Synchronous eventDispatcher filtering again. Better content-type check

* Stripe example

* Side menu “Usage & Billing” is just “Usage” if self-hosting

* Fix for filtering not working

* Removed filter log

* Fix for jobs being optional and null

* Removed old nav bar from the invite page

* Deleted old nav bar component

* import cleanup

* NoTask message only displays if the run has status “SUCCESS”

* Added a global page loading indicator

* Added documentation buttons to the most important pages

* Added GitHub

* Use “verifyRequestSignature” helpr

* Removed todo

* Fixed the layout of the Rerun Job popover

* Jobs table has margin bottom so it sits off the bottom of the page

* Small improvement to the search bar layout on the integration page

* HTTP endpoint and Trigger concept docs

* Reference docs and JSDocs for HTTP endpoint

* SDK reference docs for HttpEndpoint

* Added an HttpTrigger SDK reference, which mainly just point you elsewhere

* Improved the copy in the help panels

* Removed unused code

* Improved HTTP endpoint comment about setting the secret

* Delete unused asyncFilter

* Fixed the delete modal not having a title

* Always try get the arrayBuffer for an HTTP request, catch and use undefined as the default

* Add extra method to invokeTrigger

* Use a transaction for the secret store

* verifyRequestSignature docs page and JSDocs

* Added verifyRequestSignature docs page to nav

* Warn if they use the same HttpEndpoint id twice

* Added another job using the same HttpEndpoint

* Renamed the api route to http-endpoints from httpendpoints

* Debug bar for VSCode is now in the commandCenter

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
This commit is contained in:
Matt Aitken
2023-11-06 15:32:40 +00:00
committed by GitHub
parent 10e935b864
commit 9c6838e5c8
177 changed files with 5623 additions and 2257 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"deno.enablePaths": ["references/deno-reference"]
"deno.enablePaths": ["references/deno-reference"],
"debug.toolBarLocation": "commandCenter"
}
+3
View File
@@ -23,12 +23,15 @@ const preview: Preview = {
},
],
},
layout: "fullscreen",
},
decorators: [
(Story) => {
const RemixStub = createRemixStub([
{
path: "/*",
action: () => ({ redirect: "/" }),
loader: () => ({ redirect: "/" }),
Component: Story,
},
]);
@@ -0,0 +1,36 @@
export function EndpointIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.24336 5.11182C8.75159 4.60359 9.57559 4.60359 10.0838 5.11182L18.8882 13.9162C19.3964 14.4244 19.3964 15.2484 18.8882 15.7566L17.1469 17.4979C16.453 18.2196 15.6222 18.7956 14.7031 19.1922C13.7839 19.5889 12.7949 19.7982 11.7939 19.8081C10.7929 19.8179 9.79992 19.628 8.87318 19.2494C7.94643 18.8708 7.10449 18.3113 6.39662 17.6034C5.68875 16.8955 5.12916 16.0536 4.7506 15.1268C4.37205 14.2001 4.18212 13.2071 4.19194 12.2061C4.20176 11.2051 4.41112 10.2161 4.80779 9.29691C5.20445 8.37777 5.78044 7.54697 6.50206 6.85312L8.24336 5.11182Z"
fill="currentColor"
/>
<path
d="M3.32398 20.676L7.11974 16.8803"
stroke="currentColor"
strokeWidth="2.16549"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.2535 3.32398L11.4578 7.11974"
stroke="currentColor"
strokeWidth="2.16549"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20.676 8.74649L16.8803 12.5423"
stroke="currentColor"
strokeWidth="2.16549"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,5 @@
import { LogoIcon } from "~/components/LogoIcon";
export function IntegrationIcon() {
return <LogoIcon className="h-3.5 w-3.5 flex-none pb-0.5" />;
}
+18
View File
@@ -0,0 +1,18 @@
export function RunsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.2964 3.66962C11.97 3.83662 11.6961 4.09048 11.5047 4.40323C11.3134 4.71598 11.2121 5.07547 11.212 5.44211L11.211 8.0162L9.22047 8.0172C8.95637 8.0172 8.70309 8.12211 8.51635 8.30886C8.3296 8.4956 8.22469 8.74888 8.22469 9.01298V14.9877L8.23166 15.1042C8.26019 15.3464 8.37664 15.5697 8.5589 15.7318C8.74116 15.8939 8.97657 15.9834 9.22047 15.9834L11.211 15.9825L11.212 18.5585C11.2121 18.9524 11.329 19.3373 11.5478 19.6648C11.7666 19.9922 12.0777 20.2474 12.4415 20.3981C12.8054 20.5488 13.2057 20.5882 13.592 20.5114C13.9783 20.4346 14.3331 20.245 14.6116 19.9666L21.1698 13.4084C21.5432 13.0349 21.7529 12.5284 21.7529 12.0003C21.7529 11.4722 21.5432 10.9658 21.1698 10.5923L14.6116 4.03408C14.3331 3.75539 13.9782 3.56558 13.5918 3.48865C13.2054 3.41172 12.8048 3.45114 12.4408 3.60191L12.2964 3.66962Z"
fill="currentColor"
/>
<path
d="M3.24578 8.0172C3.48968 8.01723 3.72509 8.10677 3.90735 8.26885C4.08961 8.43092 4.20606 8.65425 4.23459 8.89647L4.24156 9.01298V14.9877C4.24128 15.2415 4.1441 15.4856 3.96986 15.6701C3.79563 15.8547 3.55751 15.9658 3.30414 15.9806C3.05077 15.9955 2.80128 15.9131 2.60666 15.7502C2.41203 15.5873 2.28695 15.3562 2.25697 15.1042L2.25 14.9877V9.01298C2.25 8.74888 2.35491 8.4956 2.54166 8.30885C2.7284 8.12211 2.98168 8.0172 3.24578 8.0172Z"
fill="currentColor"
/>
<path
d="M6.23312 8.0172C6.47702 8.01723 6.71243 8.10677 6.89469 8.26885C7.07696 8.43092 7.1934 8.65425 7.22193 8.89647L7.2289 9.01298V14.9877C7.22862 15.2415 7.13144 15.4856 6.95721 15.6701C6.78298 15.8547 6.54485 15.9658 6.29148 15.9806C6.03811 15.9955 5.78863 15.9131 5.594 15.7502C5.39937 15.5873 5.27429 15.3562 5.24431 15.1042L5.23734 14.9877V9.01298C5.23734 8.74888 5.34226 8.4956 5.529 8.30885C5.71575 8.12211 5.96903 8.0172 6.23312 8.0172Z"
fill="currentColor"
/>
</svg>
);
}
@@ -1,24 +1,18 @@
import { Form } from "@remix-run/react";
import { Paragraph } from "./primitives/Paragraph";
import { Button } from "./primitives/Buttons";
import { UserMinusIcon } from "@heroicons/react/20/solid";
export function ImpersonationBanner({ impersonationId }: { impersonationId: string }) {
export function ImpersonationBanner() {
return (
<div className="grid h-8 grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 px-4 text-center">
<span></span>
<Paragraph variant="small">
You are impersonating <span className="font-semibold">{impersonationId}</span>
</Paragraph>
<Form
action="/resources/impersonation"
method="delete"
reloadDocument
className="justify-self-end"
>
<div className="w-full">
<Form action="/resources/impersonation" method="delete" reloadDocument className="w-full">
<Button
type="submit"
className="!text-slate-700 transition hover:!text-slate-900"
variant="secondary/small"
variant="small-menu-item"
LeadingIcon={UserMinusIcon}
fullWidth
textAlignLeft
>
Stop impersonating
</Button>
@@ -1,13 +1,14 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { useFetcher } from "@remix-run/react";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { generateTwoRandomWords } from "~/utils/randomWords";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogTrigger } from "~/components/primitives/Dialog";
import { Header1, Header2 } from "../primitives/Headers";
import { Header1 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { useState } from "react";
import { useFetcher } from "@remix-run/react";
import { generateTwoRandomWords } from "~/utils/randomWords";
type ModalProps = {
id: string;
@@ -25,11 +26,16 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="tertiary/small" leadingIconClassName="text-dimmed" LeadingIcon="key">
<Button
variant="tertiary/small"
leadingIconClassName="text-dimmed"
LeadingIcon={ArrowPathIcon}
>
Regenerate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
<RegenerateApiKeyModalContent
id={id}
title={title}
@@ -41,7 +47,7 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
);
}
const RegenerateApiKeyModalContent = ({ id, title, randomWord, closeModal }: ModalContentProps) => {
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
const [confirmationText, setConfirmationText] = useState("");
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
@@ -52,55 +58,47 @@ const RegenerateApiKeyModalContent = ({ id, title, randomWord, closeModal }: Mod
}
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="flex justify-center">
<Header1>{title} Environment</Header1>
<div className="flex w-full flex-col items-center gap-y-5 pb-4">
<div className="mt-3 flex gap-x-3 rounded-md border border-ui-border py-4 pl-3 pr-5">
<ExclamationTriangleIcon className="relative top-1 h-6 w-6 min-w-[2rem] text-amber-500" />
<Paragraph>
Regenerating the keys for this environment will temporarily break any live Jobs in the
<span className="text-bright"> {title} Environment</span> until the new API keys are set
in the relevant environment variables.
</Paragraph>
</div>
<Header2 className="rounded border border-rose-500 bg-rose-500/10 px-3.5 py-2 text-center text-bright">
Are you sure you want to regenerate the keys <br />
for this environment?
</Header2>
<Paragraph variant="small" className="px-6 text-center">
<>
This will temporarily break any live jobs in the
<span className="strong text-bright"> {title} Environment</span> until the new API keys
are set in the relevant environment variables.
</>
</Paragraph>
<fetcher.Form
method="post"
action={`/resources/environments/${id}/regenerate-api-key`}
className="mt-2 flex w-full flex-col items-center gap-2"
>
<Paragraph variant="small" className="font-medium">
To confirm, please enter the text:{" "}
<span className="font-bold text-bright">{randomWord}</span>
</Paragraph>
<Input
type="text"
placeholder="Confirmation Text"
fullWidth={false}
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
/>
<Button
variant="primary/large"
fullWidth
className="mt-4"
disabled={confirmationText !== randomWord}
>
{isSubmitting ? (
<Spinner color="white" />
) : (
<>
<NamedIcon
name="key"
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
/>
Regenerate API Keys
</>
)}
</Button>
<div className="mb-4 flex items-center gap-x-2">
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
<Paragraph
variant="small"
className="select-all rounded-md border border-ui-border bg-slate-900 px-2 py-1 font-mono text-bright"
>
{randomWord}
</Paragraph>
</div>
<div className="flex items-center">
<Input
type="text"
placeholder="Confirmation text"
fullWidth
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
className="rounded-r-none"
variant="large"
/>
<Button
variant="primary/large"
disabled={confirmationText !== randomWord}
className="rounded-l-none"
>
{isSubmitting ? <Spinner color="white" /> : <>Regenerate</>}
</Button>
</div>
</fetcher.Form>
</div>
);
@@ -3,11 +3,10 @@ import { GitHubDarkIcon } from "@trigger.dev/companyicons";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { projectSetupPath } from "~/utils/pathBuilder";
import { PageGradient } from "../PageGradient";
import { LinkButton } from "../primitives/Buttons";
import { Header1 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
export type FrameworkComingSoonProps = {
frameworkName: string;
githubIssueUrl: string;
@@ -25,43 +24,41 @@ export function FrameworkComingSoon({
const project = useProject();
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="mb-6 grid place-items-center pb-6">{children}</div>
<div className="flex items-center justify-between">
<Header1 spacing>{frameworkName} is coming soon!</Header1>
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
</div>
<Paragraph spacing className="border-b border-slate-800 pb-4">
We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with
the GitHub issue or contribute and help us bring it to Trigger.dev faster.
</Paragraph>
<a
href={githubIssueUrl}
target="_blank"
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-6 grid place-items-center pb-6">{children}</div>
<div className="flex items-center justify-between">
<Header1 spacing>{frameworkName} is coming soon!</Header1>
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
<h2 className="text-2xl font-semibold text-black">
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
support for {frameworkName}
</h2>
<div className="mt-4 flex items-center gap-1.5">
<GitHubDarkIcon className="h-4 w-4" />
<Paragraph variant="small">View on GitHub</Paragraph>
<NamedIcon
name="arrow-right"
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
/>
</div>
</a>
Choose a different framework
</LinkButton>
</div>
</PageGradient>
<Paragraph spacing className="border-b border-slate-800 pb-4">
We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with
the GitHub issue or contribute and help us bring it to Trigger.dev faster.
</Paragraph>
<a
href={githubIssueUrl}
target="_blank"
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
>
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
<h2 className="text-2xl font-semibold text-black">
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
support for {frameworkName}
</h2>
<div className="mt-4 flex items-center gap-1.5">
<GitHubDarkIcon className="h-4 w-4" />
<Paragraph variant="small">View on GitHub</Paragraph>
<NamedIcon
name="arrow-right"
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
/>
</div>
</a>
</div>
);
}
@@ -24,7 +24,6 @@ import {
projectSetupSvelteKitPath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { PageGradient } from "../PageGradient";
import { Button } from "../primitives/Buttons";
import { Header1 } from "../primitives/Headers";
@@ -33,51 +32,49 @@ export function FrameworkSelector() {
const project = useProject();
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<Header1 spacing>Choose a framework to get started</Header1>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
Request a framework
</Button>
}
defaultValue="feature"
/>
</div>
<div className="grid grid-cols-3 place-items-center gap-2">
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
<NextjsLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
<ExpressLogo className="w-36" />
</FrameworkLink>
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
<RemixLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
<AstroLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
<RedwoodLogo className="w-44" />
</FrameworkLink>
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
<NuxtLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)} supported>
<SvelteKitLogo className="w-44" />
</FrameworkLink>
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
<FastifyLogo className="w-36" />
</FrameworkLink>
<FrameworkLink to={projectSetupNestjsPath(organization, project)} supported>
<NestjsLogo className="w-36" />
</FrameworkLink>
</div>
<div className="mx-auto max-w-3xl pt-28">
<div className="flex items-center justify-between">
<Header1 spacing>Choose a framework to get started</Header1>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
Request a framework
</Button>
}
defaultValue="feature"
/>
</div>
</PageGradient>
<div className="grid grid-cols-3 place-items-center gap-2">
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
<NextjsLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
<ExpressLogo className="w-36" />
</FrameworkLink>
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
<RemixLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
<AstroLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)} supported>
<SvelteKitLogo className="w-44" />
</FrameworkLink>
<FrameworkLink to={projectSetupNestjsPath(organization, project)} supported>
<NestjsLogo className="w-36" />
</FrameworkLink>
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
<FastifyLogo className="w-36" />
</FrameworkLink>
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
<NuxtLogo className="w-32" />
</FrameworkLink>
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
<RedwoodLogo className="w-44" />
</FrameworkLink>
</div>
</div>
);
}
@@ -4,8 +4,7 @@ import { StepNumber } from "~/components/primitives/StepNumber";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { IntegrationIcon } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route";
import { jobTestPath } from "~/utils/pathBuilder";
import { docsPath, jobTestPath } from "~/utils/pathBuilder";
import { CodeBlock } from "../code/CodeBlock";
import { InlineCode } from "../code/InlineCode";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
@@ -21,6 +20,7 @@ import selectEnvironment from "./select-environment.png";
import selectExample from "./select-example.png";
import { StepContentContainer } from "../StepContentContainer";
import { TriggerDevCommand } from "../SetupCommands";
import { IntegrationIcon } from "~/assets/icons/IntegrationIcon";
export function HowToRunYourJob() {
const organization = useOrganization();
@@ -294,3 +294,140 @@ export function HowToUseApiKeysAndEndpoints() {
</>
);
}
export function WhatAreHttpEndpoints() {
return (
<>
<Paragraph spacing>
HTTP endpoints allow you to trigger your Jobs from any webhooks. They require a bit more
work than using <TextLink to={docsPath("integrations/introduction")}>Integrations</TextLink>{" "}
but allow you to connect to any API.
</Paragraph>
<Header2 spacing>Getting started</Header2>
<Paragraph spacing>
You need to define the HTTP endpoint in your code. To do this you use{" "}
<InlineCode>client.defineHttpEndpoint()</InlineCode>. This will create an HTTP endpoint.
</Paragraph>
<Paragraph spacing>
Then you can create a Trigger from this by calling <InlineCode>.onRequest()</InlineCode> on
the created HTTP endpoint.
</Paragraph>
<Callout variant="docs" to={docsPath("documentation/concepts/triggers/http-endpoints")}>
Read the HTTP endpoints guide to learn more.
</Callout>
<Header2 spacing className="mt-4">
An example: cal.com
</Header2>
<CodeBlock
code={`//create an HTTP endpoint
const caldotcom = client.defineHttpEndpoint({
id: "cal.com",
source: "cal.com",
icon: "caldotcom",
verify: async (request) => {
//this helper function makes verifying most webhooks easy
return await verifyRequestSignature({
request,
headerName: "X-Cal-Signature-256",
secret: process.env.CALDOTCOM_SECRET!,
algorithm: "sha256",
});
},
});
client.defineJob({
id: "http-caldotcom",
name: "HTTP Cal.com",
version: "1.0.0",
enabled: true,
//create a Trigger from the HTTP endpoint above. The filter is optional.
trigger: caldotcom.onRequest({ filter: { body: { triggerEvent: ["BOOKING_CANCELLED"] } } }),
run: async (request, io, ctx) => {
//note that when using HTTP endpoints, the first parameter is the request
//you need to get the body, usually it will be json so you do:
const body = await request.json();
await io.logger.info("Body", body);
},
});`}
/>
</>
);
}
export function HowToConnectHttpEndpoint() {
return (
<>
<Header2 spacing>Setting up your webhook</Header2>
<Paragraph spacing>
To start receiving data you need to enter the Endpoint URL and secret into the API service
you want to receive webhooks from.
</Paragraph>
<StepNumber stepNumber="1" title={<>Go to the relevant API dashboard</>} />
<StepContentContainer>
<Paragraph spacing>
For example, if you want to receive webhooks from Cal.com then you should login to your
Cal.com account and go to their Settings/Developer/Webhooks page.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title={<>Copy the Webhook URL and Secret</>} />
<StepContentContainer>
<Paragraph spacing>
A unique Webhook URL is created for each environment (Dev, Staging, and Prod). Jobs will
only be triggered from the relevant environment.
</Paragraph>
<Paragraph spacing>
Copy the relevant Endpoint URL and secret from the table opposite and paste it into the
correct place in the API dashboard you located in the previous step.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="3" title={<>Add the Secret to your Environment variables</>} />
<StepContentContainer>
<Paragraph spacing>
You should also add the Secret to the Environment variables in your code and where you're
deploying. Usually in Node this means adding it to the .env file.
</Paragraph>
<Paragraph spacing>
Use the secret in the <InlineCode>verify()</InlineCode> function of HTTP Endpoint. This
ensures that someone can't just send a request to your Endpoint and trigger a Job.
Different APIs do this verification in different ways a common way is to have a header
that has a hash of the payload and secret. Refer to the API's documentation for more
information.
</Paragraph>
</StepContentContainer>
<Header2 spacing>Triggering runs</Header2>
<StepNumber stepNumber="1" title="Ensure you're using the HTTP Endpoint in your code" />
<StepContentContainer>
<Paragraph spacing>
In your code, you should use the <InlineCode>.onRequest()</InlineCode> function in a Job
Trigger. You can filter so only data that matches your criteria triggers the Job.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Make sure your code is deployed (for Staging and Prod)" />
<StepContentContainer>
<Paragraph spacing>
If you're using the Staging or Prod environment, you need to make sure your code is
deployed. Deploy like you normally would {" "}
<TextLink to={docsPath("documentation/guides/deployment")}>
read our deployment guide
</TextLink>
.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="3" title="Perform an action that sends a webhook" />
<StepContentContainer>
<Paragraph spacing>
Now you need to actually perform an action on that third-party service that triggers the
webhook you've subscribed to. For example, add a new meeting using Cal.com.
</Paragraph>
</StepContentContainer>
<Callout variant="docs" to={docsPath("documentation/concepts/triggers/http-endpoints")}>
Read the HTTP endpoints guide to learn more.
</Callout>
</>
);
}
@@ -40,7 +40,7 @@ export function DeleteJobDialogContent({
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="flex flex-col items-center justify-center gap-y-2">
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
<Header1>{title}</Header1>
<Paragraph variant="small">ID: {slug}</Paragraph>
</div>
+10 -11
View File
@@ -1,7 +1,7 @@
import { ProjectJob } from "~/hooks/useJobs";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { JobRunStatus } from "~/models/job.server";
import { ProjectJob } from "~/presenters/JobListPresenter.server";
import { jobPath, jobTestPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { DateTime } from "../primitives/DateTime";
@@ -30,7 +30,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
const project = useProject();
return (
<Table>
<Table containerClassName="mb-4">
<TableHeader>
<TableRow>
<TableHeaderCell>Job</TableHeaderCell>
@@ -160,19 +160,18 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<Dialog>
<DialogTrigger asChild>
<Button variant="menu-item" LeadingIcon="trash-can">
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs">
Delete Job
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DeleteJobDialogContent
id={job.id}
title={job.title}
slug={job.slug}
environments={job.environments}
/>
</DialogHeader>
<DialogHeader>Delete Job</DialogHeader>
<DeleteJobDialogContent
id={job.id}
title={job.title}
slug={job.slug}
environments={job.environments}
/>
</DialogContent>
</Dialog>
</TableCellMenu>
@@ -11,7 +11,7 @@ export function AppContainer({
}) {
return (
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
<div className={cn("grid h-full w-full grid-rows-[2.75rem_auto]")}>{children}</div>
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
</BackgroundGradient>
);
}
@@ -47,7 +47,11 @@ export function PageContainer({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("grid h-full grid-rows-[auto_1fr]", className)}>{children}</div>;
return (
<div className={cn("grid h-full grid-rows-[auto_1fr] overflow-hidden", className)}>
{children}
</div>
);
}
export function PageBody({
@@ -58,7 +62,15 @@ export function PageBody({
scrollable?: boolean;
}) {
return (
<div className={cn(scrollable ? "overflow-y-auto p-4 " : "overflow-hidden")}>{children}</div>
<div
className={cn(
scrollable
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
: "overflow-hidden"
)}
>
{children}
</div>
);
}
@@ -68,8 +80,8 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) {
export function MainCenteredContainer({ children }: { children: React.ReactNode }) {
return (
<div className="h-full w-full">
<div className="mx-auto mt-[30vh] max-w-xs">{children}</div>
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mx-auto mt-[25vh] max-w-xs overflow-y-auto">{children}</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { UIMatch, useMatches } from "@remix-run/react";
import { Fragment, ReactNode } from "react";
import { BreadcrumbIcon } from "../primitives/BreadcrumbIcon";
import { Handle } from "~/utils/handle";
import { LinkButton } from "../primitives/Buttons";
export type BreadcrumbItem = (match: UIMatch, allMatches: UIMatch[]) => ReactNode;
@@ -9,19 +10,26 @@ export function Breadcrumb() {
const matches = useMatches() as UIMatch<unknown, Handle>[];
return (
<div className="hidden items-center md:flex">
{matches.map((match) => {
if (!match.handle || !match.handle.breadcrumb) return null;
<div className="flex items-center px-1">
{matches
.filter((b) => b.handle && b.handle.breadcrumb)
.map((match, index) => {
const breadcrumb = match.handle.breadcrumb as BreadcrumbItem;
const breadcrumb = match.handle.breadcrumb as BreadcrumbItem;
return (
<Fragment key={match.id}>
<BreadcrumbIcon />
{breadcrumb(match, matches)}
</Fragment>
);
})}
return (
<Fragment key={match.id}>
{index !== 0 && <BreadcrumbIcon />} {breadcrumb(match, matches)}
</Fragment>
);
})}
</div>
);
}
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
return (
<LinkButton to={to} variant="tertiary/small">
{title}
</LinkButton>
);
}
@@ -1,65 +0,0 @@
import { Link, UIMatch } from "@remix-run/react";
import { useState } from "react";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useJobs } from "~/hooks/useJobs";
import { cn } from "~/utils/cn";
import { jobPath } from "~/utils/pathBuilder";
import { LabelValueStack } from "../primitives/LabelValueStack";
import { NamedIcon } from "../primitives/NamedIcon";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverSectionHeader,
} from "../primitives/Popover";
export function JobsMenu({ matches }: { matches: UIMatch[] }) {
const [isOpen, setIsOpen] = useState(false);
const organization = useOrganization(matches);
const project = useProject(matches);
const projectJobs = useJobs(matches);
const currentJob = useJob(matches);
return (
<>
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverArrowTrigger isOpen={isOpen}>
{currentJob?.title ?? "Select a job"}
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
collisionPadding={20}
>
<PopoverSectionHeader title="Jobs" />
<div className="flex flex-col gap-1 p-1">
{projectJobs.map((job) => {
const isSelected = job.id === currentJob?.id;
return (
<Link
key={job.id}
to={jobPath(organization, project, job)}
className={cn(
"flex w-full items-center gap-2 rounded-md p-2 transition hover:bg-slate-800",
isSelected && "bg-slate-750 group-hover:bg-slate-750"
)}
>
<NamedIcon name={job.event.icon} className="h-8 w-8" />
<LabelValueStack
label={job.title}
value={job.slug}
variant="primary"
className="w-full"
/>
{isSelected && <NamedIcon name="check" className="mr-1 h-6 w-6" />}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</>
);
}
@@ -1,47 +0,0 @@
import { Popover, Transition } from "@headlessui/react";
import { BookOpenIcon, ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import { Fragment } from "react";
import { cn } from "~/utils/cn";
import { Feedback } from "../Feedback";
import { LogoIcon } from "../LogoIcon";
import { Button, LinkButton } from "../primitives/Buttons";
import { Breadcrumb } from "./Breadcrumb";
import { docsRoot } from "~/utils/pathBuilder";
export function NavBar() {
return (
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-uiBorder py-1 pl-1 pr-2.5">
<div className="flex gap-0.5">
<Link to="/" className="p-2">
<LogoIcon className="h-5 w-5" />
</Link>
<Breadcrumb />
</div>
<div className="hidden items-center gap-2 sm:flex">
<LinkButton to={docsRoot()} variant="secondary/small" LeadingIcon={BookOpenIcon}>
Documentation
</LinkButton>
<Feedback
button={
<Button
variant="secondary/small"
LeadingIcon={ChatBubbleLeftRightIcon}
shortcut={{ key: "f" }}
>
Help & feedback
</Button>
}
/>
</div>
</div>
);
}
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
return (
<LinkButton to={to} variant="tertiary/small">
{title}
</LinkButton>
);
}
@@ -0,0 +1,9 @@
import { useNavigation } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
export function PageNavigationIndicator() {
const navigation = useNavigation();
if (navigation.state === "loading") {
return <Spinner color="muted" className="h-4 w-4" />;
}
}
@@ -1,206 +0,0 @@
import { useMatches } from "@remix-run/react";
import { motion } from "framer-motion";
import { useOptionalJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
accountPath,
organizationBillingPath,
organizationTeamPath,
projectEnvironmentsPath,
projectIntegrationsPath,
projectPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { NavLinkButton } from "../primitives/Buttons";
import { NamedIcon, type IconNames } from "../primitives/NamedIcon";
import { SimpleTooltip } from "../primitives/Tooltip";
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
return <div className="flex h-full w-full justify-stretch overflow-hidden">{children}</div>;
}
const expandedWith = "14rem";
const collapsedWith = "2.81rem";
const menuVariants = {
expanded: {
minWidth: expandedWith,
width: expandedWith,
},
collapsed: {
minWidth: collapsedWith,
width: collapsedWith,
},
};
export function ProjectSideMenu() {
const organization = useOrganization();
const project = useProject();
const matches = useMatches();
//the deepest route `handle` determines if the menu is expanded
const deepestMatch = matches.at(-1);
const handle = deepestMatch?.handle as Handle;
const isCollapsed = handle?.expandSidebar ? !handle.expandSidebar : true;
const job = useOptionalJob();
const jobsActive =
job !== undefined ||
deepestMatch?.id === "routes/_app.orgs.$organizationSlug.projects.$projectParam._index";
return (
<motion.div
animate={isCollapsed ? "collapsed" : "expanded"}
variants={menuVariants}
initial={isCollapsed ? "collapsed" : "expanded"}
transition={{ type: "spring", duration: 0.5 }}
className={cn(
"flex h-full flex-col justify-between overflow-hidden border-r border-uiBorder p-1 transition"
)}
>
<div className="flex flex-col gap-1">
<SideMenuItem
name="Jobs"
icon="job"
to={projectPath(organization, project)}
isCollapsed={isCollapsed}
forceActive={jobsActive}
data-action="jobs"
/>
<SideMenuItem
name="Integrations"
icon="integration"
to={projectIntegrationsPath(organization, project)}
isCollapsed={isCollapsed}
hasWarning={project.hasUnconfiguredIntegrations}
data-action="integrations"
/>
<SideMenuItem
name="Triggers"
icon="trigger"
to={projectTriggersPath(organization, project)}
isCollapsed={isCollapsed}
hasWarning={project.hasInactiveExternalTriggers}
data-action="triggers"
/>
<SideMenuItem
name="Environments & API Keys"
icon="environment"
to={projectEnvironmentsPath(organization, project)}
isCollapsed={isCollapsed}
data-action="environments & api keys"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuItem
name="Team"
icon="team"
to={organizationTeamPath(organization)}
isCollapsed={isCollapsed}
data-action="team"
/>
<SideMenuItem
name="Usage & Billing"
icon="billing"
to={organizationBillingPath(organization)}
isCollapsed={isCollapsed}
data-action="usage & billing"
/>
<SideMenuItem
name="Onboarding"
icon="clipboard-checked"
to={projectSetupPath(organization, project)}
isCollapsed={isCollapsed}
data-action="onboarding"
/>
<SideMenuItem
name="Changelog"
icon="list"
to="https://trigger.dev/changelog"
isCollapsed={isCollapsed}
data-action="changelog"
target="_blank"
/>
<SideMenuItem
name="Account"
icon={UserProfilePhoto}
to={accountPath()}
isCollapsed={isCollapsed}
data-action="account"
/>
</div>
</motion.div>
);
}
const itemVariants = {
expanded: {
opacity: 1,
},
collapsed: {
opacity: 0,
},
};
function SideMenuItem({
icon,
name,
to,
isCollapsed,
forceActive,
hasWarning = false,
target,
}: {
icon: IconNames | React.ComponentType<any>;
name: string;
to: string;
isCollapsed: boolean;
hasWarning?: boolean;
forceActive?: boolean;
target?: string;
}) {
return (
<SimpleTooltip
button={
<NavLinkButton
variant="menu-item"
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName="text-dimmed"
to={to}
target={target}
className={({ isActive, isPending }) => {
if (forceActive !== undefined) {
isActive = forceActive;
}
return cn(
"relative",
isActive || isPending
? "bg-slate-800 text-bright group-hover:bg-slate-800"
: "text-dimmed group-hover:bg-slate-850 group-hover:text-bright"
);
}}
>
<motion.span
className="shrink-0 pl-1"
animate={isCollapsed ? "collapsed" : "expanded"}
variants={itemVariants}
initial={isCollapsed ? "collapsed" : "expanded"}
>
{name}
</motion.span>
{hasWarning && <NamedIcon name="error" className="absolute left-1 top-1 h-4 w-4" />}
</NavLinkButton>
}
content={name}
side="right"
hidden={!isCollapsed}
/>
);
}
@@ -1,79 +0,0 @@
import { UIMatch } from "@remix-run/react";
import { Fragment, useState } from "react";
import simplur from "simplur";
import { Badge } from "~/components/primitives/Badge";
import { useIsNewOrganizationPage, useOrganizations } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { newOrganizationPath, newProjectPath, projectPath } from "~/utils/pathBuilder";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
} from "../primitives/Popover";
export function ProjectsMenu({ matches }: { matches: UIMatch[] }) {
const [isOpen, setIsOpen] = useState(false);
const organizations = useOrganizations(matches);
const isNewOrgPage = useIsNewOrganizationPage(matches);
const currentProject = useOptionalProject(matches);
if (isNewOrgPage) {
return null;
}
return (
<>
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverArrowTrigger isOpen={isOpen}>
{currentProject?.name ?? "Select a project"}
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
collisionPadding={20}
>
{organizations.map((organization) => (
<Fragment key={organization.id}>
<PopoverSectionHeader title={organization.title} />
<div className="flex flex-col gap-1 p-1 ">
{organization.projects.map((project) => {
const isSelected = project.id === currentProject?.id;
return (
<PopoverMenuItem
key={project.id}
to={projectPath(organization, project)}
title={
<div className="flex w-full items-center justify-between pl-1 text-bright">
<span className="grow truncate text-left">{project.name}</span>
<Badge className="mr-0.5">{simplur`${project._count.jobs} job[|s]`}</Badge>
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})}
<PopoverMenuItem
to={newProjectPath(organization)}
title="New Project"
isSelected={false}
icon="plus"
/>
</div>
</Fragment>
))}
<div className="border-t border-slate-700 p-1">
<PopoverMenuItem
to={newOrganizationPath()}
title="New Organization"
isSelected={false}
icon="plus"
/>
</div>
</PopoverContent>
</Popover>
</>
);
}
@@ -0,0 +1,426 @@
import {
AcademicCapIcon,
ArrowRightOnRectangleIcon,
ChartBarIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { cn } from "~/utils/cn";
import {
accountPath,
inviteTeamMemberPath,
logoutPath,
newOrganizationPath,
newProjectPath,
organizationBillingPath,
organizationIntegrationsPath,
organizationPath,
organizationTeamPath,
projectEnvironmentsPath,
projectHttpEndpointsPath,
projectPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto";
import { Button, LinkButton } from "../primitives/Buttons";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
} from "../primitives/Popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "hasInactiveExternalTriggers" | "jobCount" | "httpEndpointCount"
>;
type SideMenuProps = {
user: SideMenuUser;
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
};
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const { isManagedCloud } = useFeatures();
useEffect(() => {
const handleScroll = () => {
if (borderRef.current) {
const shouldShowHeaderDivider = borderRef.current.scrollTop > 1;
if (showHeaderDivider !== shouldShowHeaderDivider) {
setShowHeaderDivider(shouldShowHeaderDivider);
}
}
};
borderRef.current?.addEventListener("scroll", handleScroll);
return () => borderRef.current?.removeEventListener("scroll", handleScroll);
}, [showHeaderDivider]);
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
)}
>
<div className="flex h-full flex-col">
<div
className={cn(
"flex items-center justify-between border-b bg-background px-1 py-1 transition",
showHeaderDivider ? " border-border" : "border-transparent"
)}
>
<ProjectSelector
organization={organization}
organizations={organizations}
project={project}
/>
<UserMenu user={user} />
</div>
<div
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={project.name || "No project found"}>
<PopoverMenuItem
to={projectSetupPath(organization, project)}
title="Framework setup"
icon="plus"
/>
</SideMenuHeader>
<SideMenuItem
name="Jobs"
icon="job"
iconColor="text-indigo-500"
count={project.jobCount}
to={projectPath(organization, project)}
data-action="jobs"
/>
<SideMenuItem
name="Triggers"
icon="trigger"
iconColor="text-amber-500"
to={projectTriggersPath(organization, project)}
data-action="triggers"
hasWarning={project.hasInactiveExternalTriggers}
/>
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
iconColor="text-green-500"
count={project.httpEndpointCount}
to={projectHttpEndpointsPath(organization, project)}
data-action="httpendpoints"
/>
<SideMenuItem
name="Environments & API Keys"
icon="environment"
iconColor="text-rose-500"
to={projectEnvironmentsPath(organization, project)}
data-action="environments & api keys"
/>
</div>
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title={organization.title}>
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
<PopoverMenuItem
to={inviteTeamMemberPath(organization)}
title="Invite team member"
icon={UserPlusIcon}
leadingIconClassName="text-indigo-500"
/>
</SideMenuHeader>
<SideMenuItem
name="Integrations"
icon="integration"
to={organizationIntegrationsPath(organization)}
data-action="integrations"
hasWarning={organization.hasUnconfiguredIntegrations}
/>
<SideMenuItem
name="Projects"
icon="folder"
to={organizationPath(organization)}
data-action="projects"
/>
<SideMenuItem
name="Team"
icon={UserGroupIcon}
to={organizationTeamPath(organization)}
iconColor="text-sky-500"
data-action="team"
/>
<SideMenuItem
name={isManagedCloud ? "Usage & Billing" : "Usage"}
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-pink-500"
data-action="usage & billing"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
data-action="help & feedback"
fullWidth
textAlignLeft
>
Help & Feedback
</Button>
}
/>
</div>
</div>
</div>
);
}
function ProjectSelector({
project,
organization,
organizations,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
}) {
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setOrgMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
<PopoverArrowTrigger
isOpen={isOrgMenuOpen}
overflowHidden
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
>
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
<span className="truncate">{organization.title ?? "Select an organization"}</span>
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
{organizations.map((organization) => (
<Fragment key={organization.id}>
<PopoverSectionHeader title={organization.title} />
<div className="flex flex-col gap-1 p-1">
{organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={projectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-bright">
<span className="grow truncate text-left">{p.name}</span>
<MenuCount count={p.jobCount} />
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})}
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
</div>
</Fragment>
))}
<div className="border-t border-slate-800 p-1">
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon="plus" />
</div>
</PopoverContent>
</Popover>
);
}
function UserMenu({ user }: { user: SideMenuUser }) {
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setProfileMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1">
<UserAvatar
className={cn(
"h-5 w-5 text-slate-600",
user.isImpersonating && "rounded-full border border-yellow-500"
)}
/>
</PopoverCustomTrigger>
<PopoverContent
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<Fragment>
<PopoverSectionHeader title={user.email} variant="extra-small" />
<div className="flex flex-col gap-1 p-1">
{user.isImpersonating && <ImpersonationBanner />}
{user.admin && (
<PopoverMenuItem
to={"/admin"}
title="Admin"
icon={AcademicCapIcon}
leadingIconClassName="text-yellow-500"
/>
)}
<PopoverMenuItem
to={accountPath()}
title="View profile"
icon={UserProfilePhoto}
leadingIconClassName="text-indigo-500"
/>
<PopoverMenuItem
to={logoutPath()}
title="Log out"
icon={ArrowRightOnRectangleIcon}
leadingIconClassName="text-rose-500"
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
);
}
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
const [isProjectMenuOpen, setProfileMenuOpen] = useState(false);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
<PopoverCustomTrigger isOpen={isProjectMenuOpen} className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</div>
);
}
function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between overflow-hidden">
<span className="truncate">{name}</span>
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -7,99 +7,129 @@ import { ShortcutKey } from "./ShortcutKey";
const variant = {
"primary/small": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
button:
"h-6 px-[5px] text-xs bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:pointer-events-none",
icon: "h-3.5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "small" as const,
shortcut:
"ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60 justify-self-center",
},
"secondary/small": {
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
button:
"h-6 px-[5px] text-xs bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50 group-disabled:pointer-events-none",
icon: "h-3.5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "small" as const,
shortcut:
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
},
"tertiary/small": {
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
button:
"h-6 px-[5px] text-xs bg-transparent group-hover:bg-slate-850 disabled:opacity-50 group-disabled:pointer-events-none",
icon: "h-3.5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "small" as const,
shortcut:
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
},
"danger/small": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
button:
"h-6 px-[5px] text-xs bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50 group-disabled:pointer-events-none",
icon: "h-3.5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "small" as const,
shortcut: "ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"primary/medium": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
button: "h-8 px-2 text-sm bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
icon: "h-4",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"secondary/medium": {
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
button: "h-8 px-2 text-sm bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
icon: "h-4",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut:
"ml-1.5 -mr-0.5 border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright",
},
"tertiary/medium": {
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
button: "h-8 px-2 text-sm bg-transparent group-hover:bg-slate-850 disabled:opacity-50",
icon: "h-4",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut:
"ml-1.5 -mr-0.5 border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright",
},
"danger/medium": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
button: "h-8 px-2 text-sm bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50",
icon: "h-4",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"primary/large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80",
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
button:
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"secondary/large": {
textColor: "text-dimmed",
textColor: "text-dimmed px-1",
button:
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"danger/large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50 px-1",
button:
"h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"menu-item": {
textColor: "text-bright",
textColor: "text-bright px-1",
button:
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-slate-800 transition",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"small-menu-item": {
textColor: "text-bright",
button:
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
icon: "h-4",
iconSpacing: "gap-x-1.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"small-menu-sub-item": {
textColor: "text-dimmed",
button:
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
icon: undefined,
iconSpacing: undefined,
shortcutVariant: undefined,
shortcut: undefined,
},
@@ -110,7 +140,7 @@ const allVariants = {
variant: variant,
};
type ButtonContentPropsType = {
export type ButtonContentPropsType = {
children?: React.ReactNode;
LeadingIcon?: React.ComponentType<any> | IconNamesOrString;
TrailingIcon?: React.ComponentType<any> | IconNamesOrString;
@@ -140,6 +170,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
// Based on the size prop, we'll use the corresponding variant classnames
const btnClassName = cn(allVariants.$all, variation.button);
const iconClassName = variation.icon;
const iconSpacingClassName = variation.iconSpacing;
const shortcutClassName = variation.shortcut;
const textColorClassName = variation.textColor;
@@ -148,7 +179,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center gap-x-0.5"
"flex w-full items-center",
iconSpacingClassName
)}
>
{LeadingIcon &&
@@ -170,7 +202,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate px-1", textColorClassName)}>
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
@@ -43,12 +43,12 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 p-6 pt-11 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
>
<hr className="-ml-6 w-[calc(100%_+_3rem)]" />
<hr className="absolute left-0 top-12 w-full" />
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
<div className="flex gap-x-2">
@@ -73,7 +73,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
<div className={cn("flex flex-col text-left font-medium text-dimmed", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
@@ -21,7 +21,7 @@ export function FormTitle({
<div
className={cn(
"mb-4 flex flex-col gap-3 pb-4",
divide ? "border-b border-uiBorder" : "",
divide ? "border-b border-ui-border" : "",
className
)}
>
@@ -88,12 +88,7 @@ export function HelpContent({
)}
</div>
<div
className="grow rounded border border-slate-850 bg-midnight-850 bg-contain bg-left-top bg-no-repeat p-6"
style={{
backgroundImage: `url(${gradientPath})`,
}}
>
<div className="grow rounded border border-slate-850 bg-midnight-850/80 bg-contain bg-left-top bg-no-repeat p-6">
{children}
</div>
</div>
@@ -59,16 +59,18 @@ import {
import { CompanyIcon, hasIcon } from "@trigger.dev/companyicons";
import { ActivityIcon, HourglassIcon } from "lucide-react";
import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon";
import { EndpointIcon } from "~/assets/icons/EndpointIcon";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
import { ScheduleIcon } from "~/assets/icons/ScheduleIcon";
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
import { WebhookIcon } from "~/assets/icons/WebhookIcon";
import { cn } from "~/utils/cn";
import { tablerIcons } from "~/utils/tablerIcons";
import { LogoIcon } from "../LogoIcon";
import { Spinner } from "./Spinner";
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "./tabler-sprite.svg";
const icons = {
@@ -127,7 +129,9 @@ const icons = {
"invite-member": (className: string) => (
<UserPlusIcon className={cn("text-indigo-500", className)} />
),
job: (className: string) => <WrenchScrewdriverIcon className={cn("text-teal-500", className)} />,
job: (className: string) => (
<WrenchScrewdriverIcon className={cn("text-indigo-500", className)} />
),
key: (className: string) => <KeyIcon className={cn("text-amber-400", className)} />,
lightbulb: (className: string) => <LightBulbIcon className={cn("text-amber-400", className)} />,
"clipboard-checked": (className: string) => (
@@ -183,6 +187,11 @@ const icons = {
<ScheduleIcon className={cn("text-sky-500", className)} />
),
webhook: (className: string) => <WebhookIcon className={cn("text-pink-500", className)} />,
endpoint: (className: string) => <EndpointIcon className={cn("text-blue-500", className)} />,
"http-endpoint": (className: string) => (
<GlobeAltIcon className={cn("text-blue-500", className)} />
),
runs: (className: string) => <RunsIcon className={cn("text-lime-500", className)} />,
};
export type IconNames = keyof typeof icons;
@@ -6,6 +6,9 @@ import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
import { NamedIcon } from "./NamedIcon";
import { Tabs, TabsProps } from "./Tabs";
import { Icon, RenderIcon } from "./Icon";
import { Button, LinkButton } from "./Buttons";
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
type WithChildren = {
children: React.ReactNode;
@@ -13,7 +16,7 @@ type WithChildren = {
export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) {
return (
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-uiBorder pb-4")}>
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-ui-border pb-4")}>
{children}
</div>
);
@@ -24,6 +27,7 @@ export function PageTitleRow({ children }: WithChildren) {
}
type PageTitleProps = {
icon?: RenderIcon;
title: string;
backButton?: {
to: string;
@@ -31,7 +35,7 @@ type PageTitleProps = {
};
};
export function PageTitle({ title, backButton }: PageTitleProps) {
export function PageTitle({ icon, title, backButton }: PageTitleProps) {
return (
<div className="flex items-center gap-2">
{backButton && (
@@ -48,7 +52,9 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
<BreadcrumbIcon className="h-6" />
</div>
)}
<Header1>{title}</Header1>
<Header1 className="flex items-center gap-1">
{icon && <Icon icon={icon} className="h-5 w-5" />} {title}
</Header1>
</div>
);
}
@@ -89,6 +95,28 @@ export function PageInfoProperty({
icon,
label,
value,
to,
}: {
icon?: string | React.ReactNode;
label?: string;
value: React.ReactNode;
to?: string;
}) {
if (to === undefined) {
return <PageInfoPropertyContent icon={icon} label={label} value={value} />;
}
return (
<LinkButton variant="tertiary/small" to={to} TrailingIcon={ArrowUpRightIcon}>
<PageInfoPropertyContent icon={icon} label={label} value={value} />
</LinkButton>
);
}
function PageInfoPropertyContent({
icon,
label,
value,
}: {
icon?: string | React.ReactNode;
label?: string;
@@ -51,6 +51,7 @@ const paragraphVariants = {
text: "font-sans text-xxs font-normal text-bright",
spacing: "mb-1",
},
"extra-extra-small/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed",
spacing: "mb-1",
@@ -59,6 +60,10 @@ const paragraphVariants = {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-bright",
spacing: "mb-1",
},
"extra-extra-small/dimmed/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed",
spacing: "mb-1",
},
};
export type ParagraphVariant = keyof typeof paragraphVariants;
@@ -3,12 +3,11 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { Paragraph, ParagraphVariant } from "./Paragraph";
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import { LinkButton } from "./Buttons";
import { ButtonContentPropsType, LinkButton } from "./Buttons";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
@@ -22,19 +21,27 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
style={{
maxHeight: "var(--radix-popover-content-available-height)",
}}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
function PopoverSectionHeader({ title }: { title: string }) {
function PopoverSectionHeader({
title,
variant = "extra-extra-small/dimmed/caps",
}: {
title: string;
variant?: ParagraphVariant;
}) {
return (
<Paragraph variant="extra-extra-small/bright/caps" className="bg-slate-900 px-2.5 py-2">
<Paragraph variant={variant} className="bg-slate-900 px-2.5 py-1.5">
{title}
</Paragraph>
);
@@ -45,28 +52,33 @@ function PopoverMenuItem({
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
}: {
to: string;
icon: string;
icon: string | React.ComponentType<any>;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
}) {
return (
<LinkButton
to={to}
variant="menu-item"
variant={variant.variant}
LeadingIcon={icon}
leadingIconClassName={leadingIconClassName}
fullWidth
textAlignLeft
TrailingIcon={isSelected ? "check" : undefined}
className={isSelected ? "bg-slate-750 group-hover:bg-slate-750" : undefined}
className={isSelected ? "bg-slate-750 group-hover:bg-slate-800" : undefined}
>
{title}
</LinkButton>
);
}
function PopoverArrowTrigger({
function PopoverCustomTrigger({
isOpen,
children,
className,
@@ -76,15 +88,50 @@ function PopoverArrowTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
"group flex items-center justify-end gap-1 rounded text-dimmed transition hover:bg-slate-850 hover:text-bright",
className
)}
>
<Paragraph variant="extra-small" className="transition group-hover:text-bright">
{children}
</PopoverTrigger>
);
}
function PopoverArrowTrigger({
isOpen,
children,
fullWidth = false,
overflowHidden = false,
className,
...props
}: {
isOpen?: boolean;
fullWidth?: boolean;
overflowHidden?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
return (
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
fullWidth && "w-full justify-between",
className
)}
>
<Paragraph
variant="extra-small"
className={cn(
"flex transition group-hover:text-bright",
overflowHidden && "overflow-hidden"
)}
>
{children}
</Paragraph>
<ChevronDownIcon
className={cn("h-3 w-3 transition group-hover:text-bright", isOpen && "-rotate-180")}
className={cn(
"h-3 w-3 min-w-[0.75rem] text-slate-600 transition group-hover:text-bright",
isOpen && "-rotate-180"
)}
/>
</PopoverTrigger>
);
@@ -113,7 +160,8 @@ export {
PopoverTrigger,
PopoverContent,
PopoverSectionHeader,
PopoverCustomTrigger,
PopoverArrowTrigger,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
PopoverMenuItem,
};
@@ -51,7 +51,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-uiBorder",
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-ui-border",
{
variants: {
position: {
@@ -154,7 +154,7 @@ const SheetContent = React.forwardRef<
{...props}
>
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
<div className="flex items-center gap-2 border-b border-uiBorder p-2">
<div className="flex items-center gap-2 border-b border-ui-border p-2">
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-slate-800 disabled:pointer-events-none">
<NamedIcon name="close" className="h-4 w-4" />
<span className="sr-only">Close</span>
@@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivE
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"mx-4 flex shrink-0 items-center gap-4 border-b border-uiBorder py-3.5",
"mx-4 flex shrink-0 items-center gap-4 border-b border-ui-border py-3.5",
className
)}
{...props}
@@ -194,7 +194,7 @@ export const SheetFooter = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("shrink-0", className)} {...props}>
<div className="mx-4 border-t border-uiBorder py-3">{children}</div>
<div className="mx-4 border-t border-ui-border py-3">{children}</div>
</div>
);
@@ -5,7 +5,7 @@ export function Spinner({
color = "blue",
}: {
className?: string;
color?: "blue" | "white";
color?: "blue" | "white" | "muted";
}) {
const colors = {
blue: {
@@ -16,6 +16,10 @@ export function Spinner({
light: "rgba(255, 255, 255, 0.4)",
dark: "rgba(255, 255, 255)",
},
muted: {
light: "#1C2433",
dark: "#3C4B62",
},
};
const currentColor = colors[color];
@@ -16,7 +16,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps>(
return (
<div
className={cn(
"overflow-x-auto whitespace-nowrap rounded-md border border-uiBorder scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
"overflow-x-auto whitespace-nowrap rounded-md border border-ui-border scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
containerClassName,
fullWidth && "w-full"
)}
@@ -39,7 +39,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
return (
<thead
ref={ref}
className={cn("rounded-t-md", "relative divide-y divide-uiBorder bg-slate-850", className)}
className={cn("rounded-t-md", "relative divide-y divide-ui-border bg-slate-850", className)}
>
{children}
</thead>
@@ -55,7 +55,7 @@ type TableBodyProps = {
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className, children }, ref) => {
return (
<tbody ref={ref} className={cn("relative divide-y divide-uiBorder", className)}>
<tbody ref={ref} className={cn("relative divide-y divide-ui-border", className)}>
{children}
</tbody>
);
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
ref={ref}
scope="col"
className={cn(
"px-4 py-3 align-middle text-xs font-normal uppercase tracking-wider text-dimmed",
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-dimmed",
alignmentClassName,
className
)}
@@ -12,7 +12,7 @@ export type TabsProps = {
export function Tabs({ tabs, className }: TabsProps) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-uiBorder`, className)}>
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
{tabs.map((tab, index) => (
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
{({ isActive, isPending }) => (
+30 -25
View File
@@ -1,6 +1,7 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BoltIcon, ForwardIcon } from "@heroicons/react/24/solid";
import { PlayIcon } from "@heroicons/react/20/solid";
import { BoltIcon } from "@heroicons/react/24/solid";
import {
Form,
Outlet,
@@ -262,7 +263,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
<CodeBlock language="json" code={run.output} maxLines={10} />
) : (
run.output === null && (
<Paragraph variant="small">This Run returned nothing.</Paragraph>
<Paragraph variant="small" className="mt-4">
This Run returned nothing.
</Paragraph>
)
)}
</RunPanelBody>
@@ -273,7 +276,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
{/* Detail view */}
<div className="overflow-y-auto py-4 pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Header2 className="mb-2">Detail</Header2>
<Header2 className="mb-2">Details</Header2>
{selectedId ? <Outlet /> : <Callout variant="info">Select a task or trigger</Callout>}
</div>
</div>
@@ -338,50 +341,52 @@ function RerunPopover({
Rerun Job
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
{environmentType === "PRODUCTION" && (
<Callout variant="warning" className="mb-2">
This will rerun this Job in your Production environment.
</Callout>
<div className="px-4 pt-4">
<Callout variant="warning">
This will rerun this Job in your Production environment.
</Callout>
</div>
)}
<div className="flex flex-col items-start gap-4 divide-y divide-slate-600">
<div>
<div className="flex flex-col items-start divide-y divide-slate-800">
<div className="p-4">
<Paragraph variant="small" className="mb-3">
Start a brand new Job run with the same Trigger data as this one. This will re-do
every Task.
</Paragraph>
<Button
variant="primary/small"
variant="secondary/medium"
type="submit"
name={conform.INTENT}
value="start"
fullWidth
LeadingIcon={BoltIcon}
className="text-bright"
>
<BoltIcon className="mr-1 h-3.5 w-3.5 text-bright" />
Run again
</Button>
<Paragraph variant="extra-small" className="mt-2">
Start a brand new job run with the same Trigger data as this one. This will re-do
every task.
</Paragraph>
</div>
{status === "FAILED" && (
<div className="pt-4">
<div className="p-4">
<Paragraph variant="small" className="mb-3">
Continue running this Job run from where it left off. This will skip any Task that
has already been completed.
</Paragraph>
<Button
variant="primary/small"
variant="secondary/medium"
type="submit"
name={conform.INTENT}
value="continue"
fullWidth
LeadingIcon={ForwardIcon}
className="text-bright"
>
Retry job run
<PlayIcon className="mr-1 h-3.5 w-3.5 text-bright" />
Retry Job run
</Button>
<Paragraph variant="extra-small" className="mt-2">
Continue running this job run from where it left off. This will skip any task that
has already been completed.
</Paragraph>
</div>
)}
</div>
@@ -129,7 +129,7 @@ export function RunsTable({
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Callout variant="warning" className="w-auto">
<Callout variant="info" className="w-auto">
{title}
</Callout>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
import { ProjectJob } from "./useJobs";
import { ProjectJob } from "~/presenters/JobListPresenter.server";
import { useTextFilter } from "./useTextFilter";
import { useToggleFilter } from "./useToggleFilter";
@@ -1,14 +1,14 @@
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam/route";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedClient = UseDataFunctionReturn<typeof loader>["client"];
export function useOptionalIntegrationClient(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam",
id: "routes/_app.orgs.$organizationSlug.integrations_.$clientParam",
matches,
});
@@ -1,11 +1,11 @@
import { UIMatch, useMatches } from "@remix-run/react";
export function useIsProjectChildPage(matches?: UIMatch[]) {
export function useIsOrgChildPage(matches?: UIMatch[]) {
if (!matches) {
matches = useMatches();
}
return matches.some((matchData) => {
return matchData.id.startsWith("routes/_app.orgs.$organizationSlug.projects.$projectParam");
return matchData.id.startsWith("routes/_app.orgs.$organizationSlug");
});
}
+3 -2
View File
@@ -9,17 +9,18 @@ export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
export const jobMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
export function useOptionalJob(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: jobMatchId,
matches,
});
if (!routeMatch || !routeMatch.job) {
if (!routeMatch) {
return undefined;
}
return routeMatch.projectJobs.find((j) => j.id === routeMatch.job.id);
return routeMatch.job;
}
export function useJob(matches?: UIMatch[]) {
-28
View File
@@ -1,28 +0,0 @@
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
export type ProjectJob = UseDataFunctionReturn<typeof loader>["projectJobs"][number];
export const jobsMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
// This is only used in the JobsMenu component, which is the breadcrumb job list dropdown.
// This dropdown is only shown once you have selected a job, so we can assume that
// the route above has loaded and we can use the data from it.
export function useOptionalJobs(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: jobsMatchId,
matches,
});
return routeMatch?.projectJobs;
}
export function useJobs(matches?: UIMatch[]) {
const jobs = useOptionalJobs(matches);
invariant(jobs, "Jobs must be defined");
return jobs;
}
+4 -4
View File
@@ -1,17 +1,17 @@
import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import type { loader as appLoader } from "~/routes/_app/route";
import { hydrateObject, useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedOrganization = UseDataFunctionReturn<typeof appLoader>["organizations"][number];
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
export const organizationMatchId = "routes/_app.orgs.$organizationSlug";
export function useOptionalOrganizations(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof appLoader>({
id: "routes/_app",
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.organizations;
+7 -50
View File
@@ -9,61 +9,12 @@ import { customAlphabet } from "nanoid";
import slug from "slug";
import { prisma, PrismaClientOrTransaction } from "~/db.server";
import { createProject } from "./project.server";
import { generate } from "random-words";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
export type { Organization };
const nanoid = customAlphabet("1234567890abcdef", 4);
const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
12
);
export function getOrganizationFromSlug({
userId,
slug,
}: Pick<Organization, "slug"> & {
userId: User["id"];
}) {
return prisma.organization.findFirst({
include: {
environments: true,
},
where: { slug, members: { some: { userId } } },
});
}
export function getOrganizations({ userId }: { userId: User["id"] }) {
return prisma.organization.findMany({
where: { members: { some: { userId } } },
orderBy: { createdAt: "desc" },
include: {
environments: {
orderBy: { slug: "asc" },
},
projects: {
orderBy: { name: "asc" },
include: {
_count: {
select: {
jobs: {
where: {
internal: false,
deletedAt: null,
},
},
},
},
},
},
_count: {
select: {
members: true,
},
},
},
});
}
export async function createOrganization(
{
@@ -136,12 +87,14 @@ export async function createEnvironment(
const slug = envSlug(type);
const apiKey = createApiKeyForEnv(type);
const pkApiKey = createPkApiKeyForEnv(type);
const shortcode = createShortcode().join("-");
return await prismaClient.runtimeEnvironment.create({
data: {
slug,
apiKey,
pkApiKey,
shortcode,
autoEnableInternalSources: type !== "DEVELOPMENT",
organization: {
connect: {
@@ -158,3 +111,7 @@ export async function createEnvironment(
},
});
}
function createShortcode() {
return generate({ exactly: 2 });
}
@@ -14,6 +14,7 @@ import {
IndexEndpointStats,
parseEndpointIndexStats,
} from "@trigger.dev/core";
import { sortEnvironments } from "~/services/environmentSort.server";
export type Client = {
slug: string;
@@ -197,31 +198,20 @@ export class EnvironmentsPresenter {
}
return {
environments: filtered
.map((environment) => ({
environments: sortEnvironments(
filtered.map((environment) => ({
id: environment.id,
apiKey: environment.apiKey,
pkApiKey: environment.pkApiKey,
type: environment.type,
slug: environment.slug,
}))
.sort((a, b) => {
const aIndex = environmentSortOrder.indexOf(a.type);
const bIndex = environmentSortOrder.indexOf(b.type);
return aIndex - bIndex;
}),
),
clients,
};
}
}
const environmentSortOrder: RuntimeEnvironmentType[] = [
"DEVELOPMENT",
"PREVIEW",
"STAGING",
"PRODUCTION",
];
function endpointClient(
endpoint: Pick<Endpoint, "id" | "slug" | "url" | "indexingHookIdentifier"> & {
indexings: Pick<EndpointIndex, "status" | "source" | "updatedAt" | "stats" | "error">[];
@@ -0,0 +1,154 @@
import { TriggerHttpEndpoint } from "@trigger.dev/database";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
import { getSecretStore } from "~/services/secrets/secretStore.server";
export class HttpEndpointPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
httpEndpointKey,
}: {
userId: string;
projectSlug: string;
httpEndpointKey: string;
}) {
const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({
select: {
id: true,
key: true,
icon: true,
title: true,
updatedAt: true,
projectId: true,
secretReference: {
select: {
key: true,
provider: true,
},
},
httpEndpointEnvironments: {
select: {
id: true,
immediateResponseFilter: true,
skipTriggeringRuns: true,
source: true,
active: true,
updatedAt: true,
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
},
},
where: {
key: httpEndpointKey,
project: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
if (!httpEndpoint) {
throw new Error("Could not find http endpoint");
}
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
type: true,
slug: true,
shortcode: true,
orgMember: {
select: {
userId: true,
},
},
},
where: {
projectId: httpEndpoint.projectId,
},
});
const relevantEnvironments = sortEnvironments(
environments
.filter(
(environment) => environment.orgMember === null || environment.orgMember.userId === userId
)
.map((environment) => ({
...environment,
webhookUrl: httpEndpointUrl({ httpEndpointId: httpEndpoint.id, environment }),
}))
);
//get the secret
const secretStore = getSecretStore(httpEndpoint.secretReference.provider);
let secret: string | undefined;
try {
const secretData = await secretStore.getSecretOrThrow(
z.object({ secret: z.string() }),
httpEndpoint.secretReference.key
);
secret = secretData.secret;
} catch (e) {
let error = e instanceof Error ? e.message : JSON.stringify(e);
throw new Error(`Could not retrieve secret: ${error}`);
}
if (!secret) {
throw new Error("Could not find secret");
}
const httpEndpointEnvironments = httpEndpoint.httpEndpointEnvironments
.filter(
(httpEndpointEnvironment) =>
httpEndpointEnvironment.environment.orgMember === null ||
httpEndpointEnvironment.environment.orgMember.userId === userId
)
.map((endpointEnv) => ({
...endpointEnv,
immediateResponseFilter: endpointEnv.immediateResponseFilter != null,
environment: {
type: endpointEnv.environment.type,
},
webhookUrl: relevantEnvironments.find((e) => e.type === endpointEnv.environment.type)
?.webhookUrl,
}));
return {
httpEndpoint: {
...httpEndpoint,
httpEndpointEnvironments,
},
environments: relevantEnvironments,
unconfiguredEnvironments: relevantEnvironments.filter(
(e) => httpEndpointEnvironments.find((h) => h.environment.type === e.type) === undefined
),
secret,
};
}
}
@@ -0,0 +1,64 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
export class HttpEndpointsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
slug,
}: Pick<Project, "slug"> & {
userId: User["id"];
}) {
const httpEndpoints = await this.#prismaClient.triggerHttpEndpoint.findMany({
select: {
id: true,
key: true,
icon: true,
title: true,
updatedAt: true,
httpEndpointEnvironments: {
select: {
id: true,
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
},
},
where: {
project: {
slug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
return httpEndpoints.map((httpEndpoint) => ({
...httpEndpoint,
httpEndpointEnvironments: httpEndpoint.httpEndpointEnvironments.filter(
(httpEndpointEnvironment) =>
httpEndpointEnvironment.environment.orgMember === null ||
httpEndpointEnvironment.environment.orgMember.userId === userId
),
}));
}
}
@@ -1,7 +1,6 @@
import { User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { ConnectionMetadataSchema } from "~/services/externalApis/types";
export class IntegrationClientConnectionsPresenter {
@@ -14,12 +13,10 @@ export class IntegrationClientConnectionsPresenter {
public async call({
userId,
organizationSlug,
projectSlug,
clientSlug,
}: {
userId: User["id"];
organizationSlug: Organization["slug"];
projectSlug: Project["slug"];
clientSlug: string;
}) {
const connections = await this.#prismaClient.integrationConnection.findMany({
@@ -17,12 +17,10 @@ export class IntegrationClientPresenter {
public async call({
userId,
organizationSlug,
projectSlug,
clientSlug,
}: {
userId: User["id"];
organizationSlug: Organization["slug"];
projectSlug: Project["slug"];
clientSlug: string;
}) {
const integration = await this.#prismaClient.integration.findFirst({
@@ -59,8 +57,8 @@ export class IntegrationClientPresenter {
jobIntegrations: {
where: {
job: {
project: {
slug: projectSlug,
organization: {
slug: organizationSlug,
},
internal: false,
deletedAt: null,
@@ -15,12 +15,10 @@ export class IntegrationClientScopesPresenter {
public async call({
userId,
organizationSlug,
projectSlug,
clientSlug,
}: {
userId: User["id"];
organizationSlug: Organization["slug"];
projectSlug: Project["slug"];
clientSlug: string;
}) {
const integration = await this.#prismaClient.integration.findFirst({
@@ -25,11 +25,9 @@ export class IntegrationsPresenter {
public async call({
userId,
projectSlug,
organizationSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
}) {
const clients = await this.#prismaClient.integration.findMany({
@@ -67,8 +65,8 @@ export class IntegrationsPresenter {
jobIntegrations: {
where: {
job: {
project: {
slug: projectSlug,
organization: {
slug: organizationSlug,
},
internal: false,
deletedAt: null,
@@ -8,6 +8,9 @@ import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { z } from "zod";
import { projectPath } from "~/utils/pathBuilder";
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
export class JobListPresenter {
#prismaClient: PrismaClient;
@@ -23,8 +26,8 @@ export class JobListPresenter {
integrationSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug?: Organization["slug"];
projectSlug?: Project["slug"];
organizationSlug: Organization["slug"];
integrationSlug?: string;
}) {
const orgWhere: Prisma.JobWhereInput["organization"] = organizationSlug
@@ -68,6 +71,8 @@ export class JobListPresenter {
},
},
},
triggerLink: true,
triggerHelp: true,
},
},
environment: {
@@ -90,14 +95,21 @@ export class JobListPresenter {
type: true,
},
},
project: {
select: {
slug: true,
},
},
},
where: {
internal: false,
deletedAt: null,
organization: orgWhere,
project: {
slug: projectSlug,
},
project: projectSlug
? {
slug: projectSlug,
}
: undefined,
integrations: integrationsWhere,
},
orderBy: [{ title: "asc" }],
@@ -182,6 +194,11 @@ export class JobListPresenter {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: projectSlug
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
alias.version.triggerLink
}`
: undefined,
},
integrations,
hasIntegrationsRequiringAction: integrations.some(
@@ -0,0 +1,234 @@
import {
DisplayProperty,
DisplayPropertySchema,
EventSpecificationSchema,
TriggerHelpSchema,
} from "@trigger.dev/core";
import { PrismaClient, Prisma, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { z } from "zod";
import { projectPath } from "~/utils/pathBuilder";
import { Job } from "@trigger.dev/database";
export class JobPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
jobSlug,
projectSlug,
organizationSlug,
}: {
userId: User["id"];
jobSlug: Job["slug"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
}) {
const job = await this.#prismaClient.job.findFirst({
select: {
id: true,
slug: true,
title: true,
aliases: {
select: {
version: {
select: {
version: true,
eventSpecification: true,
properties: true,
status: true,
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
integrations: {
select: {
key: true,
integration: {
select: {
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
triggerLink: true,
triggerHelp: true,
},
},
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
},
dynamicTriggers: {
select: {
type: true,
},
},
project: {
select: {
slug: true,
},
},
_count: {
select: {
runs: {
where: {
isTest: false,
},
},
},
},
},
where: {
slug: jobSlug,
deletedAt: null,
organization: {
members: {
some: {
userId,
},
},
},
project: {
slug: projectSlug,
},
},
});
if (!job) {
return undefined;
}
//the best alias to select:
// 1. Logged-in user dev
// 2. Prod
// 3. Any other user's dev
const sortedAliases = job.aliases.sort((a, b) => {
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
return -1;
}
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
return 1;
}
if (a.environment.type === "PRODUCTION") {
return -1;
}
if (b.environment.type === "PRODUCTION") {
return 1;
}
return 0;
});
const alias = sortedAliases.at(0);
if (!alias) {
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
}
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
const lastRuns = job.aliases
.map((alias) => alias.version.runs.at(0))
.filter(Boolean)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime();
});
const lastRun = lastRuns.at(0);
const integrations = alias.version.integrations.map((integration) => ({
key: integration.key,
title: integration.integration.slug,
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
setupStatus: integration.integration.setupStatus,
}));
let properties: DisplayProperty[] = [];
if (eventSpecification.properties) {
properties = [...properties, ...eventSpecification.properties];
}
if (alias.version.properties) {
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
properties = [...properties, ...versionProperties];
}
const environments = job.aliases.map((alias) => ({
type: alias.environment.type,
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
}));
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
//we exclude test runs from this count
const hasRealRuns = job._count.runs > 0;
return {
id: job.id,
slug: job.slug,
title: job.title,
version: alias.version.version,
status: alias.version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: `${projectRootPath}/${alias.version.triggerLink}`,
},
noRunsHelp: hasRealRuns
? undefined
: this.#getNoRunsHelp(alias.version.triggerHelp, projectRootPath),
integrations,
hasIntegrationsRequiringAction: integrations.some((i) => i.setupStatus === "MISSING_FIELDS"),
lastRun,
properties,
environments,
};
}
#getNoRunsHelp(data: Prisma.JsonValue, projectPath: string) {
const triggerHelp = TriggerHelpSchema.nullish().parse(data);
if (!triggerHelp) {
return undefined;
}
if (triggerHelp.noRuns) {
triggerHelp.noRuns.link = triggerHelp.noRuns.link
? `${projectPath}/${triggerHelp.noRuns.link}`
: undefined;
return triggerHelp.noRuns;
}
}
}
@@ -0,0 +1,20 @@
import { PrismaClient, User } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export class NewOrganizationPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId }: { userId: User["id"] }) {
const organizations = await this.#prismaClient.organization.findMany({
where: { members: { some: { userId } } },
});
return {
hasOrganizations: organizations.length > 0,
};
}
}
@@ -0,0 +1,117 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { ProjectPresenter } from "./ProjectPresenter.server";
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
organizationSlug,
request,
projectSlug,
}: {
userId: string;
organizationSlug: string;
request: Request;
projectSlug?: string;
}) {
const organizations = await this.getOrganizations(userId);
const organization = organizations.find((o) => o.slug === organizationSlug);
if (!organization) {
throw new Response("Not Found", { status: 404 });
}
const project = await this.getProject(organization, projectSlug, request, userId);
return { organizations, organization, project };
}
async getOrganizations(userId: string) {
const orgs = await this.#prismaClient.organization.findMany({
where: { members: { some: { userId } } },
orderBy: { createdAt: "desc" },
include: {
projects: {
orderBy: { name: "asc" },
include: {
_count: {
select: {
jobs: {
where: {
internal: false,
deletedAt: null,
},
},
sources: {
where: {
active: false,
},
},
httpEndpoints: true,
},
},
},
},
_count: {
select: {
members: true,
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
},
},
},
},
},
});
return orgs.map((org) => {
return {
id: org.id,
slug: org.slug,
title: org.title,
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
name: project.name,
jobCount: project._count.jobs,
})),
hasUnconfiguredIntegrations: org._count.integrations > 0,
memberCount: org._count.members,
};
});
}
async getProject(
organization: Org,
projectSlug: string | undefined,
request: Request,
userId: string
) {
const projectPresenter = new ProjectPresenter();
if (!projectSlug) {
const projectId = await getCurrentProjectId(request);
const orgProject = organization.projects.find((p) => p.id === projectId);
if (!orgProject) {
throw new Response("Not Found", { status: 404 });
}
projectSlug = orgProject.slug;
}
const project = await projectPresenter.call({ userId, slug: projectSlug });
if (!project) {
throw new Response("Not Found", { status: 404 });
}
return project;
}
}
@@ -91,6 +91,13 @@ export class ProjectPresenter {
active: false,
},
},
jobs: {
where: {
internal: false,
deletedAt: null,
},
},
httpEndpoints: true,
},
},
organization: {
@@ -135,7 +142,8 @@ export class ProjectPresenter {
createdAt: project.createdAt,
updatedAt: project.updatedAt,
hasInactiveExternalTriggers: project._count.sources > 0,
hasUnconfiguredIntegrations: project.organization._count.integrations > 0,
jobCount: project._count.jobs,
httpEndpointCount: project._count.httpEndpoints,
environments: project.environments.map((environment) => ({
id: environment.id,
slug: environment.slug,
@@ -0,0 +1,49 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
export class SelectBestProjectPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, request }: { userId: string; request: Request }) {
//try get current project from cookie
const projectId = await getCurrentProjectId(request);
if (projectId) {
const project = await this.#prismaClient.project.findUnique({
where: { id: projectId, organization: { members: { some: { userId } } } },
include: { organization: true },
});
if (project) {
return { project, organization: project.organization };
}
}
//failing that, we pick the project with the most jobs
const projects = await this.#prismaClient.project.findMany({
include: {
organization: true,
},
where: {
organization: {
members: { some: { userId } },
},
},
orderBy: {
jobs: {
_count: "desc",
},
},
take: 1,
});
if (projects.length === 0) {
throw new Response("Not Found", { status: 404 });
}
return { project: projects[0], organization: projects[0].organization };
}
}
@@ -0,0 +1,19 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
import { requireUserId } from "~/services/session.server";
import { newOrganizationPath, projectPath } from "~/utils/pathBuilder";
//this loader chooses the best project to redirect you to, ideally based on the cookie
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const presenter = new SelectBestProjectPresenter();
try {
const { project, organization } = await presenter.call({ userId, request });
return redirect(projectPath(organization, project));
} catch (e) {
//this should only happen if the user has no projects
return redirect(newOrganizationPath());
}
};
@@ -1,73 +0,0 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import simplur from "simplur";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { newProjectPath, organizationPath, projectPath } from "~/utils/pathBuilder";
export function OrganizationGridItem({ organization }: { organization: MatchedOrganization }) {
return (
<li key={organization.id}>
<div
className={cn(
"rounded-md border border-slate-800 bg-gradient-to-bl from-indigo-900/70 via-indigo-500/10 to-slate-900 to-70% p-2"
)}
>
<Link
to={organizationPath(organization)}
className="group flex items-center gap-y-4 rounded-md border-b border-slate-800 px-2 pb-4 pt-2"
>
<div className="flex w-full items-center justify-between gap-x-2">
<NamedIcon name="organization" className="h-10 w-10 flex-none" aria-hidden="true" />
<div className="flex-1">
<Header2 className="">{organization.title}</Header2>
<Paragraph variant="extra-small">
{simplur`${organization._count.members} team member[|s]`}
</Paragraph>
</div>
</div>
<ChevronRightIcon
className="h-5 w-5 text-dimmed/0 transition group-hover:text-dimmed/100"
aria-hidden="true"
/>
</Link>
<div className="pt-4">
<Paragraph className="mb-2 px-2" variant="extra-small/bright/caps">
Projects
</Paragraph>
<div>
{organization.projects.map((project) => (
<LinkButton
variant="menu-item"
to={projectPath(organization, project)}
key={project.id}
LeadingIcon="folder"
fullWidth
textAlignLeft
>
<span className="flex grow items-center justify-between pl-1">
<span className="grow text-left">{project.name}</span>
<Badge>{simplur`${project._count.jobs} job[|s]`}</Badge>
</span>
</LinkButton>
))}
<LinkButton
to={newProjectPath(organization)}
variant="menu-item"
LeadingIcon="plus"
fullWidth
textAlignLeft
>
New Project
</LinkButton>
</div>
</div>
</div>
</li>
);
}
@@ -1,62 +0,0 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { useOptionalOrganizations } from "~/hooks/useOrganizations";
import { getUsersInvites } from "~/models/member.server";
import { getOrganizations } from "~/models/organization.server";
import { requireUser } from "~/services/session.server";
import { invitesPath, newOrganizationPath } from "~/utils/pathBuilder";
import { OrganizationGridItem } from "./OrganizationGrid";
import { Link } from "@remix-run/react";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
//if there are invites then we should redirect to the invites page
const invites = await getUsersInvites({ email: user.email });
if (invites.length > 0) {
return redirect(invitesPath());
}
//if there are no orgs, then redirect to create an org
const organizations = await getOrganizations({ userId: user.id });
if (organizations.length === 0) {
return redirect(newOrganizationPath());
}
return json({});
};
export default function Page() {
const organizations = useOptionalOrganizations();
return (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,_minmax(350px,_auto))]">
<>
{organizations &&
organizations.map((organization) => (
<OrganizationGridItem key={organization.id} organization={organization} />
))}
</>
<li>
<Link
to={newOrganizationPath()}
className="group grid h-full grow place-items-center rounded-md border border-slate-800 p-2 shadow-sm shadow-transparent transition duration-300 hover:border-indigo-900 hover:shadow-glow-secondary"
>
<div className="flex flex-col items-center justify-center gap-y-2">
<NamedIcon
name="plus"
className="h-10 w-10 text-dimmed transition duration-300 group-hover:text-green-500"
aria-hidden="true"
/>
<Paragraph variant="small" className="transition duration-300 group-hover:text-bright">
New Organization
</Paragraph>
</div>
</Link>
</li>
</ul>
);
}
@@ -1,49 +0,0 @@
import { ArrowRightOnRectangleIcon } from "@heroicons/react/20/solid";
import { Outlet } from "@remix-run/react";
import { PageContainer, PageBody } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
PageDescription,
PageTabs,
} from "~/components/primitives/PageHeader";
import {
newOrganizationPath,
organizationsPath,
accountPath,
logoutPath,
} from "~/utils/pathBuilder";
export default function Page() {
return (
<PageContainer>
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle title="Organizations and Account" />
<PageButtons>
<LinkButton
to={logoutPath()}
variant="tertiary/small"
TrailingIcon={ArrowRightOnRectangleIcon}
>
Logout
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>Create new Organizations and manage your account.</PageDescription>
<PageTabs
tabs={[
{ label: "Organizations", to: organizationsPath() },
{ label: "Account", to: accountPath() },
]}
/>
</PageHeader>
<PageBody>
<Outlet />
</PageBody>
</PageContainer>
);
}
@@ -1,55 +0,0 @@
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
PageDescription,
PageTabs,
} from "~/components/primitives/PageHeader";
import { useOrganization } from "~/hooks/useOrganizations";
import {
newProjectPath,
organizationBillingPath,
organizationPath,
organizationTeamPath,
organizationsPath,
} from "~/utils/pathBuilder";
export function OrgAdminHeader() {
const organization = useOrganization();
return (
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle
backButton={{
to: organizationsPath(),
text: "Organizations and Account",
}}
title={organization.title}
/>
<PageButtons>
<LinkButton
to={newProjectPath(organization)}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new project
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>Manage your projects and team.</PageDescription>
<PageTabs
tabs={[
{ label: "Projects", to: organizationPath(organization) },
{ label: "Team", to: organizationTeamPath(organization) },
{
label: "Usage & billing",
to: organizationBillingPath(organization),
},
]}
/>
</PageHeader>
);
}
@@ -1,20 +1,44 @@
import { Link } from "@remix-run/react";
import simplur from "simplur";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { Handle } from "~/utils/handle";
import { newProjectPath, projectPath } from "~/utils/pathBuilder";
import { OrgAdminHeader } from "./OrgAdminHeader";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Projects" />,
};
export default function Page() {
const organization = useOrganization();
return (
<PageContainer>
<OrgAdminHeader />
<PageHeader>
<PageTitleRow>
<PageTitle title={`${organization.title} projects`} />
<PageButtons>
<LinkButton
to={newProjectPath(organization)}
variant="secondary/small"
shortcut={{ key: "n" }}
>
Create a new project
</LinkButton>
</PageButtons>
</PageTitleRow>
</PageHeader>
<PageBody>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{organization.projects.length > 0 ? (
@@ -22,13 +46,13 @@ export default function Page() {
return (
<li key={project.id}>
<Link
className="flex gap-4 rounded-md border border-slate-850 bg-slate-900 bg-gradient-to-b from-indigo-900/60 to-slate-900 to-70% p-4"
className="flex gap-4 rounded-md border border-ui-border-dimmed p-4 transition hover:bg-slate-900 "
to={projectPath(organization, project)}
>
<NamedIcon name="folder" className="h-10 w-10 flex-none" />
<div className="flex flex-col">
<Header3>{project.name}</Header3>
<Paragraph variant="small">{simplur`${project._count.jobs} job[|s]`}</Paragraph>
<Paragraph variant="small">{simplur`${project.jobCount} Job[|s]`}</Paragraph>
</div>
</Link>
</li>
@@ -11,13 +11,26 @@ import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrganizationParamsSchema, jobPath, organizationTeamPath } from "~/utils/pathBuilder";
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
import {
OrganizationParamsSchema,
jobPath,
newProjectPath,
organizationTeamPath,
} from "~/utils/pathBuilder";
import { Link } from "@remix-run/react/dist/components";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
} from "~/components/primitives/PageHeader";
import { Handle } from "~/utils/handle";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -47,13 +60,21 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
return null;
};
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Usage & Billing" />,
};
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<OrgAdminHeader />
<PageHeader>
<PageTitleRow>
<PageTitle title="Usage & Billing" />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded border border-border p-6">
@@ -151,7 +172,7 @@ export default function Page() {
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6">
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
<Header2 className="">Jobs</Header2>
<Header2 className="">Runs</Header2>
@@ -1,15 +1,15 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useCallback, useState } from "react";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { IntegrationIcon } from "~/assets/icons/IntegrationIcon";
import { Feedback } from "~/components/Feedback";
import { LogoIcon } from "~/components/LogoIcon";
import { HowToConnectAnIntegration } from "~/components/helpContent/HelpContentText";
import { ConnectToIntegrationSheet } from "~/components/integrations/ConnectToIntegrationSheet";
import { IntegrationWithMissingFieldSheet } from "~/components/integrations/IntegrationWithMissingFieldSheet";
import { NoIntegrationSheet } from "~/components/integrations/NoIntegrationSheet";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
@@ -17,7 +17,7 @@ import { DetailCell } from "~/components/primitives/DetailCell";
import { Header2 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import { Input } from "~/components/primitives/Input";
import { NamedIcon, NamedIconInBox } from "~/components/primitives/NamedIcon";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageButtons,
PageDescription,
@@ -25,7 +25,6 @@ import {
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
@@ -38,10 +37,9 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import {
Client,
@@ -51,20 +49,21 @@ import {
import { requireUser } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
OrganizationParamsSchema,
ProjectParamSchema,
docsCreateIntegration,
docsPath,
integrationClientPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new IntegrationsPresenter();
const data = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
});
return typedjson(data);
@@ -72,14 +71,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Integrations" />,
expandSidebar: true,
};
export default function Integrations() {
const { clients, clientMissingFields, options, callbackUrl } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
@@ -87,8 +84,12 @@ export default function Integrations() {
<PageTitleRow>
<PageTitle title="Integrations" />
<PageButtons>
<LinkButton to={docsCreateIntegration()} variant="secondary/small" LeadingIcon="docs">
Create your own Integration
<LinkButton
to={docsPath("/integrations/introduction")}
variant="secondary/small"
LeadingIcon="docs"
>
Integrations documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
@@ -113,11 +114,7 @@ export default function Integrations() {
options={options}
/>
)}
<ConnectedIntegrationsList
clients={clients}
organization={organization}
project={project}
/>
<ConnectedIntegrationsList clients={clients} organization={organization} />
</div>
</div>
</PageBody>
@@ -144,94 +141,96 @@ function PossibleIntegrationsList({
});
return (
<div className="overflow-y-auto py-4 pl-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="flex items-center justify-between">
<Header2 className="mb-2">Connect an API</Header2>
<Switch
checked={onlyShowIntegrations}
onCheckedChange={setOnlyShowIntegrations}
variant="small"
label={
<span className="mt-0.5 inline-flex items-center gap-1">
<IntegrationIcon /> Trigger.dev Integrations
</span>
}
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="py-4 pl-4">
<div className="flex items-center justify-between">
<Header2 className="mb-2">Connect an API</Header2>
<Switch
checked={onlyShowIntegrations}
onCheckedChange={setOnlyShowIntegrations}
variant="small"
label={
<span className="mt-0.5 inline-flex items-center gap-1">
<IntegrationIcon /> Trigger.dev Integrations
</span>
}
/>
</div>
<Input
placeholder="Search APIs"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
<Input
placeholder="Search APIs"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 sm:grid-cols-[repeat(auto-fill,_minmax(14rem,_auto))]">
{filteredItems.map((option) => {
switch (option.type) {
case "integration":
return (
<ConnectToIntegrationSheet
key={option.identifier}
integration={option}
organizationId={organizationId}
callbackUrl={callbackUrl}
icon={option.icon}
button={
<AddIntegrationConnection
identifier={option.identifier}
name={option.name}
icon={option.icon}
isIntegration
/>
}
/>
);
case "api":
return (
<NoIntegrationSheet
key={option.identifier}
api={option}
requested={option.voted}
button={
<AddIntegrationConnection
identifier={option.identifier}
name={option.name}
isIntegration={false}
/>
}
/>
);
<div className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 sm:grid-cols-[repeat(auto-fill,_minmax(14rem,_auto))]">
{filteredItems.map((option) => {
switch (option.type) {
case "integration":
return (
<ConnectToIntegrationSheet
key={option.identifier}
integration={option}
organizationId={organizationId}
callbackUrl={callbackUrl}
icon={option.icon}
button={
<AddIntegrationConnection
identifier={option.identifier}
name={option.name}
icon={option.icon}
isIntegration
/>
}
/>
);
case "api":
return (
<NoIntegrationSheet
key={option.identifier}
api={option}
requested={option.voted}
button={
<AddIntegrationConnection
identifier={option.identifier}
name={option.name}
isIntegration={false}
/>
}
/>
);
}
})}
</div>
<Header2 className="mb-2 mt-6">Missing an API?</Header2>
<Feedback
button={
<button className="w-full">
<DetailCell
leadingIcon="plus"
leadingIconClassName="text-dimmed"
label="Request an API and we'll add it to the list as an Integration"
trailingIcon="chevron-right"
trailingIconClassName="text-slate-700 group-hover:text-bright"
/>
</button>
}
})}
</div>
<Header2 className="mb-2 mt-6">Missing an API?</Header2>
<Feedback
button={
<button className="w-full">
<DetailCell
leadingIcon="plus"
leadingIconClassName="text-dimmed"
label="Request an API and we'll add it to the list as an Integration"
trailingIcon="chevron-right"
trailingIconClassName="text-slate-700 group-hover:text-bright"
/>
</button>
}
defaultValue="integration"
/>
defaultValue="integration"
/>
<Header2 className="mb-2 mt-6">Create an Integration</Header2>
<a href="https://docs.trigger.dev/integrations/create" target="_blank">
<DetailCell
leadingIcon="integration"
leadingIconClassName="text-dimmed"
label="Learn how to create your own API Integrations"
trailingIcon="external-link"
trailingIconClassName="text-slate-700 group-hover:text-bright"
/>
</a>
<Header2 className="mb-2 mt-6">Create an Integration</Header2>
<a href="https://docs.trigger.dev/integrations/create" target="_blank">
<DetailCell
leadingIcon="integration"
leadingIconClassName="text-dimmed"
label="Learn how to create your own API Integrations"
trailingIcon="external-link"
trailingIconClassName="text-slate-700 group-hover:text-bright"
/>
</a>
</div>
</div>
);
}
@@ -239,11 +238,9 @@ function PossibleIntegrationsList({
function ConnectedIntegrationsList({
clients,
organization,
project,
}: {
clients: Client[];
organization: Organization;
project: Project;
organization: MatchedOrganization;
}) {
const { filterText, setFilterText, filteredItems } = useTextFilter<Client>({
items: clients,
@@ -278,19 +275,20 @@ function ConnectedIntegrationsList({
</HelpContent>
<div className="mb-2 flex items-center justify-between">
<Header2 className="m-0">Your connected Integrations</Header2>
<HelpTrigger title="How do I connect an Integration?" />
</div>
{clients.length > 0 && (
<div>
<Input
placeholder="Search connected Integrations"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="mb-2 flex items-center gap-2">
<Input
placeholder="Search connected Integrations"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<HelpTrigger title="How do I connect an Integration?" />
</div>
<Table>
<TableHeader>
<TableRow>
@@ -318,7 +316,7 @@ function ConnectedIntegrationsList({
) : (
<>
{filteredItems.map((client) => {
const path = integrationClientPath(organization, project, client);
const path = integrationClientPath(organization, client);
return (
<TableRow key={client.id}>
<TableCell to={path}>{client.title}</TableCell>
@@ -496,7 +494,3 @@ function AddIntegrationConnection({
/>
);
}
export function IntegrationIcon() {
return <LogoIcon className="h-3.5 w-3.5 flex-none pb-0.5" />;
}
@@ -3,7 +3,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { HowToUseThisIntegration } from "~/components/helpContent/HelpContentText";
import { JobSkeleton } from "~/components/jobs/JobSkeleton";
import { JobsTable } from "~/components/jobs/JobsTable";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
@@ -23,14 +23,12 @@ import {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, clientParam } =
IntegrationClientParamSchema.parse(params);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const jobsPresenter = new JobListPresenter();
const jobs = await jobsPresenter.call({
userId,
projectSlug: projectParam,
organizationSlug,
integrationSlug: clientParam,
});
@@ -1,7 +1,7 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { connectionType } from "~/components/integrations/connectionType";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { DateTime } from "~/components/primitives/DateTime";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -20,14 +20,12 @@ import { IntegrationClientParamSchema, trimTrailingSlash } from "~/utils/pathBui
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, clientParam } =
IntegrationClientParamSchema.parse(params);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const presenter = new IntegrationClientConnectionsPresenter();
const { connections } = await presenter.call({
userId: userId,
organizationSlug,
projectSlug: projectParam,
clientSlug: clientParam,
});
@@ -1,6 +1,6 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Paragraph } from "~/components/primitives/Paragraph";
import { IntegrationClientScopesPresenter } from "~/presenters/IntegrationClientScopesPresenter.server";
import { requireUserId } from "~/services/session.server";
@@ -9,14 +9,12 @@ import { IntegrationClientParamSchema, trimTrailingSlash } from "~/utils/pathBui
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, clientParam } =
IntegrationClientParamSchema.parse(params);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const presenter = new IntegrationClientScopesPresenter();
const { scopes } = await presenter.call({
userId: userId,
organizationSlug,
projectSlug: projectParam,
clientSlug: clientParam,
});
@@ -3,7 +3,7 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { connectionType } from "~/components/integrations/connectionType";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import {
@@ -26,19 +26,17 @@ import {
integrationClientConnectionsPath,
integrationClientPath,
integrationClientScopesPath,
projectIntegrationsPath,
organizationIntegrationsPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, clientParam, projectParam } =
IntegrationClientParamSchema.parse(params);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const presenter = new IntegrationClientPresenter();
const client = await presenter.call({
userId: user.id,
organizationSlug,
projectSlug: projectParam,
clientSlug: clientParam,
});
@@ -59,23 +57,22 @@ export const handle: Handle = {
export default function Integrations() {
const { client } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
let tabs = [
{
label: "Jobs",
to: integrationClientPath(organization, project, client),
to: integrationClientPath(organization, client),
},
];
if (client.authMethod.type !== "local") {
tabs.push({
label: "Connections",
to: integrationClientConnectionsPath(organization, project, client),
to: integrationClientConnectionsPath(organization, client),
});
tabs.push({
label: "Scopes",
to: integrationClientScopesPath(organization, project, client),
to: integrationClientScopesPath(organization, client),
});
}
@@ -86,7 +83,7 @@ export default function Integrations() {
<PageTitle
title={client.title}
backButton={{
to: projectIntegrationsPath(organization, project),
to: organizationIntegrationsPath(organization),
text: "Integrations",
}}
/>
@@ -8,6 +8,7 @@ import simplur from "simplur";
import invariant from "tiny-invariant";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -17,12 +18,16 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { scheduleEmail } from "~/services/email.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder";
import { loader } from "../_app.orgs.$organizationSlug/route";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
const schema = z.object({
emails: z.preprocess((i) => {
@@ -85,6 +90,20 @@ export const action: ActionFunction = async ({ request, params }) => {
}
};
export const handle: Handle = {
breadcrumb: (match, matches) => {
const orgMatch = matches.find((m) => m.id === organizationMatchId);
const data = useTypedMatchData<typeof loader>(orgMatch);
return (
<Fragment>
{data && <BreadcrumbLink to={organizationTeamPath(data?.organization)} title="Team" />}
<BreadcrumbIcon />
<BreadcrumbLink to={match.pathname} title="Invite" />
</Fragment>
);
},
};
export default function Page() {
const organization = useOrganization();
const lastSubmission = useActionData();
@@ -107,7 +126,7 @@ export default function Page() {
<FormTitle
LeadingIcon="invite-member"
title="Invite team members"
description={`Invite a new team member to ${organization.title}.`}
description={`Invite new team members to ${organization.title}.`}
/>
<Form method="post" {...form.props}>
<Fieldset>
@@ -4,7 +4,6 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { FrameworkSelector } from "~/components/frameworks/FrameworkSelector";
import { JobsTable } from "~/components/jobs/JobsTable";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
@@ -27,20 +26,15 @@ import { useProject } from "~/hooks/useProject";
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
ProjectParamSchema,
projectIntegrationsPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
import { ProjectParamSchema, organizationIntegrationsPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
try {
const presenter = new JobListPresenter();
const jobs = await presenter.call({ userId, projectSlug: projectParam });
const jobs = await presenter.call({ userId, organizationSlug, projectSlug: projectParam });
return typedjson({
jobs,
@@ -54,11 +48,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Jobs" />,
expandSidebar: true,
};
export default function Page() {
const organization = useOrganization();
const project = useProject();
@@ -78,8 +67,8 @@ export default function Page() {
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty icon={"job"} label={"All Jobs"} value={totalJobs} />
<PageInfoProperty icon={"job"} label={"Active Jobs"} value={activeJobCount} />
<PageInfoProperty icon={"job"} label={"Total"} value={totalJobs} />
<PageInfoProperty icon={"job"} label={"Active"} value={activeJobCount} />
</PageInfoGroup>
</PageInfoRow>
</PageHeader>
@@ -94,7 +83,7 @@ export default function Page() {
{jobs.some((j) => j.hasIntegrationsRequiringAction) && (
<Callout
variant="error"
to={projectIntegrationsPath(organization, project)}
to={organizationIntegrationsPath(organization)}
className="mb-2"
>
Some of your Jobs have Integrations that have not been configured.
@@ -200,7 +189,7 @@ function ExampleJobs() {
<a
href={example.codeLink}
key={example.title}
className="flex w-full items-center rounded border-b border-uiBorder py-2 transition hover:border-transparent hover:bg-slate-800"
className="flex w-full items-center rounded border-b border-ui-border py-2 transition hover:border-transparent hover:bg-slate-800"
>
{example.icon}
<Paragraph variant="small">
@@ -29,7 +29,6 @@ import {
SelectValue,
} from "~/components/primitives/Select";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { useRef, useState } from "react";
type FirstEndpointSheetProps = {
projectId: string;
@@ -52,16 +51,16 @@ export function FirstEndpointSheet({ projectId, environments }: FirstEndpointShe
return (
<Sheet>
<SheetTrigger>
<ButtonContent variant={"primary/medium"}>Add your first endpoint</ButtonContent>
<Button variant="secondary/medium">Add your first endpoint</Button>
</SheetTrigger>
<SheetContent size="lg">
<SheetHeader>
<div>
<Header1>Add your first endpoint</Header1>
<Paragraph variant="small">
We recommend you use{" "}
<TextLink href={docsPath("documentation/guides/cli")}>the CLI</TextLink> when working
in development.
We recommend you{" "}
<TextLink href={docsPath("documentation/guides/cli")}>use the CLI</TextLink> when
working in development.
</Paragraph>
</div>
</SheetHeader>
@@ -10,14 +10,15 @@ import {
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { HowToUseApiKeysAndEndpoints } from "~/components/helpContent/HelpContentText";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Badge } from "~/components/primitives/Badge";
import { ButtonContent } from "~/components/primitives/Buttons";
import { ButtonContent, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
@@ -39,7 +40,11 @@ import { ClientEndpoint, EnvironmentsPresenter } from "~/presenters/Environments
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, projectEnvironmentsStreamingPath } from "~/utils/pathBuilder";
import {
ProjectParamSchema,
docsPath,
projectEnvironmentsStreamingPath,
} from "~/utils/pathBuilder";
import { requestUrl } from "~/utils/requestUrl.server";
import { RuntimeEnvironmentType } from "../../../../../packages/database/src";
import { ConfigureEndpointSheet } from "./ConfigureEndpointSheet";
@@ -74,8 +79,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Environments" />,
expandSidebar: true,
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Environments & API Keys" />,
};
export default function Page() {
@@ -128,6 +132,15 @@ export default function Page() {
<PageHeader>
<PageTitleRow>
<PageTitle title="Environments & API Keys" />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("/documentation/concepts/environments-endpoints#environments")}
variant="secondary/small"
>
Environments documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>API Keys and endpoints for your environments.</PageDescription>
</PageHeader>
@@ -164,13 +177,13 @@ export default function Page() {
className="w-full max-w-none"
secure
value={environment.apiKey}
variant={"primary/medium"}
variant={"secondary/medium"}
icon={<Badge variant="outline">Server</Badge>}
/>
<ClipboardField
className="w-full max-w-none"
value={environment.pkApiKey}
variant={"primary/medium"}
variant={"secondary/medium"}
icon={<Badge variant="outline">Public</Badge>}
/>
</div>
@@ -234,12 +247,9 @@ export default function Page() {
</div>
))
) : (
<>
<Paragraph>Add your first endpoint</Paragraph>
<Paragraph>
<FirstEndpointSheet projectId={project.id} environments={environments} />
</Paragraph>
</>
<Paragraph>
<FirstEndpointSheet projectId={project.id} environments={environments} />
</Paragraph>
)}
</div>
{selectedEndpoint && selectedEndpoint.endpoint && (
@@ -285,7 +295,7 @@ function EndpointRow({
<span className="text-amber-500">
The {environmentTitle({ type })} environment is not configured
</span>
<ButtonContent variant="primary/small">Configure</ButtonContent>
<ButtonContent variant="secondary/small">Configure</ButtonContent>
</div>
</TableCell>
</TableRow>
@@ -0,0 +1,236 @@
import { CheckIcon } from "@heroicons/react/20/solid";
import { StopIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { HowToConnectHttpEndpoint } from "~/components/helpContent/HelpContentText";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Header1 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import {
PageButtons,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { HttpEndpointPresenter } from "~/presenters/HttpEndpointPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { HttpEndpointParamSchema, docsPath, projectHttpEndpointsPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, httpEndpointParam } = HttpEndpointParamSchema.parse(params);
const presenter = new HttpEndpointPresenter();
try {
const result = await presenter.call({
userId,
projectSlug: projectParam,
httpEndpointKey: httpEndpointParam,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
} catch (e) {
console.log(e);
throw new Response(e instanceof Error ? e.message : JSON.stringify(e), { status: 404 });
}
};
export const handle: Handle = {
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
return (
<BreadcrumbLink
to={match.pathname}
title={data?.httpEndpoint.title ?? data?.httpEndpoint.key ?? "Endpoint"}
/>
);
},
};
export default function Page() {
const { httpEndpoint, unconfiguredEnvironments, secret } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle
title={httpEndpoint.title ?? httpEndpoint.key}
backButton={{
to: projectHttpEndpointsPath(organization, project),
text: "HTTP endpoints",
}}
icon={httpEndpoint.icon ?? undefined}
/>
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("documentation/concepts/triggers/http-endpoints")}
variant="secondary/small"
>
HTTP endpoints documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
</PageHeader>
<PageBody>
<Help defaultOpen={true}>
{(open) => (
<div className={cn("grid h-full gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div>
<div className="mb-2 flex items-center justify-between gap-x-2">
<Header1 spacing>
{httpEndpoint.httpEndpointEnvironments.length > 0
? "Ready to receive data"
: "Not deployed"}
</Header1>
<HelpTrigger title="How do I connect my HTTP Endpoint?" />
</div>
{httpEndpoint.httpEndpointEnvironments.length > 0 && (
<div className="mb-8">
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Endpoint URL</TableHeaderCell>
<TableHeaderCell>Secret</TableHeaderCell>
<TableHeaderCell>Source</TableHeaderCell>
<TableHeaderCell>Respond to request?</TableHeaderCell>
<TableHeaderCell alignment="right">Updated</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{httpEndpoint.httpEndpointEnvironments.map((httpEnvironment) => {
return (
<TableRow key={httpEnvironment.id}>
<TableCell>
<EnvironmentLabel environment={httpEnvironment.environment} />
</TableCell>
<TableCell>
<ClipboardField
className="max-w-[30rem]"
fullWidth={false}
value={httpEnvironment?.webhookUrl ?? ""}
variant="tertiary/small"
/>
</TableCell>
<TableCell>
<ClipboardField
className="max-w-[10rem]"
fullWidth={false}
value={secret}
secure
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>{httpEnvironment.source}</TableCell>
<TableCell>
{httpEnvironment.immediateResponseFilter ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
) : (
<StopIcon className="h-4 w-4 text-slate-850" />
)}
</TableCell>
<TableCell alignment="right">
<DateTime date={httpEnvironment.updatedAt} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{unconfiguredEnvironments.length > 0 && (
<>
<Header1 spacing>Not deployed</Header1>
<Paragraph spacing variant="small">
You need to deploy your code for the following environments to receive
webhooks {" "}
<TextLink to={docsPath("documentation/guides/deployment")}>
read our deployment guide
</TextLink>
.
</Paragraph>
<div className="mb-8">
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Endpoint URL</TableHeaderCell>
<TableHeaderCell>Secret</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{unconfiguredEnvironments.map((environment) => {
return (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel
environment={environment}
className="opacity-50"
/>
</TableCell>
<TableCell>
<ClipboardField
className="max-w-[30rem]"
fullWidth={false}
value={environment?.webhookUrl ?? ""}
variant="tertiary/small"
/>
</TableCell>
<TableCell>
<ClipboardField
className="max-w-[10rem]"
fullWidth={false}
value={secret}
secure
variant={"tertiary/small"}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</>
)}
</div>
<HelpContent title="How to connect my HTTP Endpoint">
<HowToConnectHttpEndpoint />
</HelpContent>
</div>
)}
</Help>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,136 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { WhatAreHttpEndpoints } from "~/components/helpContent/HelpContentText";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import { Icon } from "~/components/primitives/Icon";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { HttpEndpointsPresenter } from "~/presenters/HttpEndpointsPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, docsPath, projectHttpEndpointPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
const presenter = new HttpEndpointsPresenter();
const httpEndpoints = await presenter.call({ userId, slug: projectParam });
return typedjson({ httpEndpoints });
};
export default function Page() {
const { httpEndpoints } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="HTTP endpoints" />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("documentation/concepts/triggers/http-endpoints")}
variant="secondary/small"
>
HTTP endpoints documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>
HTTP endpoints allow you to receive webhooks from any API.
</PageDescription>
</PageHeader>
<PageBody>
<Help defaultOpen={true}>
{(open) => (
<div className={cn("grid h-full gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div>
<div className="mb-2 flex items-center justify-end gap-x-2">
<HelpTrigger title="What are HTTP endpoints?" />
</div>
<div className="mb-8">
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Title</TableHeaderCell>
<TableHeaderCell>Updated</TableHeaderCell>
<TableHeaderCell alignment="right">Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{httpEndpoints.length > 0 ? (
httpEndpoints.map((httpEndpoint) => {
const path = projectHttpEndpointPath(organization, project, httpEndpoint);
return (
<TableRow key={httpEndpoint.id}>
<TableCell to={path}>
<div className="flex items-center gap-1">
<Icon icon={httpEndpoint.icon ?? "webhook"} className="h-4 w-4" />
{httpEndpoint.key}
</div>
</TableCell>
<TableCell to={path}>{httpEndpoint.title ?? ""}</TableCell>
<TableCell to={path}>
<DateTime date={httpEndpoint.updatedAt} />
</TableCell>
<TableCell alignment="right" to={path}>
<div className="flex items-center justify-end gap-1">
{httpEndpoint.httpEndpointEnvironments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment.environment}
/>
))}
</div>
</TableCell>
<TableCellChevron to={path} isSticky />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={100}>
<Paragraph>No HTTP endpoints</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</div>
<HelpContent title="How to use HTTP endpoints">
<WhatAreHttpEndpoints />
</HelpContent>
</div>
)}
</Help>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,11 @@
import { Outlet } from "@remix-run/react";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="HTTP endpoints" />,
};
export default function Page() {
return <Outlet />;
}
@@ -1,27 +1,24 @@
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { HowToRunYourJob } from "~/components/helpContent/HelpContentText";
import { Callout } from "~/components/primitives/Callout";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import { RunsTable } from "~/components/runs/RunsTable";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
JobParamsSchema,
projectIntegrationsPath,
jobRunsParentPath,
trimTrailingSlash,
organizationIntegrationsPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "./ListPagination";
import { Callout } from "~/components/primitives/Callout";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useJob } from "~/hooks/useJob";
import simplur from "simplur";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
@@ -53,10 +50,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
};
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Runs" />,
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
@@ -68,11 +61,7 @@ export default function Page() {
return (
<>
{job.hasIntegrationsRequiringAction && (
<Callout
variant="error"
to={projectIntegrationsPath(organization, project)}
className="mb-2"
>
<Callout variant="error" to={organizationIntegrationsPath(organization)} className="mb-2">
{simplur`This Job has ${
job.integrations.filter((j) => j.setupStatus === "MISSING_FIELDS").length
} Integration[|s] that [has|have] not been configured.`}
@@ -3,7 +3,7 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment, useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils/sse/react";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { RunOverview } from "~/components/run/RunOverview";
import { jobMatchId, useJob } from "~/hooks/useJob";
@@ -50,8 +50,6 @@ export const handle: Handle = {
return (
<Fragment>
<BreadcrumbLink to={trimTrailingSlash(jobMatch?.pathname ?? "")} title="Runs" />
<BreadcrumbIcon />
{runData && runData.run && (
<BreadcrumbLink to={match.pathname} title={`Run #${runData.run.number}`} />
)}
@@ -1,6 +1,7 @@
import { JobEnvironment, JobStatusTable } from "~/components/JobsStatusTable";
import { HowToDisableAJob } from "~/components/helpContent/HelpContentText";
import { DeleteJobDialogContent } from "~/components/jobs/DeleteJobModalContent";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
@@ -10,8 +11,13 @@ import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { projectJobsPath, projectPath } from "~/utils/pathBuilder";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Settings" />,
};
export default function Page() {
const job = useJob();
const organization = useOrganization();
@@ -45,15 +51,14 @@ export default function Page() {
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DeleteJobDialogContent
title={job.title}
slug={job.slug}
environments={job.environments}
id={job.id}
redirectTo={projectJobsPath(organization, project)}
/>
</DialogHeader>
<DialogHeader>Delete Job</DialogHeader>
<DeleteJobDialogContent
title={job.title}
slug={job.slug}
environments={job.environments}
id={job.id}
redirectTo={projectJobsPath(organization, project)}
/>
</DialogContent>
</Dialog>
</div>
@@ -9,7 +9,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
@@ -4,10 +4,10 @@ import { Fragment } from "react";
import { typedjson } from "remix-typedjson";
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { JobsMenu } from "~/components/navigation/JobsMenu";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageButtons,
@@ -24,9 +24,12 @@ import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { projectMatchId, useProject } from "~/hooks/useProject";
import { useOptionalRun } from "~/hooks/useRun";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { findJobByParams } from "~/models/job.server";
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
import { JobPresenter } from "~/presenters/JobPresenter.server";
import { requireUserId } from "~/services/session.server";
import { titleCase } from "~/utils";
import { Handle } from "~/utils/handle";
import {
JobParamsSchema,
@@ -41,43 +44,34 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { jobParam, projectParam, organizationSlug } = JobParamsSchema.parse(params);
const jobsPresenter = new JobListPresenter();
const presenter = new JobPresenter();
const job = await presenter.call({
userId,
jobSlug: jobParam,
organizationSlug,
projectSlug: projectParam,
});
const [job, projectJobs] = await Promise.all([
findJobByParams({
userId,
slug: jobParam,
projectSlug: projectParam,
organizationSlug,
}),
jobsPresenter.call({ userId, projectSlug: projectParam }),
]);
if (job === null) {
if (!job) {
throw new Response("Not Found", {
status: 404,
statusText: `There is no Job ${jobParam} in this Project.`,
});
}
//todo identify job
// analytics.job.identify({ job });
return typedjson({
job,
projectJobs,
});
};
export const handle: Handle = {
breadcrumb: (_match, matches) => {
const projectMatch = matches.find((m) => m.id === projectMatchId);
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
return (
<Fragment>
<BreadcrumbLink to={trimTrailingSlash(projectMatch?.pathname ?? "")} title="Jobs" />
<BreadcrumbIcon />
<JobsMenu matches={matches} />
</Fragment>
<BreadcrumbLink
to={trimTrailingSlash(match?.pathname ?? "")}
title={data?.job.title ?? "Job"}
/>
);
},
};
@@ -111,7 +105,12 @@ export default function Job() {
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
<PageInfoProperty icon={job.event.icon} label={"Trigger"} value={job.event.title} />
<PageInfoProperty
icon={job.event.icon}
label={"Trigger"}
value={job.event.title}
to={job.event.link ?? undefined}
/>
{job.dynamic && <PageInfoProperty icon="dynamic" value={"Dynamic"} />}
<PageInfoProperty icon="id" label={"ID"} value={job.slug} />
{job.properties &&
@@ -138,14 +137,7 @@ export default function Job() {
<PageInfoProperty
icon="pulse"
label={"STATUS"}
value={
<JobStatusBadge
enabled={job.status === "ACTIVE"}
hasIntegrationsRequiringAction={job.hasIntegrationsRequiringAction}
hasRuns={job.lastRun !== undefined}
badgeSize="small"
/>
}
value={titleCase(job.status.toLowerCase())}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
@@ -154,14 +146,17 @@ export default function Job() {
</Paragraph>
</PageInfoGroup>
</PageInfoRow>
{job.noRunsHelp && (
<Callout variant="info" to={job.noRunsHelp.link} className="mt-2">
{job.noRunsHelp.text}
</Callout>
)}
<PageTabs
tabs={[
{ label: "Runs", to: jobPath(organization, project, job) },
{ label: "Test", to: jobTestPath(organization, project, job) },
{
label: "Trigger",
to: jobTriggerPath(organization, project, job),
},
{
label: "Settings",
to: jobSettingsPath(organization, project, job),
@@ -1,9 +1,4 @@
import { FrameworkSelector } from "~/components/frameworks/FrameworkSelector";
import { Handle } from "~/utils/handle";
export const handle: Handle = {
expandSidebar: true,
};
export default function Page() {
return (
@@ -1,33 +1,23 @@
import { AstroLogo } from "~/assets/logos/AstroLogo";
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import invariant from "tiny-invariant";
import { AstroLogo } from "~/assets/logos/AstroLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { StepNumber } from "~/components/primitives/StepNumber";
import { useAppOrigin } from "~/hooks/useAppOrigin";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { useDevEnvironment } from "~/hooks/useEnvironments";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { Handle } from "~/utils/handle";
import { projectSetupPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { Callout } from "~/components/primitives/Callout";
import { Badge } from "~/components/primitives/Badge";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Astro" />,
@@ -42,71 +32,69 @@ export default function SetUpAstro() {
const appOrigin = useAppOrigin();
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="mb-12 grid place-items-center">
<AstroLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<AstroLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Astro project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very simple
example Job in <InlineCode variant="extra-small">example.ts</InlineCode> to help you
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Astro app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
</PageGradient>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Astro project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very simple
example Job in <InlineCode variant="extra-small">example.ts</InlineCode> to help you
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Astro app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</div>
</div>
);
}
@@ -2,11 +2,9 @@ import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/sol
import invariant from "tiny-invariant";
import { ExpressLogo } from "~/assets/logos/ExpressLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
@@ -35,86 +33,84 @@ export default function Page() {
const appOrigin = useAppOrigin();
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="mb-12 grid place-items-center">
<ExpressLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<ExpressLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Manually set up Trigger.dev in your existing Express project"
/>
<StepContentContainer className="flex flex-col gap-2">
<Paragraph className="mt-2">Copy your server API Key to your clipboard:</Paragraph>
<div className="mb-2 flex w-full items-center justify-between">
<ClipboardField
secure
className="w-fit"
value={devEnvironment.apiKey}
variant={"secondary/medium"}
icon={<Badge variant="outline">Server</Badge>}
/>
</div>
<Paragraph>Now follow this guide:</Paragraph>
<LinkButton
to="https://trigger.dev/docs/documentation/guides/manual/express"
variant="primary/medium"
TrailingIcon="external-link"
>
Manual installation guide
</LinkButton>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Express app" />
<StepContentContainer>
<RunDevCommand />
<Callout variant="info">
You may be using the `start` script instead, in which case substitute `dev` in the
above commands.
</Callout>
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
</PageGradient>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Manually set up Trigger.dev in your existing Express project"
/>
<StepContentContainer className="flex flex-col gap-2">
<Paragraph className="mt-2">Copy your server API Key to your clipboard:</Paragraph>
<div className="mb-2 flex w-full items-center justify-between">
<ClipboardField
secure
className="w-fit"
value={devEnvironment.apiKey}
variant={"secondary/medium"}
icon={<Badge variant="outline">Server</Badge>}
/>
</div>
<Paragraph>Now follow this guide:</Paragraph>
<LinkButton
to="https://trigger.dev/docs/documentation/guides/manual/express"
variant="primary/medium"
TrailingIcon="external-link"
>
Manual installation guide
</LinkButton>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Express app" />
<StepContentContainer>
<RunDevCommand />
<Callout variant="info">
You may be using the `start` script instead, in which case substitute `dev` in the
above commands.
</Callout>
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { FastifyLogo } from "~/assets/logos/FastifyLogo";
import { FrameworkComingSoon } from "~/components/frameworks/FrameworkComingSoon";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
import { trimTrailingSlash } from "~/utils/pathBuilder";
@@ -1,11 +1,12 @@
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import invariant from "tiny-invariant";
import { NestjsLogo } from "~/assets/logos/NestjsLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { InstallPackages } from "~/components/code/InstallPackages";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -18,7 +19,6 @@ import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { Handle } from "~/utils/handle";
import { projectSetupPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { CodeBlock } from "../../components/code/CodeBlock";
import { TriggerDevStep } from "~/components/SetupCommands";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="NestJS" />,
@@ -126,95 +126,96 @@ export default function SetupNestJS() {
invariant(devEnvironment, "devEnvironment is required");
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 2 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<>
<StepNumber stepNumber="1" title="Add the dependencies" />
<StepContentContainer>
<InstallPackages
packages={["@trigger.dev/sdk", "@trigger.dev/nestjs", "@nestjs/config"]}
/>
</StepContentContainer>
<StepNumber stepNumber="2" title="Add the environment variables" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Inside your <InlineCode>.env</InlineCode> file, create the following env variables:
</Paragraph>
<CodeBlock
fileName=".env"
showChrome
code={`TRIGGER_API_KEY=${devEnvironment.apiKey}\nTRIGGER_API_URL=${appOrigin}`}
/>
</StepContentContainer>
<StepNumber stepNumber="3" title="Add the TriggerDevModule" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, go to your <InlineCode>app.module.ts</InlineCode> and add the{" "}
<InlineCode>TriggerDevModule</InlineCode>:
</Paragraph>
<CodeBlock fileName="app.module.ts" showChrome code={AppModuleCode} />
</StepContentContainer>
<StepNumber stepNumber="4" title="Add the first job" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Create a <InlineCode>controller</InlineCode> called{" "}
<InlineCode>job.controller.ts</InlineCode> and add the following code:
</Paragraph>
<CodeBlock fileName="src/job.controller.ts" showChrome code={JobControllerCode} />
</StepContentContainer>
<StepNumber stepNumber="5" title="Update your app.module.ts" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, add the new <InlineCode>controller</InlineCode> to your{" "}
<InlineCode>app.module.ts</InlineCode>:
</Paragraph>
<CodeBlock fileName="app.module.ts" showChrome code={AppModuleWithControllerCode} />
</StepContentContainer>
<StepNumber stepNumber="6" title="Update your package.json" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, add this to the top-level of your <InlineCode>package.json</InlineCode>:
</Paragraph>
<CodeBlock fileName="package.json" showChrome code={packageJsonCode} />
</StepContentContainer>
<StepNumber stepNumber="7" title="Run your app" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Finally, run your project with <InlineCode>npm run start</InlineCode>:
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="8" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="9" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<NestjsLogo className="w-52" />
</div>
</PageGradient>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<>
<StepNumber stepNumber="1" title="Add the dependencies" />
<StepContentContainer>
<InstallPackages
packages={["@trigger.dev/sdk", "@trigger.dev/nestjs", "@nestjs/config"]}
/>
</StepContentContainer>
<StepNumber stepNumber="2" title="Add the environment variables" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Inside your <InlineCode>.env</InlineCode> file, create the following env variables:
</Paragraph>
<CodeBlock
fileName=".env"
showChrome
code={`TRIGGER_API_KEY=${devEnvironment.apiKey}\nTRIGGER_API_URL=${appOrigin}`}
/>
</StepContentContainer>
<StepNumber stepNumber="3" title="Add the TriggerDevModule" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, go to your <InlineCode>app.module.ts</InlineCode> and add the{" "}
<InlineCode>TriggerDevModule</InlineCode>:
</Paragraph>
<CodeBlock fileName="app.module.ts" showChrome code={AppModuleCode} />
</StepContentContainer>
<StepNumber stepNumber="4" title="Add the first job" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Create a <InlineCode>controller</InlineCode> called{" "}
<InlineCode>job.controller.ts</InlineCode> and add the following code:
</Paragraph>
<CodeBlock fileName="src/job.controller.ts" showChrome code={JobControllerCode} />
</StepContentContainer>
<StepNumber stepNumber="5" title="Update your app.module.ts" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, add the new <InlineCode>controller</InlineCode> to your{" "}
<InlineCode>app.module.ts</InlineCode>:
</Paragraph>
<CodeBlock fileName="app.module.ts" showChrome code={AppModuleWithControllerCode} />
</StepContentContainer>
<StepNumber stepNumber="6" title="Update your package.json" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Now, add this to the top-level of your <InlineCode>package.json</InlineCode>:
</Paragraph>
<CodeBlock fileName="package.json" showChrome code={packageJsonCode} />
</StepContentContainer>
<StepNumber stepNumber="7" title="Run your app" />
<StepContentContainer className="flex flex-col gap-2">
<Paragraph>
Finally, run your project with <InlineCode>npm run start</InlineCode>:
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="8" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="9" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
</div>
);
}
@@ -1,13 +1,14 @@
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import { useState } from "react";
import invariant from "tiny-invariant";
import { NextjsLogo } from "~/assets/logos/NextjsLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import {
ClientTabs,
ClientTabsContent,
@@ -21,14 +22,12 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import { StepNumber } from "~/components/primitives/StepNumber";
import { useAppOrigin } from "~/hooks/useAppOrigin";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { useDevEnvironment } from "~/hooks/useEnvironments";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { Handle } from "~/utils/handle";
import { projectSetupPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { Callout } from "~/components/primitives/Callout";
import { NextjsLogo } from "~/assets/logos/NextjsLogo";
type SelectionChoices = "use-existing-project" | "create-new-next-app";
@@ -47,171 +46,169 @@ export default function SetupNextjs() {
invariant(devEnvironment, "devEnvironment is required");
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="mb-12 grid place-items-center">
<NextjsLogo className="w-56" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in {selectedValue === "create-new-next-app" ? "5" : "2"} minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<RadioGroup
className="mb-4 flex gap-x-2"
onValueChange={(value) => setSelectedValue(value as SelectionChoices)}
>
<RadioGroupItem
label="Use an existing Next.js project"
description="Use Trigger.dev in an existing Next.js project in less than 2 mins."
value="use-existing-project"
checked={selectedValue === "use-existing-project"}
variant="icon"
data-action="use-existing-project"
icon={<NamedIcon className="h-12 w-12 text-green-600" name={"tree"} />}
/>
<RadioGroupItem
label="Create a new Next.js project"
description="This is the quickest way to try out Trigger.dev in a new Next.js project and takes 5 mins."
value="create-new-next-app"
checked={selectedValue === "create-new-next-app"}
variant="icon"
data-action="create-new-next-app"
icon={<NamedIcon className="h-8 w-8 text-green-600" name={"sapling"} />}
/>
</RadioGroup>
{selectedValue && (
<>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for
long-running servers soon.
</Callout>
{selectedValue === "create-new-next-app" ? (
<>
<StepNumber stepNumber="1" title="Create a new Next.js project" />
<StepContentContainer>
<ClientTabs defaultValue="npm">
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"npm"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`npx create-next-app@latest`}
/>
</ClientTabsContent>
<ClientTabsContent value={"pnpm"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`pnpm create next-app`}
/>
</ClientTabsContent>
<ClientTabsContent value={"yarn"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`yarn create next-app`}
/>
</ClientTabsContent>
</ClientTabs>
<Paragraph spacing variant="small">
Trigger.dev works with either the Pages or App Router configuration.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Navigate to your new Next.js project" />
<StepContentContainer>
<Paragraph spacing>
You have now created a new Next.js project. Lets <InlineCode>cd</InlineCode>{" "}
into it using the project name you just provided:
</Paragraph>
<ClipboardField
value={"cd [replace with your project name]"}
variant={"primary/medium"}
></ClipboardField>
</StepContentContainer>
<StepNumber
stepNumber="3"
title="Run the CLI 'init' command in your new Next.js project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="4" title="Run your Next.js app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="5" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
) : (
<>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Next.js project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Next.js app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="4" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
)}
</>
)}
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<NextjsLogo className="w-56" />
</div>
</PageGradient>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in {selectedValue === "create-new-next-app" ? "5" : "2"} minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<RadioGroup
className="mb-4 flex gap-x-2"
onValueChange={(value) => setSelectedValue(value as SelectionChoices)}
>
<RadioGroupItem
label="Use an existing Next.js project"
description="Use Trigger.dev in an existing Next.js project in less than 2 mins."
value="use-existing-project"
checked={selectedValue === "use-existing-project"}
variant="icon"
data-action="use-existing-project"
icon={<NamedIcon className="h-12 w-12 text-green-600" name={"tree"} />}
/>
<RadioGroupItem
label="Create a new Next.js project"
description="This is the quickest way to try out Trigger.dev in a new Next.js project and takes 5 mins."
value="create-new-next-app"
checked={selectedValue === "create-new-next-app"}
variant="icon"
data-action="create-new-next-app"
icon={<NamedIcon className="h-8 w-8 text-green-600" name={"sapling"} />}
/>
</RadioGroup>
{selectedValue && (
<>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
{selectedValue === "create-new-next-app" ? (
<>
<StepNumber stepNumber="1" title="Create a new Next.js project" />
<StepContentContainer>
<ClientTabs defaultValue="npm">
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"npm"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`npx create-next-app@latest`}
/>
</ClientTabsContent>
<ClientTabsContent value={"pnpm"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`pnpm create next-app`}
/>
</ClientTabsContent>
<ClientTabsContent value={"yarn"}>
<ClipboardField
variant="primary/medium"
className="mb-4"
value={`yarn create next-app`}
/>
</ClientTabsContent>
</ClientTabs>
<Paragraph spacing variant="small">
Trigger.dev works with either the Pages or App Router configuration.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Navigate to your new Next.js project" />
<StepContentContainer>
<Paragraph spacing>
You have now created a new Next.js project. Lets <InlineCode>cd</InlineCode> into
it using the project name you just provided:
</Paragraph>
<ClipboardField
value={"cd [replace with your project name]"}
variant={"primary/medium"}
></ClipboardField>
</StepContentContainer>
<StepNumber
stepNumber="3"
title="Run the CLI 'init' command in your new Next.js project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="4" title="Run your Next.js app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="5" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
) : (
<>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Next.js project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Next.js app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="4" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</>
)}
</>
)}
</div>
);
}
@@ -1,6 +1,6 @@
import { NuxtLogo } from "~/assets/logos/NuxtLogo";
import { FrameworkComingSoon } from "~/components/frameworks/FrameworkComingSoon";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
import { trimTrailingSlash } from "~/utils/pathBuilder";
@@ -1,6 +1,6 @@
import { RedwoodLogo } from "~/assets/logos/RedwoodLogo";
import { FrameworkComingSoon } from "~/components/frameworks/FrameworkComingSoon";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
import { trimTrailingSlash } from "~/utils/pathBuilder";
@@ -1,33 +1,23 @@
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import invariant from "tiny-invariant";
import { RemixLogo } from "~/assets/logos/RemixLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { StepNumber } from "~/components/primitives/StepNumber";
import { useAppOrigin } from "~/hooks/useAppOrigin";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { useDevEnvironment } from "~/hooks/useEnvironments";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { Handle } from "~/utils/handle";
import { projectSetupPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { Callout } from "~/components/primitives/Callout";
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { Badge } from "~/components/primitives/Badge";
import { RemixLogo } from "~/assets/logos/RemixLogo";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Remix" />,
@@ -42,71 +32,69 @@ export default function SetUpRemix() {
const appOrigin = useAppOrigin();
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="mb-12 grid place-items-center">
<RemixLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<RemixLogo className="w-64" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Remix project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very simple
example Job in <InlineCode variant="extra-small">example.server.ts</InlineCode> to
help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Remix app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
</PageGradient>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Run the CLI 'init' command in an existing Remix project"
/>
<StepContentContainer>
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
<Paragraph spacing variant="small">
Youll notice a new folder in your project called 'jobs'. Weve added a very simple
example Job in <InlineCode variant="extra-small">example.server.ts</InlineCode> to
help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your Remix app" />
<StepContentContainer>
<RunDevCommand />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</div>
</div>
);
}
@@ -1,26 +1,23 @@
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
import invariant from "tiny-invariant";
import { SvelteKitLogo } from "~/assets/logos/SveltekitLogo";
import { Feedback } from "~/components/Feedback";
import { PageGradient } from "~/components/PageGradient";
import { RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { InlineCode } from "~/components/code/InlineCode";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Header1 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { StepNumber } from "~/components/primitives/StepNumber";
import { useAppOrigin } from "~/hooks/useAppOrigin";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { useDevEnvironment } from "~/hooks/useEnvironments";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
import { Handle } from "~/utils/handle";
import { projectSetupPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { Callout } from "~/components/primitives/Callout";
import { Badge } from "~/components/primitives/Badge";
export const handle: Handle = {
breadcrumb: (match) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="SvelteKit" />
@@ -34,80 +31,81 @@ export default function SetUpSveltekit() {
const devEnvironment = useDevEnvironment();
invariant(devEnvironment, "Dev environment must be defined");
return (
<PageGradient>
<div className="mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
<div className="mx-auto max-w-3xl pt-16">
<div className="mb-12 grid place-items-center">
<SvelteKitLogo className="w-72" />
</div>
<div className="flex items-center justify-between">
<Header1 spacing className="text-bright">
Get setup in 5 minutes
</Header1>
<div className="flex items-center gap-2">
<LinkButton
to={projectSetupPath(organization, project)}
variant="tertiary/small"
LeadingIcon={Squares2X2Icon}
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Follow the steps from the Sveltekit manual installation guide"
/>
<StepContentContainer className="flex flex-col gap-2">
<Paragraph className="mt-2">Copy your server API Key to your clipboard:</Paragraph>
<div className="mb-2 flex w-full items-center justify-between">
<ClipboardField
secure
className="w-fit"
value={devEnvironment.apiKey}
variant={"secondary/medium"}
icon={<Badge variant="outline">Server</Badge>}
/>
</div>
<Paragraph>Now follow this guide:</Paragraph>
<LinkButton
to="https://trigger.dev/docs/documentation/guides/manual/sveltekit"
variant="primary/medium"
TrailingIcon="external-link"
>
Manual installation guide
</LinkButton>
<div className="flex items-start justify-start gap-2"></div>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your sveltekit app" />
<StepContentContainer>
<RunDevCommand extra=" -- --open --host" />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep extra=" --port 5173" />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
Choose a different framework
</LinkButton>
<Feedback
button={
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
</PageGradient>
<div>
<Callout
variant={"info"}
to="https://github.com/triggerdotdev/trigger.dev/discussions/430"
className="mb-8"
>
Trigger.dev has full support for serverless. We will be adding support for long-running
servers soon.
</Callout>
<div>
<StepNumber
stepNumber="1"
title="Follow the steps from the Sveltekit manual installation guide"
/>
<StepContentContainer className="flex flex-col gap-2">
<Paragraph className="mt-2">Copy your server API Key to your clipboard:</Paragraph>
<div className="mb-2 flex w-full items-center justify-between">
<ClipboardField
secure
className="w-fit"
value={devEnvironment.apiKey}
variant={"secondary/medium"}
icon={<Badge variant="outline">Server</Badge>}
/>
</div>
<Paragraph>Now follow this guide:</Paragraph>
<LinkButton
to="https://trigger.dev/docs/documentation/guides/manual/sveltekit"
variant="primary/medium"
TrailingIcon="external-link"
>
Manual installation guide
</LinkButton>
<div className="flex items-start justify-start gap-2"></div>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run your sveltekit app" />
<StepContentContainer>
<RunDevCommand extra=" -- --open --host" />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStep extra=" --port 5173" />
</StepContentContainer>
<StepNumber stepNumber="6" title="Wait for Jobs" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</div>
</div>
);
}
@@ -1,11 +1,11 @@
import { Outlet } from "@remix-run/react";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
import { trimTrailingSlash } from "~/utils/pathBuilder";
export const handle: Handle = {
breadcrumb: (match) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Onboarding" />
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Framework setup" />
),
};
@@ -2,7 +2,6 @@ import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -22,8 +21,7 @@ import { useProject } from "~/hooks/useProject";
import { TriggersPresenter } from "~/presenters/TriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, externalTriggerPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { ProjectParamSchema, externalTriggerPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
@@ -39,13 +37,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson(data);
};
export const handle: Handle = {
breadcrumb: (match) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="External Triggers" />
),
expandSidebar: true,
};
export default function Integrations() {
const { triggers } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
@@ -3,7 +3,7 @@ import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { DateTime } from "~/components/primitives/DateTime";
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
import { NamedIcon } from "~/components/primitives/NamedIcon";
@@ -43,7 +43,6 @@ export const handle: Handle = {
breadcrumb: (match) => (
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Scheduled Triggers" />
),
expandSidebar: true,
};
export default function Integrations() {
@@ -1,6 +1,6 @@
import { Outlet } from "@remix-run/react";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageButtons,
@@ -39,7 +39,7 @@ export default function Page() {
to={docsPath("documentation/concepts/triggers")}
variant="secondary/small"
>
Triggers Documentation
Triggers documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
@@ -4,7 +4,7 @@ import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
@@ -27,7 +27,7 @@ import { requireUser, requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
organizationsPath,
rootPath,
projectTriggersPath,
externalTriggerPath,
externalTriggerRunsParentPath,
@@ -3,7 +3,7 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment, useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils/sse/react";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { RunOverview } from "~/components/run/RunOverview";
import { prisma } from "~/db.server";
@@ -3,13 +3,14 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import invariant from "tiny-invariant";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { ProjectSideMenu, SideMenuContainer } from "~/components/navigation/ProjectSideMenu";
import { ProjectsMenu } from "~/components/navigation/ProjectsMenu";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
import { telemetry } from "~/services/telemetry.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { Handle } from "~/utils/handle";
import { projectPath } from "~/utils/pathBuilder";
@@ -35,9 +36,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
telemetry.project.identify({ project });
return typedjson({
project,
});
const session = await setCurrentProjectId(project.id, request);
return typedjson(
{
project,
},
{
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
}
);
} catch (error) {
if (error instanceof Response) {
throw error;
@@ -52,7 +60,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const handle: Handle = {
breadcrumb: (_match, matches) => <ProjectsMenu matches={matches} />,
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
return <BreadcrumbLink to={match.pathname} title={data?.project.name ?? "Project"} />;
},
scripts: (match) => [
{
src: "https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js",
@@ -64,12 +75,7 @@ export const handle: Handle = {
export default function Project() {
return (
<>
<SideMenuContainer>
<ProjectSideMenu />
<div className="flex-grow">
<Outlet />
</div>
</SideMenuContainer>
<Outlet />
</>
);
}
@@ -30,8 +30,10 @@ import { getTeamMembersAndInvites, removeTeamMember } from "~/models/member.serv
import { redirectWithSuccessMessage } from "~/models/message.server";
import { requireUserId } from "~/services/session.server";
import { inviteTeamMemberPath, organizationTeamPath, resendInvitePath } from "~/utils/pathBuilder";
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
import { DateTime } from "~/components/primitives/DateTime";
import { PageHeader, PageTitleRow, PageTitle } from "~/components/primitives/PageHeader";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -93,6 +95,10 @@ export const action: ActionFunction = async ({ request, params }) => {
type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Team" />,
};
export default function Page() {
const user = useUser();
const { members, invites } = useTypedLoaderData<typeof loader>();
@@ -100,10 +106,14 @@ export default function Page() {
return (
<PageContainer>
<OrgAdminHeader />
<PageHeader>
<PageTitleRow>
<PageTitle title="Team" />
</PageTitleRow>
</PageHeader>
<PageBody>
<Header2>Members</Header2>
<ul className="flex w-full max-w-md flex-col divide-y divide-uiBorder border-b border-uiBorder">
<ul className="flex w-full max-w-md flex-col divide-y divide-ui-border border-b border-ui-border">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
<UserAvatar
@@ -1,63 +1,95 @@
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import invariant from "tiny-invariant";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { useOrganization } from "~/hooks/useOrganizations";
import { getOrganizationFromSlug } from "~/models/organization.server";
import { telemetry } from "~/services/telemetry.server";
import { commitCurrentOrgSession, setCurrentOrg } from "~/services/currentOrganization.server";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { SideMenu } from "~/components/navigation/SideMenu";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { Handle } from "~/utils/handle";
import { organizationPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationSlug: z.string(),
projectParam: z.string().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
const impersonationId = await getImpersonationId(request);
const organization = await getOrganizationFromSlug({
const { organizationSlug, projectParam } = ParamsSchema.parse(params);
const orgsPresenter = new OrganizationsPresenter();
const { organizations, organization, project } = await orgsPresenter.call({
userId,
slug: organizationSlug,
request,
organizationSlug,
projectSlug: projectParam,
});
if (organization === null) {
throw new Response("Not Found", { status: 404 });
}
telemetry.organization.identify({ organization });
const session = await setCurrentOrg(organization.slug, request);
return typedjson({
organizations,
organization,
currentProject: project,
isImpersonating: !!impersonationId,
});
};
return typedjson(
{
organization,
},
{
headers: {
"Set-Cookie": await commitCurrentOrgSession(session),
},
}
);
export const handle: Handle = {
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
return (
<BreadcrumbLink to={match.pathname} title={data?.organization.title ?? "Organization"} />
);
},
};
export default function Organization() {
const { organization, currentProject, organizations, isImpersonating } =
useTypedLoaderData<typeof loader>();
const user = useUser();
//the side menu won't change projects when using the switcher unless we use the hook (on project pages)
const project = useOptionalProject() ?? currentProject;
return (
<>
<Outlet />
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<SideMenu
user={{ ...user, isImpersonating }}
project={project}
organization={organization}
organizations={organizations}
/>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border pr-2">
<Breadcrumb />
<PageNavigationIndicator />
</div>
<Outlet />
</div>
</div>
</>
);
}
export function ErrorBoundary() {
const org = useOrganization();
return <RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />;
const org = useOptionalOrganization();
return org ? (
<RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />
) : (
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
);
}
export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
if (options.formAction === "/resources/environment") {
return false;
}
return true;
};
+33 -10
View File
@@ -1,11 +1,12 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import type { ActionFunction, LoaderFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
@@ -14,16 +15,27 @@ import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { projectPath } from "~/utils/pathBuilder";
import { projectPath, rootPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
projectName: z.string().min(3).max(50),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const presenter = new NewOrganizationPresenter();
const { hasOrganizations } = await presenter.call({ userId });
return typedjson({
hasOrganizations,
});
};
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
@@ -41,17 +53,21 @@ export const action: ActionFunction = async ({ request }) => {
projectName: submission.value.projectName,
});
return redirectWithSuccessMessage(
projectPath(organization, organization.projects[0]),
request,
`${submission.value.orgName} created`
);
const project = organization.projects[0];
const session = await setCurrentProjectId(project.id, request);
return redirect(projectPath(organization, project), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(session),
},
});
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const [form, { orgName, projectName }] = useForm({
@@ -96,6 +112,13 @@ export default function NewOrganizationPage() {
Create
</Button>
}
cancelButton={
hasOrganizations ? (
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
) : null
}
/>
</Fieldset>
</Form>
+11 -47
View File
@@ -1,70 +1,34 @@
import { Outlet, ShouldRevalidateFunction } from "@remix-run/react";
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { redirect, typedjson } from "remix-typedjson";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { ImpersonationBanner } from "~/components/ImpersonationBanner";
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { NavBar } from "~/components/navigation/NavBar";
import { useIsProjectChildPage } from "~/hooks/useIsProjectChildPage";
import { getOrganizations } from "~/models/organization.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { useIsOrgChildPage } from "~/hooks/useIsOrgChildPage";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { requireUser } from "~/services/session.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const organizations = await getOrganizations({ userId: user.id });
const impersonationId = await getImpersonationId(request);
//you have to confirm basic details before you can do anything
if (!user.confirmedBasicDetails) {
return redirect(confirmBasicDetailsPath());
}
return typedjson(
{
organizations,
impersonationId,
},
{
headers: [["Set-Cookie", await commitSession(await clearRedirectTo(request))]],
}
);
};
export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
//added an org
if (options.formAction === "/orgs/new") return true;
//added a project
if (options.formAction && /^\/orgs\/[^\/]+\/projects\/new$/i.test(options.formAction)) {
return true;
}
//left a team
if (options.formAction && /^\/orgs\/[^\/]+\/team$/i.test(options.formAction)) {
return true;
}
return false;
return typedjson({
headers: [["Set-Cookie", await commitSession(await clearRedirectTo(request))]],
});
};
export default function App() {
const { impersonationId } = useTypedLoaderData<typeof loader>();
const isProjectChildPage = useIsProjectChildPage();
const showBackgroundGradient = !isProjectChildPage;
const isOrgChildPage = useIsOrgChildPage();
const showBackgroundGradient = !isOrgChildPage;
return (
<>
{impersonationId && <ImpersonationBanner impersonationId={impersonationId} />}
<AppContainer showBackgroundGradient={showBackgroundGradient}>
<NavBar />
<Outlet />
</AppContainer>
</>
<AppContainer showBackgroundGradient={showBackgroundGradient}>
<Outlet />
</AppContainer>
);
}
@@ -1,5 +1,5 @@
import { Form, useActionData } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
@@ -12,13 +12,15 @@ import { z } from "zod";
import { ActionFunction, json, redirect } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { parse } from "@conform-to/zod";
import { accountPath } from "~/utils/pathBuilder";
import { accountPath, rootPath } from "~/utils/pathBuilder";
import { conform, useForm } from "@conform-to/react";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { Checkbox } from "~/components/primitives/Checkbox";
import { updateUser } from "~/models/user.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { prisma } from "~/db.server";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { FormTitle } from "~/components/primitives/FormTitle";
function createSchema(
constraints: {
@@ -121,53 +123,63 @@ export default function Page() {
});
return (
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<AppContainer showBackgroundGradient={true}>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
cancelButton={
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
</AppContainer>
);
}

Some files were not shown because too many files have changed in this diff Show More