Compare commits

...

78 Commits

Author SHA1 Message Date
Eric Allam af57bc208a Update pnpm lock file 2023-11-10 16:54:06 +00:00
Eric Allam 7cbbb26038 Merge pull request #730 from triggerdotdev/changeset-release/main
chore: Update version for release
2023-11-10 16:49:47 +00:00
github-actions[bot] 2e5f8d8de3 chore: Update version for release 2023-11-10 16:05:39 +00:00
Eric Allam 9a7c08c26a Improvements: Fix dangling SSE issue and compression memory leak (#733)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Downgrade to remix-auth-email-link to remove yarn dependency

* Turn off the pg listen service for now

* Add snapshot admin route

* A couple logger fixes

* Add ability to disable compression

* Add ability to disable SSE

* Fix SSE memory leak + DB load issue
2023-11-10 16:02:53 +00:00
Eric Allam e8e7c116d1 Remove the pg listen code to see if it’s causing DB issues 2023-11-09 21:52:48 +00:00
Matt Aitken 99dd6673f9 Test page display a message if there are no environments that the logged in user can run tests with 2023-11-09 20:12:20 +00:00
Matt Aitken a41d9b3e67 Fix for first endpoint sheet React error 2023-11-09 20:11:36 +00:00
Eric Allam cb1825bfaf Add OpenAI support for 4.16.0 (#726)
* Add OpenAI support for 4.16.0

* Add support for background polling and use that in OpenAI integration to power assistants

* Much improved OpenAI docs

* Added backgroundPoll docs

* Implements waitForEvent and added docs for more built in tasks

* Add sendEvent API referenc

* Write the task libray

* Add changeset and warning for waitForEvent
2023-11-09 16:59:58 +00:00
nicktrn d02173442c Feature: io.sendEvents() (#728)
* Add io.sendEvents

* Add examples to built-ins catalog entry

* Add docs

* Add changeset

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-11-09 16:42:23 +00:00
Matt Aitken 9f1f59cc81 Added the redirect to the invites page back in… 2023-11-09 16:30:21 +00:00
Matt Aitken e48c9b5e69 Made it clearer in the HTTP endpoints docs that we’re using Cal.com as an example 2023-11-08 10:15:42 +00:00
Eric Allam 55a9b96c88 Remove the coming soon warning 2023-11-07 16:31:44 +00:00
Matt Aitken dc8c8edf04 Latest lockfile 2023-11-07 16:27:31 +00:00
github-actions[bot] 41927edf12 chore: Update version for release (#718)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-07 16:26:41 +00:00
James Ritchie 1afba46cf0 Made the no-runs message consistent with the others
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-07 15:06:42 +00:00
James Ritchie 32e4fc6310 Updated our registered address on the magic link email 2023-11-07 14:57:04 +00:00
Matt Aitken 0add95e224 The auth method is now displayed correctly 2023-11-07 14:51:04 +00:00
Matt Aitken de7f133ef9 The Job List can shows jobs across projects now 2023-11-07 14:50:52 +00:00
Matt Aitken 8b13681afc Removed the project + from the sidemenu 2023-11-07 14:30:04 +00:00
Matt Aitken 88baac2144 Improved the side menu project styling 2023-11-07 13:42:56 +00:00
Matt Aitken df664ad472 Removed “New project” from the project selector. Added a + button to the Proejcts side menu 2023-11-07 12:46:41 +00:00
James Ritchie d8c53784f7 Triple dot menus now dismiss when you click an option 2023-11-07 11:45:53 +00:00
James Ritchie 3c7b2de095 Clearer state for unsupported frameworks 2023-11-07 11:45:53 +00:00
James Ritchie ae9249405c Autofocus input fields 2023-11-07 11:45:53 +00:00
James Ritchie d63f4e2451 User avatar now displays if one is set 2023-11-07 11:45:53 +00:00
Matt Aitken f4275e5054 Added a changeset for verifyRequestSignature – added an error if the passed in secret is undefined or empty 2023-11-07 10:54:08 +00:00
Matt Aitken 690b308fea verifyRequestSignature – added an error if the passed in secret is undefined or empty 2023-11-07 10:52:55 +00:00
Eric Allam a74716a1b9 Added waitForRequest built-in tasks (#725)
* Added waitForRequest built-in tasks

* Create grumpy-buttons-thank.md
2023-11-06 18:30:49 +00:00
Eric Allam 42185bc36b Upgrade resend integration to use resend-node 2.0.0 (#724)
* Upgrade resend integration to use resend-node 2.0.0, fixes issue with Next.js 14

* Create mighty-dodos-study.md
2023-11-06 18:30:37 +00:00
Eric Allam 276181b91c Fixed HttpSourceRequest import 2023-11-06 17:55:34 +00:00
Eric Allam 578d2e5410 Fixed Buffer reference error in the browser 2023-11-06 17:51:47 +00:00
Eric Allam 329944841f Fix the cancel event example in job ref 2023-11-06 17:38:18 +00:00
Eric Allam 4a0f030ebf Adding no-cache to our client fetch to fix Next.js POST caching 2023-11-06 17:38:18 +00:00
Matt Aitken 13345d0025 e2e failure was caused by… a space 2023-11-06 17:02:27 +00:00
Matt Aitken d905059915 e2e moving to project correctly 2023-11-06 16:36:05 +00:00
Matt Aitken 0d8530028f Added “test:e2e:ui” script so you can see Playwright running 2023-11-06 16:35:11 +00:00
Matt Aitken c37e7f3030 Fixed docs link 2023-11-06 16:34:33 +00:00
Matt Aitken 6d400c3b57 Fix for trigger link showing on non-http endpoint jobs 2023-11-06 16:34:12 +00:00
Matt Aitken 2b8a44aed1 Fix docs link to HTTP endpoints 2023-11-06 16:11:23 +00:00
Matt Aitken 6d842b3abb Added shortcodes to the e2e test suite 2023-11-06 15:54:55 +00:00
Matt Aitken 9c6838e5c8 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>
2023-11-06 15:32:40 +00:00
Abdou Belghalem 10e935b864 fix(TRI-1444): Allow only logins from WHITELISTED_EMAILS regex (fixes #685) (#721) 2023-11-06 09:06:54 +00:00
Eric Allam 02e453c3b5 Fix a ReferenceError when using OpenAI.backgroundCreateChatCompletion 2023-11-03 22:41:23 +00:00
James Ritchie 330ddb3311 NoTask message only displays if the run has status “SUCCESS” 2023-11-03 14:53:43 +00:00
Eric Allam 14a2f4cc94 Fix lock file 2023-11-03 11:26:16 +00:00
Alexandre Costa 7e57f1f3dd [TRI-1449] Display warning message when duplicate job IDs are detected (#699)
* add warning message when duplicate job IDs are detected

* fix warning message for duplicated job IDs in defineJob

* Create bright-buckets-bow.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2023-11-03 11:10:26 +00:00
nicktrn cf8f994604 Feature: io.random() (#716)
* Add io.random

* Add changeset

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2023-11-03 11:07:12 +00:00
Hemachandar 2ae5178479 fix: show only the current user's dev env in run-test page (#711) 2023-11-03 11:06:36 +00:00
Eric Allam 620b83832b Implement manually invokable jobs through the invokeTrigger (#700)
* Implement manually invokable jobs through the invokeTrigger

Also implemented a job run notification system, that will POST details of a run on completion. This combines with the task callbackUrl system to implement the invokeAndWait

* Document the invoke trigger

* batch invoke and wait

* background fetch timeouts

* Use @whatwg-node/fetch instead of the polyfilled fetch

* Fix some outdated dependencies in webapp

* Improved subtask error propogation messages

* Document the OpenAI changes and the batch invoke stuff

* Fix dequeuing jobs

* Don’t retry the OpenAI completion background task

* Added OpenAI changesets

* Use the new ResumeTaskService in ProcessCallbackTimeout as well
2023-11-03 11:05:00 +00:00
D-K-P c7434125e4 Removed Hacktoberfest from the readme 2023-11-01 15:12:01 +00:00
Eric Allam 67b5365d50 Fix pnpm lock 2023-11-01 10:16:52 +00:00
github-actions[bot] ea25c37a10 chore: Update version for release (#709)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-01 09:19:10 +00:00
Eric Allam c1710ae72d fix: Buffer import breaks @trigger.dev/react (#714) 2023-10-31 15:28:55 +00:00
nicktrn 9c5a7cf073 Fix: Allow errors and special values on Airtable field set (#697)
* Allow errors and special values on field set

* Add changeset
2023-10-31 10:17:05 +00:00
Eric Allam 8eedf994af Fixed call -> on 2023-10-30 21:58:04 +00:00
nicktrn 2c91c492df Feature: Cross-instance notification system (#689)
* Add notification system

* Graceful worker shutdown on migration notification

* Add notification catalog

* Rename pgListen call to on
2023-10-30 17:08:16 +00:00
Hemachandar b859abdb66 fix: remove unstable createRemixStub in storybook (#707) 2023-10-30 17:04:29 +00:00
James Ritchie 0876b87526 No Tasks in a Run warning message (#688)
* Card hover state is now grey

* Added a callout warning if you don’t have any tasks

* Conditionally show the no-task warning at the top of the run

* New job to test the no-task warning message

* Created a new example Job for the CLI that uses Tasks

* Updated the Next.js cli joke job

* Updated the Remix app cli job example

* Updated the Astro app cli job example

* Made sure the example job works in remix

* Added changeset

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2023-10-30 16:24:46 +00:00
Hemachandar 9c4be40adc fix: append nanoid for dynamic trigger reg event (#628)
* fix: append nanoid for dynamic trigger reg event

* use idempotency-key as event-id

* add changeset
2023-10-30 16:24:03 +00:00
D-K-P 2635f4bd10 typo fix 2023-10-30 14:36:17 +00:00
nicktrn 401b7ee03d Set NODE_ENV to production in supabase docs 2023-10-28 13:09:01 +00:00
Eric Allam 6881de84ab Fix pnpm lock file 2023-10-26 20:04:03 +01:00
github-actions[bot] 0ca8296b9a chore: Update version for release (#686)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-10-26 20:03:37 +01:00
Eric Allam 55946fcdb7 Update @trigger.dev/nextjs to support Next.js 14 (#690) 2023-10-26 19:45:19 +01:00
Eric Allam c0f9f10b07 Upgrade to Remix v2.1.0 2023-10-26 15:54:21 +01:00
Eric Allam e80aa2826d @trigger.dev/express: Remove web-fetch dependency and use native node Request/Response 2023-10-26 13:42:20 +01:00
NIXEY 1be25d7639 BlankRow centered (#678) 2023-10-26 13:35:15 +01:00
ROMIT PAL 3c3a5e1f28 added a missing CODE OF CONDUCT file (#677)
Incorporating a Code of Conduct in your GitHub repository is crucial for promoting an inclusive, respectful community. It establishes guidelines, minimizes conflicts, safeguards participants, and adheres to legal standards. Additionally, it demonstrates professionalism and aligns with GitHub's best practices, contributing to a welcoming and harmonious environment, which, in turn, enhances collaboration and the project's overall success.
2023-10-26 13:30:14 +01:00
Vineet J Karni 8e741c7218 Added warning ⚠️ - No support for long-running servers (#669) 2023-10-26 13:18:28 +01:00
Hemachandar 83455e3ad4 feat: implement API key regeneration (#649) 2023-10-26 13:15:50 +01:00
Ronit Panda f0f0420280 feat: adds helm charts (#636)
* feat: adds helm charts template and adds postgres deps

* feat: adds all depl (barebones)

* feat: adds all depl (barebones)

* fix: removes print

* fix: image pulling issue

* fix: templatisation complete

* style: cleanup

* feat: works

* feat: adds ingress and some docs

* fix: ingress bug --> routing to cluster works

* feat: completes docs v1 todo --> needs hosting of the helm chart on chosen platform
2023-10-26 11:50:59 +01:00
Hemachandar c4533c36cb fix: set error message in runTask & executeJob (#634)
* fix: set error message in runTask & executeJob

* add changeset
2023-10-26 11:46:09 +01:00
Eric Allam f8e034c190 Fixed import that was causing typecheck to fail 2023-10-26 11:43:23 +01:00
Eric Allam 21f62e780c Added tests and refactored response to use camelCase 2023-10-26 11:30:49 +01:00
hmacr c3564ca0d4 fix doc 2023-10-26 11:18:11 +01:00
hmacr 6e1b8a11d4 feat: allow cancelling job runs for an event-id 2023-10-26 11:18:11 +01:00
Matt Aitken 9900f19b70 Migration: Updates any Endpoint runChunkExecutionLimits that were 60ms to be 60000ms 2023-10-25 15:50:11 +01:00
Matt Aitken 237545754f Indexing errors weren’t being serialized to JSON properly in some cases, so the errors weren’t getting inserted
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-10-24 18:08:46 +01:00
479 changed files with 19201 additions and 4828 deletions
+2
View File
@@ -13,6 +13,8 @@ APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
+2
View File
@@ -52,3 +52,5 @@ apps/**/public/build
/test-results/
/playwright-report/
/playwright/.cache/
.cosine
+2 -1
View File
@@ -1,3 +1,4 @@
{
"deno.enablePaths": ["references/deno-reference"]
"deno.enablePaths": ["references/deno-reference"],
"debug.toolBarLocation": "commandCenter"
}
+43
View File
@@ -0,0 +1,43 @@
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct that could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
-12
View File
@@ -14,18 +14,6 @@
</div>
# ✨🎃 Get involved with Hacktoberfest 2023! 🎃✨
All of October we're participating in Hacktoberfest and invite you to join us! We have a bunch of issues labeled `🎃 Hacktoberfest` that are ready for you to work on which will count towards Hacktoberfest. We are also running our own game, earn 💎 points to win swag!
- Check out our [Hacktoberfest landing page](https://trigger.dev/hacktoberfest) for how to participate and win swag.
- Contribute to either our [/trigger.dev](https://github.com/triggerdotdev/trigger.dev/labels/%F0%9F%8E%83%20hacktoberfest), [/api-reference](https://github.com/triggerdotdev/api-reference/issues?q=is%3Aopen+is%3Aissue+label%3A%F0%9F%8E%83hacktoberfest) or [/jobs-showcase](https://github.com/triggerdotdev/jobs-showcase/labels/%F0%9F%8E%83%20hacktoberfest) repositories and complete issues marked `🎃 Hacktoberfest` to be eligible for swag.
- Join our [Discord](https://discord.gg/JtBAxBr2m3) and get involved in with the community.
_New to Hacktober? Check out the [Hacktoberfest website](https://hacktoberfest.digitalocean.com/) for more information._
🎃 **Happy Hacking!** 🎃
# About Trigger.dev
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
+6 -3
View File
@@ -1,6 +1,6 @@
import type { Preview } from "@storybook/react";
import "../app/tailwind.css";
import { unstable_createRemixStub } from "@remix-run/testing";
import { createRemixStub } from "@remix-run/testing";
import React from "react";
import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider";
@@ -23,13 +23,16 @@ const preview: Preview = {
},
],
},
layout: "fullscreen",
},
decorators: [
(Story) => {
const RemixStub = unstable_createRemixStub([
const RemixStub = createRemixStub([
{
path: "/*",
element: <Story />,
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>
);
}
+2 -1
View File
@@ -36,7 +36,8 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
const [form, { path, feedbackType, message }] = useForm({
id: "accept-invite",
lastSubmission,
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
@@ -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>
@@ -0,0 +1,105 @@
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 { Header1 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
type ModalProps = {
id: string;
title: string;
};
type ModalContentProps = ModalProps & {
randomWord: string;
closeModal: () => void;
};
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
const randomWord = generateTwoRandomWords();
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="tertiary/small"
leadingIconClassName="text-dimmed"
LeadingIcon={ArrowPathIcon}
>
Regenerate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
<RegenerateApiKeyModalContent
id={id}
title={title}
randomWord={randomWord}
closeModal={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
}
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
const [confirmationText, setConfirmationText] = useState("");
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
// form submission completed
if (fetcher.state === "loading") {
closeModal();
}
return (
<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>
<fetcher.Form
method="post"
action={`/resources/environments/${id}/regenerate-api-key`}
className="mt-2 flex w-full flex-col items-center gap-2"
>
<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>
);
}
@@ -93,8 +90,7 @@ function FrameworkLink({ children, to, supported = false }: FrameworkLinkProps)
to={to}
className={cn(
"flex h-28 w-full items-center justify-center rounded-md border border-slate-750 px-8 py-4 transition hover:bg-slate-850",
!supported &&
"border-dashed opacity-50 grayscale transition hover:opacity-100 hover:grayscale-0"
!supported && "border opacity-30 grayscale transition hover:opacity-100 hover:grayscale-0"
)}
>
{children}
@@ -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/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/http-endpoints")}>
Read the HTTP endpoints guide to learn more.
</Callout>
</>
);
}
@@ -46,7 +46,8 @@ export function ConnectToOAuthForm({
const [form, { title, slug, scopes, hasCustomClient, customClientId, customClientSecret }] =
useForm({
lastSubmission: fetcher.data,
// TODO: type this
lastSubmission: fetcher.data as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
@@ -45,7 +45,8 @@ export function UpdateOAuthForm({
const { isManagedCloud } = useFeatures();
const [form, { title, scopes, hasCustomClient, customClientId, customClientSecret }] = useForm({
lastSubmission: fetcher.data,
// TODO: type this
lastSubmission: fetcher.data as any,
onValidate({ formData }) {
return parse(formData, {
schema,
@@ -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>
+12 -14
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";
@@ -27,10 +27,9 @@ import { JobStatusBadge } from "./JobStatusBadge";
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
const organization = useOrganization();
const project = useProject();
return (
<Table>
<Table containerClassName="mb-4">
<TableHeader>
<TableRow>
<TableHeaderCell>Job</TableHeaderCell>
@@ -45,7 +44,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<TableBody>
{jobs.length > 0 ? (
jobs.map((job) => {
const path = jobPath(organization, project, job);
const path = jobPath(organization, { slug: job.projectSlug }, job);
return (
<TableRow key={job.id} className="group">
<TableCell to={path}>
@@ -153,26 +152,25 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<TableCellMenu isSticky>
<PopoverMenuItem to={path} title="View Job" icon="eye" />
<PopoverMenuItem
to={jobTestPath(organization, project, job)}
to={jobTestPath(organization, { slug: job.projectSlug }, job)}
title="Test Job"
icon="beaker"
/>
<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>
);
}
@@ -1,26 +1,35 @@
import { RouteMatch, useMatches } from "@remix-run/react";
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: RouteMatch, allMatches: RouteMatch[]) => ReactNode;
export type BreadcrumbItem = (match: UIMatch, allMatches: UIMatch[]) => ReactNode;
export function Breadcrumb() {
const matches = useMatches();
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, RouteMatch } 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: RouteMatch[] }) {
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 { RouteMatch } 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: RouteMatch[] }) {
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,431 @@
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"
/>
);
})}
</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">
<UserProfilePhoto
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 [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
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) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger 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>
@@ -2,6 +2,7 @@ import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { SimpleTooltip } from "./Tooltip";
import { Link } from "@remix-run/react";
const variations = {
primary: {
@@ -45,18 +46,7 @@ export function LabelValueStack({
<Paragraph variant={variation.label}>{label}</Paragraph>
<>
{href ? (
<SimpleTooltip
side="bottom"
button={
<Paragraph variant={variation.value}>
<a href={href} className="underline underline-offset-2" target="_blank">
{value}
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
</a>
</Paragraph>
}
content={href}
/>
<ValueButton value={value} href={href} variant={variant} />
) : (
<Paragraph variant={variation.value}>{value}</Paragraph>
)}
@@ -64,3 +54,40 @@ export function LabelValueStack({
</div>
);
}
type ValueButtonStackProps = {
value: React.ReactNode;
href: string;
variant?: keyof typeof variations;
};
function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackProps) {
const variation = variations[variant];
const isExternalUrl = href.startsWith("http");
if (!isExternalUrl) {
return (
<Paragraph variant={variation.value}>
<Link to={href} reloadDocument className="underline underline-offset-2">
{value}
</Link>
</Paragraph>
);
}
return (
<SimpleTooltip
side="bottom"
button={
<Paragraph variant={variation.value}>
<a href={href} className="underline underline-offset-2" target="_blank">
{value}
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
</a>
</Paragraph>
}
content={href}
/>
);
}
@@ -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,10 @@ 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 +96,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 }) => (
+1 -1
View File
@@ -36,7 +36,7 @@ export function RunPanel({
? "border-slate-850"
: "border-slate-900",
onClick && "cursor-pointer",
onClick && !selected && "hover:border-green-500/30",
onClick && !selected && "hover:border-slate-500/30",
className
)}
onClick={() => onClick && onClick()}
+45 -27
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,
@@ -159,6 +160,17 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
<div className="grid h-full grid-cols-2 gap-2">
<div className="flex flex-col gap-6 overflow-y-auto py-4 pl-4 pr-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div>
{run.status === "SUCCESS" &&
(run.tasks.length === 0 || run.tasks.every((t) => t.noop)) && (
<Callout
variant={"warning"}
to="https://trigger.dev/docs/documentation/concepts/tasks"
className="mb-4"
>
This Run completed but it did not use any Tasks this can cause unpredictable
results. Read the docs to view the solution.
</Callout>
)}
<Header2 className="mb-2">Trigger</Header2>
<RunPanel
selected={selectedId === "trigger"}
@@ -251,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>
@@ -262,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>
@@ -313,7 +327,8 @@ function RerunPopover({
const [form, { successRedirect }] = useForm({
id: "rerun",
lastSubmission,
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
@@ -326,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>
@@ -386,7 +403,8 @@ export function CancelRun({ runId }: { runId: string }) {
const [form, { redirectUrl }] = useForm({
id: "cancel-run",
lastSubmission,
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: cancelSchema });
},
@@ -28,7 +28,7 @@ import {
} from "../primitives/Table";
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
import { TaskStatusIcon } from "./TaskStatus";
import { ClientOnly } from "remix-utils";
import { ClientOnly } from "remix-utils/client-only";
import { Spinner } from "../primitives/Spinner";
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
+3 -10
View File
@@ -1,14 +1,10 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunList } from "~/presenters/RunListPresenter.server";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { formatDuration } from "~/utils";
import { JobForPath, OrgForPath, ProjectForPath, jobRunDashboardPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { Callout } from "../primitives/Callout";
import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import {
Table,
@@ -21,7 +17,6 @@ import {
TableRow,
} from "../primitives/Table";
import { RunStatus } from "./RunStatuses";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
type RunTableItem = {
id: string;
@@ -129,9 +124,7 @@ export function RunsTable({
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Callout variant="warning" className="w-auto">
{title}
</Callout>
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
+2 -19
View File
@@ -3,6 +3,7 @@ import invariant from "tiny-invariant";
import { z } from "zod";
import { logger } from "./services/logger.server";
import { env } from "./env.server";
import { singleton } from "./utils/singleton";
export type PrismaTransactionClient = Omit<
PrismaClient,
@@ -66,24 +67,7 @@ export async function $transaction<R>(
export { Prisma };
let prisma: PrismaClient;
declare global {
var __db__: PrismaClient;
}
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
// in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === "production") {
prisma = getClient();
} else {
if (!global.__db__) {
global.__db__ = getClient();
}
prisma = global.__db__;
}
export const prisma = singleton("prisma", getClient);
function getClient() {
const { DATABASE_URL } = process.env;
@@ -143,7 +127,6 @@ function getClient() {
return client;
}
export { prisma };
export type { PrismaClient } from "@trigger.dev/database";
export const PrismaErrorSchema = z.object({
+80 -56
View File
@@ -1,6 +1,9 @@
import { H } from "@highlight-run/node";
import type { DataFunctionArgs, EntryContext, Headers } from "@remix-run/node"; // or cloudflare/deno
import { Response } from "@remix-run/node"; // or cloudflare/deno
import {
createReadableStreamFromReadable,
type DataFunctionArgs,
type EntryContext,
} from "@remix-run/node"; // or cloudflare/deno
import { RemixServer } from "@remix-run/react";
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
@@ -36,7 +39,7 @@ export default function handleRequest(
// response to render before sending it to the client. This
// ensures that bots can see the full page content.
if (isbot(request.headers.get("user-agent"))) {
return serveTheBots(
return handleBotRequest(
request,
responseStatusCode,
responseHeaders,
@@ -46,7 +49,7 @@ export default function handleRequest(
);
}
return serveBrowsers(
return handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
@@ -56,7 +59,7 @@ export default function handleRequest(
);
}
function serveTheBots(
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
@@ -65,76 +68,97 @@ function serveTheBots(
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
// Use onAllReady to wait for the entire document to be ready
onAllReady() {
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
let body = new PassThrough();
pipe(body);
resolve(
new Response(body, {
status: responseStatusCode,
headers: responseHeaders,
})
);
},
onShellError(err: unknown) {
reject(err);
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
function serveBrowsers(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
let didError = false;
let shellReady = false;
const { pipe, abort } = renderToPipeableStream(
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
// use onShellReady to wait until a suspense boundary is triggered
onShellReady() {
shellReady = true;
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
let body = new PassThrough();
pipe(body);
resolve(
new Response(body, {
status: didError ? 500 : responseStatusCode,
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(err: unknown) {
reject(err);
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
didError = true;
if (shellReady) {
logError(error, request);
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
+9
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
import { isValidRegex } from "./utils/regex";
const EnvironmentSchema = z.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -10,6 +11,10 @@ const EnvironmentSchema = z.object({
SESSION_SECRET: z.string(),
MAGIC_LINK_SECRET: z.string(),
ENCRYPTION_KEY: z.string(),
WHITELISTED_EMAILS: z
.string()
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
.optional(),
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
@@ -40,7 +45,11 @@ const EnvironmentSchema = z.object({
EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
WORKER_ENABLED: z.string().default("true"),
EXECUTION_WORKER_ENABLED: z.string().default("true"),
TASK_OPERATION_WORKER_ENABLED: z.string().default("true"),
TASK_OPERATION_WORKER_CONCURRENCY: z.coerce.number().int().default(10),
TASK_OPERATION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000),
DISABLE_SSE: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+4 -4
View File
@@ -1,17 +1,17 @@
import { RouteMatch } from "@remix-run/react";
import { UIMatch } from "@remix-run/react";
import { MatchedProject, useOptionalProject } from "./useProject";
import { useUser } from "./useUser";
export type ProjectJobEnvironment = MatchedProject["environments"][number];
export function useEnvironments(matches?: RouteMatch[]) {
export function useEnvironments(matches?: UIMatch[]) {
const project = useOptionalProject(matches);
if (!project) return;
return project.environments;
}
export function useDevEnvironment(matches?: RouteMatch[]) {
export function useDevEnvironment(matches?: UIMatch[]) {
const user = useUser();
const environments = useEnvironments(matches);
if (!environments) return;
@@ -21,7 +21,7 @@ export function useDevEnvironment(matches?: RouteMatch[]) {
);
}
export function useProdEnvironment(matches?: RouteMatch[]) {
export function useProdEnvironment(matches?: UIMatch[]) {
const environments = useEnvironments(matches);
if (!environments) return;
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
type EventSourceOptions = {
init?: EventSourceInit;
event?: string;
};
/**
* Subscribe to an event source and return the latest event.
* @param url The URL of the event source to connect to
* @param options The options to pass to the EventSource constructor
* @returns The last event received from the server
*/
export function useEventSource(
url: string | URL,
{ event = "message", init }: EventSourceOptions = {}
) {
const [data, setData] = useState<string | null>(null);
useEffect(() => {
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
// rest data if dependencies change
setData(null);
function handler(event: MessageEvent) {
setData(event.data || "UNKNOWN_EVENT_DATA");
}
return () => {
eventSource.removeEventListener(event ?? "message", handler);
eventSource.close();
};
}, [url, event, init]);
return data;
}
+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,21 +1,21 @@
import { RouteMatch } from "@remix-run/react";
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?: RouteMatch[]) {
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,
});
return routeMatch?.client;
}
export function useIntegrationClient(matches?: RouteMatch[]) {
export function useIntegrationClient(matches?: UIMatch[]) {
const integration = useOptionalIntegrationClient(matches);
invariant(integration, "Integration must be defined");
return integration;
@@ -1,11 +1,11 @@
import { RouteMatch, useMatches } from "@remix-run/react";
import { UIMatch, useMatches } from "@remix-run/react";
export function useIsProjectChildPage(matches?: RouteMatch[]) {
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");
});
}
+6 -5
View File
@@ -2,27 +2,28 @@ import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
import { useChanged } from "./useChanged";
import { RouteMatch } from "@remix-run/react";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
export const jobMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
export function useOptionalJob(matches?: RouteMatch[]) {
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?: RouteMatch[]) {
export function useJob(matches?: UIMatch[]) {
const job = useOptionalJob(matches);
invariant(job, "Job must be defined");
return job;
-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 { RouteMatch } 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?: RouteMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: jobsMatchId,
matches,
});
return routeMatch?.projectJobs;
}
export function useJobs(matches?: RouteMatch[]) {
const jobs = useOptionalJobs(matches);
invariant(jobs, "Jobs must be defined");
return jobs;
}
+10 -10
View File
@@ -1,29 +1,29 @@
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 { RouteMatch } from "@remix-run/react";
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?: RouteMatch[]) {
const data = useTypedMatchesData<typeof appLoader>({
id: "routes/_app",
export function useOptionalOrganizations(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.organizations;
}
export function useOrganizations(matches?: RouteMatch[]) {
export function useOrganizations(matches?: UIMatch[]) {
const orgs = useOptionalOrganizations(matches);
invariant(orgs, "No organizations found in loader.");
return orgs;
}
export function useOptionalOrganization(matches?: RouteMatch[]) {
export function useOptionalOrganization(matches?: UIMatch[]) {
const orgs = useOptionalOrganizations(matches);
const org = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
@@ -37,13 +37,13 @@ export function useOptionalOrganization(matches?: RouteMatch[]) {
return orgs.find((o) => o.id === org.organization.id);
}
export function useOrganization(matches?: RouteMatch[]) {
export function useOrganization(matches?: UIMatch[]) {
const org = useOptionalOrganization(matches);
invariant(org, "No organization found in loader.");
return org;
}
export function useIsNewOrganizationPage(matches?: RouteMatch[]): boolean {
export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean {
const data = useTypedMatchesData<any>({
id: "routes/_app.orgs.new",
matches,
+3 -3
View File
@@ -1,4 +1,4 @@
import { RouteMatch } from "@remix-run/react";
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/route";
@@ -9,7 +9,7 @@ export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
export function useOptionalProject(matches?: RouteMatch[]) {
export function useOptionalProject(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: projectMatchId,
matches,
@@ -18,7 +18,7 @@ export function useOptionalProject(matches?: RouteMatch[]) {
return routeMatch?.project;
}
export function useProject(matches?: RouteMatch[]) {
export function useProject(matches?: UIMatch[]) {
const project = useOptionalProject(matches);
invariant(project, "Project must be defined");
return project;
@@ -1,9 +1,9 @@
import { useEffect } from "react";
import { useEventSource } from "remix-utils";
import { projectPath, projectStreamingPath } from "~/utils/pathBuilder";
import { useProject } from "./useProject";
import { useOrganization } from "./useOrganizations";
import { useNavigate } from "@remix-run/react";
import { useEventSource } from "./useEventSource";
export function useProjectSetupComplete() {
const project = useProject();
+3 -3
View File
@@ -1,4 +1,4 @@
import { RouteMatch } from "@remix-run/react";
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route";
@@ -7,7 +7,7 @@ import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedRun = UseDataFunctionReturn<typeof runLoader>["run"];
export function useOptionalRun(matches?: RouteMatch[]) {
export function useOptionalRun(matches?: UIMatch[]) {
const project = useOptionalProject(matches);
const routeMatch = useTypedMatchesData<typeof runLoader>({
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam",
@@ -21,7 +21,7 @@ export function useOptionalRun(matches?: RouteMatch[]) {
return routeMatch.run;
}
export function useRun(matches?: RouteMatch[]) {
export function useRun(matches?: UIMatch[]) {
const run = useOptionalRun(matches);
invariant(run, "Run must be present");
return run;
+4 -4
View File
@@ -1,4 +1,4 @@
import { RouteMatch, useMatches } from "@remix-run/react";
import { UIMatch, useMatches } from "@remix-run/react";
import { RemixSerializedType, UseDataFunctionReturn, deserializeRemix } from "remix-typedjson";
type AppData = any;
@@ -8,7 +8,7 @@ function useTypedDataFromMatches<T = AppData>({
matches,
}: {
id: string;
matches: RouteMatch[];
matches: UIMatch[];
}): UseDataFunctionReturn<T> | undefined {
const match = matches.find((m) => m.id === id);
return useTypedMatchData<T>(match);
@@ -19,7 +19,7 @@ export function useTypedMatchesData<T = AppData>({
matches,
}: {
id: string;
matches?: RouteMatch[];
matches?: UIMatch[];
}): UseDataFunctionReturn<T> | undefined {
if (!matches) {
matches = useMatches();
@@ -29,7 +29,7 @@ export function useTypedMatchesData<T = AppData>({
}
export function useTypedMatchData<T = AppData>(
match: RouteMatch | undefined
match: UIMatch | undefined
): UseDataFunctionReturn<T> | undefined {
if (!match) {
return undefined;
+3 -3
View File
@@ -1,11 +1,11 @@
import type { User } from "~/models/user.server";
import { useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { RouteMatch } from "@remix-run/react";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
import { loader } from "~/root";
export function useOptionalUser(matches?: RouteMatch[]): User | undefined {
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
matches,
@@ -14,7 +14,7 @@ export function useOptionalUser(matches?: RouteMatch[]): User | undefined {
return routeMatch?.user ?? undefined;
}
export function useUser(matches?: RouteMatch[]): User {
export function useUser(matches?: UIMatch[]): User {
const maybeUser = useOptionalUser(matches);
if (!maybeUser) {
throw new Error(
+101
View File
@@ -0,0 +1,101 @@
import { RuntimeEnvironmentType, type RuntimeEnvironment } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { customAlphabet } from "nanoid";
const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
12
);
type RegenerateAPIKeyInput = {
userId: string;
environmentId: string;
};
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: environmentId,
},
include: {
organization: true,
project: true,
},
});
if (!environment) {
throw new Error("Environment does not exist");
}
// check if the user is part of the org
const organization = await prisma.organization.findFirst({
where: {
id: environment.organization.id,
members: { some: { userId } },
},
});
if (!organization) {
throw new Error("User does not have permission to regenerate API key");
}
// check if it is the user's dev environment
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
if (!environment.orgMemberId) {
throw new Error("User does not have permission to regenerate API key");
}
const orgMember = await prisma.orgMember.findFirst({
where: {
organizationId: organization.id,
userId: userId,
id: environment.orgMemberId,
},
});
if (!orgMember) {
throw new Error("User does not have permission to regenerate API key");
}
}
// generate and store new keys
const newApiKey = createApiKeyForEnv(environment.type);
const newPkApiKey = createPkApiKeyForEnv(environment.type);
const updatedEnviroment = await prisma.runtimeEnvironment.update({
data: {
apiKey: newApiKey,
pkApiKey: newPkApiKey,
},
where: {
id: environmentId,
},
});
return updatedEnviroment;
}
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
}
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
}
export function envSlug(environmentType: RuntimeEnvironment["type"]) {
switch (environmentType) {
case "DEVELOPMENT": {
return "dev";
}
case "PRODUCTION": {
return "prod";
}
case "STAGING": {
return "stg";
}
case "PREVIEW": {
return "prev";
}
}
}
@@ -0,0 +1,22 @@
import { z } from "zod";
export const JobVersionDispatchableSchema = z.object({
type: z.literal("JOB_VERSION"),
id: z.string(),
});
export const DynamicTriggerDispatchableSchema = z.object({
type: z.literal("DYNAMIC_TRIGGER"),
id: z.string(),
});
export const EphemeralDispatchableSchema = z.object({
type: z.literal("EPHEMERAL"),
url: z.string(),
});
export const DispatchableSchema = z.discriminatedUnion("type", [
JobVersionDispatchableSchema,
DynamicTriggerDispatchableSchema,
EphemeralDispatchableSchema,
]);
@@ -1,41 +1,47 @@
import { JobRun, JobRunExecution } from "@trigger.dev/database";
import { JobRun } from "@trigger.dev/database";
import { PrismaClientOrTransaction } from "~/db.server";
import { executionWorker } from "~/services/worker.server";
export type EnqueueRunExecutionV2Options = {
runAt?: Date;
resumeTaskId?: string;
isRetry?: boolean;
skipRetrying?: boolean;
executionCount?: number;
};
export async function enqueueRunExecutionV2(
run: JobRun,
tx: PrismaClientOrTransaction,
options: EnqueueRunExecutionV2Options = {}
) {
const job = await executionWorker.enqueue(
"performRunExecutionV2",
{
id: run.id,
reason: run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB",
resumeTaskId: options.resumeTaskId,
isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false,
},
{
tx,
runAt: options.runAt,
jobKey: `job_run:${run.id}:${options.executionCount ?? 0}${
options.resumeTaskId ? `:task:${options.resumeTaskId}` : ""
}`,
maxAttempts: options.skipRetrying ? 1 : undefined,
}
);
}
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
return await executionWorker.dequeue(`job_run:${run.id}`, {
tx,
});
}
export type EnqueueRunExecutionV3Options = {
runAt?: Date;
skipRetrying?: boolean;
};
export async function enqueueRunExecutionV3(
run: JobRun,
tx: PrismaClientOrTransaction,
options: EnqueueRunExecutionV3Options = {}
) {
const reason = run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB";
return await executionWorker.enqueue(
"performRunExecutionV3",
{
id: run.id,
reason: reason,
},
{
tx,
runAt: options.runAt,
queueName: `job_run:${run.id}`,
jobKey: `job_run:${reason}:${run.id}`,
maxAttempts: options.skipRetrying ? 1 : undefined,
}
);
}
export async function dequeueRunExecutionV3(run: JobRun, tx: PrismaClientOrTransaction) {
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
tx,
});
await executionWorker.dequeue(`job_run:PREPROCESS:${run.id}`, {
tx,
});
}
+6 -73
View File
@@ -9,60 +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(
{
@@ -135,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,27 +112,6 @@ export async function createEnvironment(
});
}
function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
}
function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
}
function envSlug(environmentType: RuntimeEnvironment["type"]) {
switch (environmentType) {
case "DEVELOPMENT": {
return "dev";
}
case "PRODUCTION": {
return "prod";
}
case "STAGING": {
return "stg";
}
case "PREVIEW": {
return "prev";
}
}
function createShortcode() {
return generate({ exactly: 2 });
}
+2
View File
@@ -16,6 +16,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
description: task.description,
params: task.params as any,
output: task.output as any,
context: task.context as any,
properties: task.properties as any,
style: task.style as any,
error: task.error,
@@ -25,6 +26,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
operation: task.operation,
callbackUrl: task.callbackUrl,
forceYield: task.run.forceYieldImmediately,
childExecutionMode: task.childExecutionMode,
};
}
+5
View File
@@ -1,6 +1,7 @@
import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
export type { User } from "@trigger.dev/database";
type FindOrCreateMagicLink = {
@@ -36,6 +37,10 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
export async function findOrCreateMagicLinkUser(
input: FindOrCreateMagicLink
): Promise<LoggedInUser> {
if (env.WHITELISTED_EMAILS && !new RegExp(env.WHITELISTED_EMAILS).test(input.email)) {
throw new Error("This email is unauthorized");
}
const existingUser = await prisma.user.findFirst({
where: {
email: input.email,
+1 -1
View File
@@ -164,7 +164,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#logDebug("pool:create", { attempts });
});
this.#runner?.events.on("pool:listen:success", ({ workerPool, client }) => {
this.#runner?.events.on("pool:listen:success", async ({ workerPool, client }) => {
this.#logDebug("pool:listen:success");
});
@@ -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">[];
@@ -1,7 +1,7 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sse } from "~/utils/sse";
import { sse } from "~/utils/sse.server";
type EnvironmentSignalsMap = {
[x: string]: {
@@ -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({
@@ -2,9 +2,7 @@ import { User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { integrationCatalog } from "~/services/externalApis/integrationCatalog.server";
import { Help, HelpSchema, OAuthClientSchema } from "~/services/externalApis/types";
import { HelpSchema, OAuthClientSchema } from "~/services/externalApis/types";
import { getSecretStore } from "~/services/secrets/secretStore.server";
export class IntegrationClientPresenter {
@@ -17,12 +15,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 +55,8 @@ export class IntegrationClientPresenter {
jobIntegrations: {
where: {
job: {
project: {
slug: projectSlug,
organization: {
slug: organizationSlug,
},
internal: false,
deletedAt: null,
@@ -121,11 +117,11 @@ export class IntegrationClientPresenter {
},
authMethod: {
type:
integration.authMethod?.type ?? integration.authSource === "RESOLVER" ? "local" : "local",
integration.authMethod?.type ??
(integration.authSource === "RESOLVER" ? "resolver" : "local"),
name:
integration.authMethod?.name ?? integration.authSource === "RESOLVER"
? "Auth Resolver"
: "Local Auth",
integration.authMethod?.name ??
(integration.authSource === "RESOLVER" ? "Auth Resolver" : "Local Auth"),
},
help,
};
@@ -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,
@@ -125,9 +123,9 @@ export class IntegrationsPresenter {
name: c.definition.name,
},
authMethod: {
type: c.authMethod?.type ?? c.authSource === "RESOLVER" ? "resolver" : "local",
type: c.authMethod?.type ?? (c.authSource === "RESOLVER" ? "resolver" : "local"),
name:
c.authMethod?.name ?? c.authSource === "RESOLVER" ? "Auth Resolver" : "Local Only",
c.authMethod?.name ?? (c.authSource === "RESOLVER" ? "Auth Resolver" : "Local Only"),
},
authSource: c.authSource,
setupStatus: c.setupStatus,
@@ -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(
@@ -190,6 +207,7 @@ export class JobListPresenter {
lastRun,
properties,
environments,
projectSlug: job.project.slug,
};
})
.filter(Boolean);
@@ -0,0 +1,236 @@
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: alias.version.triggerLink
? `${projectRootPath}/${alias.version.triggerLink}`
: undefined,
},
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,130 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { ProjectPresenter } from "./ProjectPresenter.server";
import { logger } from "~/services/logger.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) {
logger.info("Not Found: organization", {
organizationSlug,
projectSlug,
request,
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) {
logger.info("Not Found: proj 1", {
projectId,
organization,
projectSlug: projectSlug ?? null,
});
throw new Response("Not Found", { status: 404 });
}
projectSlug = orgProject.slug;
}
const project = await projectPresenter.call({ userId, slug: projectSlug });
if (!project) {
logger.info("Not Found: proj 2", { projectSlug, organization, 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,
@@ -156,6 +156,7 @@ export class RunPresenter {
completedAt: true,
style: true,
parentId: true,
noop: true,
runConnection: {
select: {
integration: {
@@ -1,6 +1,6 @@
import { JobRun } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { sse } from "~/utils/sse";
import { sse } from "~/utils/sse.server";
export class RunStreamPresenter {
#prismaClient: PrismaClient;
@@ -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 };
}
}
@@ -66,6 +66,18 @@ export class TestJobPresenter {
},
where: {
name: "latest",
environment: {
OR: [
{
orgMember: null,
},
{
orgMember: {
userId,
},
},
],
},
},
},
runs: {
+13 -10
View File
@@ -1,7 +1,10 @@
import type { LinksFunction, LoaderArgs } from "@remix-run/node";
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import { metaV1 } from "@remix-run/v1-meta";
import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExternalScripts } from "remix-utils/external-scripts";
import type { ToastMessage } from "~/models/message.server";
import { commitSession, getSession } from "~/models/message.server";
import tailwindStylesheetUrl from "~/tailwind.css";
@@ -11,24 +14,24 @@ import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayo
import { Toast } from "./components/primitives/Toast";
import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { useHighlight } from "./hooks/useHighlight";
import { usePostHog } from "./hooks/usePostHog";
import { getUser } from "./services/session.server";
import { appEnvTitleTag } from "./utils";
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import { useHighlight } from "./hooks/useHighlight";
import { ExternalScripts } from "remix-utils";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
};
export const meta: TypedMetaFunction<typeof loader> = ({ data }) => ({
title: `Trigger.dev${appEnvTitleTag(data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
});
export const meta: TypedMetaFunction<typeof loader> = (args) => {
return metaV1(args, {
title: `Trigger.dev${appEnvTitleTag(args.data.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
});
};
export const loader = async ({ request }: LoaderArgs) => {
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
@@ -0,0 +1,26 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
import { requireUser } from "~/services/session.server";
import { invitesPath, 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 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());
}
const presenter = new SelectBestProjectPresenter();
try {
const { project, organization } = await presenter.call({ userId: user.id, request });
//redirect them to the most appropriate project
return redirect(projectPath(organization, project));
} catch (e) {
//this should only happen if the user has no projects, and no invites
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 { LoaderArgs, 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 }: LoaderArgs) => {
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="primary/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,15 +11,28 @@ 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 { LoaderArgs } from "@remix-run/server-runtime";
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 }: LoaderArgs) {
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
@@ -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 { LoaderArgs } from "@remix-run/server-runtime";
import { useCallback, useState } from "react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
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 }: LoaderArgs) => {
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 }: LoaderArgs) => {
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" />;
}
@@ -1,9 +1,9 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
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";
@@ -21,16 +21,14 @@ import {
trimTrailingSlash,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderArgs) => {
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 { LoaderArgs } from "@remix-run/server-runtime";
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 {
@@ -18,16 +18,14 @@ import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { IntegrationClientParamSchema, trimTrailingSlash } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderArgs) => {
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,22 +1,20 @@
import { LoaderArgs } from "@remix-run/server-runtime";
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";
import { Handle } from "~/utils/handle";
import { IntegrationClientParamSchema, trimTrailingSlash } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderArgs) => {
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,
});
@@ -1,9 +1,9 @@
import { Outlet } from "@remix-run/react";
import { LoaderArgs } from "@remix-run/server-runtime";
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 {
@@ -16,7 +16,6 @@ import {
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { IntegrationClientPresenter } from "~/presenters/IntegrationClientPresenter.server";
import { requireUser } from "~/services/session.server";
@@ -26,19 +25,17 @@ import {
integrationClientConnectionsPath,
integrationClientPath,
integrationClientScopesPath,
projectIntegrationsPath,
organizationIntegrationsPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderArgs) => {
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 +56,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 +82,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,13 +90,28 @@ 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();
const [form, { emails }] = useForm({
id: "invite-members",
lastSubmission,
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
@@ -106,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>
@@ -118,6 +138,7 @@ export default function Page() {
{...conform.input(email, { type: "email" })}
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
icon="envelope"
autoFocus={index === 0}
onChange={(e) => {
fieldValues.current[index] = e.target.value;
if (
@@ -1,10 +1,9 @@
import { ArrowUpIcon } from "@heroicons/react/24/solid";
import { LoaderArgs } from "@remix-run/server-runtime";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
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 }: LoaderArgs) => {
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 }: LoaderArgs) => {
}
};
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">
@@ -1,9 +1,9 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { EnvironmentsStreamPresenter } from "~/presenters/EnvironmentsStreamPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderArgs) => {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);

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