Compare commits

...

261 Commits

Author SHA1 Message Date
Eric Allam a606db25f6 Changeset: version 2023-06-23 11:41:11 +01:00
Eric Allam 82f715bab6 Removed cli package from todo-example 2023-06-23 11:40:53 +01:00
Eric Allam 28914b878c Adding the @trigger.dev/init CLI package 2023-06-23 11:40:26 +01:00
Eric Allam 3c2f1709cc Remove welcome email (need to add back in for cloud) 2023-06-22 22:57:09 +01:00
Eric Allam 40ffde33b6 Add isManagedCloud feature, remove sentry, and require custom oauth app on self-hosting 2023-06-22 22:48:57 +01:00
Eric Allam 8184c21a80 Build the oauth2 callback url from the server (and use env.APP_ORIGIN) 2023-06-22 21:54:01 +01:00
Eric Allam 5c13d0c2df Fixed pnpm lock file again 2023-06-22 21:37:09 +01:00
Eric Allam fd8fd22bdc Fixed https missing from url 2023-06-22 21:35:49 +01:00
Eric Allam 31d8669c9d Make github auth optional and better name the env vars 2023-06-22 17:42:51 +01:00
D-K-P 59e1fbf565 Removed import 2023-06-22 17:10:10 +01:00
D-K-P 80c9bb0317 Added new components to the to do page and lots of improvements 2023-06-22 17:09:42 +01:00
Matt Aitken 6c71bad8ef Update Job in app folder to use owner + repo 2023-06-22 17:08:10 +01:00
Eric Allam 0e82f6287a Try and fix the fly entrypoint issues 2023-06-22 16:54:14 +01:00
Matt Aitken 52aacc1e1d OpenAI: createEmbedding 2023-06-22 16:29:00 +01:00
Eric Allam 909b4a5432 More fly work 2023-06-22 16:26:55 +01:00
Matt Aitken b48b724c01 OpenAI: createImage 2023-06-22 16:12:02 +01:00
Matt Aitken 91ac1567d4 OpenAI: createEdit 2023-06-22 16:12:02 +01:00
Eric Allam c034035aba Working on deploying to fly 2023-06-22 16:09:08 +01:00
Matt Aitken cd852f9563 OpenAI: retrieveModel 2023-06-22 15:51:31 +01:00
Matt Aitken 010ac6e5b6 OpenAI tasks: createFineTune, listFineTunes, retrieveFineTune, cancelFineTune, listFineTuneEvents, deleteFineTune 2023-06-22 15:45:09 +01:00
Eric Allam 0a3cf46068 Try this for publishing public images 2023-06-22 15:17:27 +01:00
Matt Aitken 2bbeeb860d Fixed gitignore for tmp folders 2023-06-22 15:10:19 +01:00
Matt Aitken b74045fe55 Ignore tmp folders 2023-06-22 15:10:19 +01:00
Matt Aitken a940102d83 Max lines before scrolling on the task input is 35 lines 2023-06-22 15:10:19 +01:00
Matt Aitken 74e9d5eb3c New OpenAI tasks: listModels, createFile, listFiles, createFineTuneFile 2023-06-22 15:10:19 +01:00
Matt Aitken b256d66374 Subtasks should always be set to last=false so you get the line at the bottom 2023-06-22 15:10:19 +01:00
Matt Aitken 5b23e60b04 Created an OpenAI example file 2023-06-22 15:10:19 +01:00
Eric Allam 4cd71decbc Starting to build fly demo 2023-06-22 15:03:13 +01:00
Eric Allam 1ba372e2c3 Unbreak the build 2023-06-22 14:33:08 +01:00
Eric Allam d88f8c87f6 Don’t cancel in progress runs 2023-06-22 14:31:52 +01:00
D-K-P ff02ab2aa0 Updated pnpm 2023-06-22 14:29:33 +01:00
D-K-P 38c71bdb3b Added logo / to do icon and style updates 2023-06-22 14:29:33 +01:00
Eric Allam b68ce9695c Try and fix the cache-from cause of Adam 2023-06-22 14:28:15 +01:00
Eric Allam 0d3e7bd996 Use docker/build-push-action 2023-06-22 14:17:30 +01:00
Eric Allam 1e0acf4ce8 Add back in the build:server script to webapp 2023-06-22 14:05:47 +01:00
Eric Allam b9e8e084a8 chmod the wait-for-it script 2023-06-22 13:54:47 +01:00
Eric Allam 1701d4253d Remove prisma config in package.json 2023-06-22 13:53:46 +01:00
Eric Allam ff60b71321 Quiet npx output 2023-06-22 13:48:19 +01:00
Eric Allam f68439b936 Adopt buildjet for faster CI builds 2023-06-22 13:46:34 +01:00
D-K-P a3fe3944b7 Style updates 2023-06-22 13:37:03 +01:00
D-K-P 9cf44777e1 Added to do custom input 2023-06-22 13:37:03 +01:00
D-K-P 5206c2c971 Removed shadcn input 2023-06-22 13:37:03 +01:00
Eric Allam 294fd10304 Use pnpm 7.18 in the docker workflow 2023-06-22 13:36:00 +01:00
Eric Allam 32650f8b9a Fix lockfile 2023-06-22 13:34:27 +01:00
Eric Allam c5d96db9a8 WIP docker github workflow 2023-06-22 13:11:43 +01:00
D-K-P d04c66006b Updated companyicons package 2023-06-22 12:03:13 +01:00
Matt Aitken d43529dce9 Added some GitHub property helpers 2023-06-22 12:02:08 +01:00
Matt Aitken 5ba95f3cbc Link to the run trigger, so it’s selected 2023-06-22 12:02:08 +01:00
Matt Aitken 7381e5b393 GitHub addIssueAssignees and addIssueLabels 2023-06-22 12:02:08 +01:00
Eric Allam b62f00dfd7 Implemented seeding the cloud based on env vars 2023-06-22 12:00:18 +01:00
Matt Aitken 95787fcc66 Make the tasks user a separate owner and repo too 2023-06-22 10:49:50 +01:00
Matt Aitken 4ef76f72dc Separate GitHub repo into owner and repo, to match official SDK/API 2023-06-22 10:49:50 +01:00
Matt Aitken 939f33e791 Added scrolling to the Jobs menu (I have a ton) 2023-06-22 10:49:50 +01:00
D-K-P e15ade91d4 Added Trigger sync card and made lots of general improvements 2023-06-21 21:54:53 +01:00
Matt Aitken 0776cc884d Pull request review 2023-06-21 19:50:20 +01:00
Matt Aitken 0ad09eb11f Pull request event 2023-06-21 19:41:33 +01:00
Matt Aitken 9e8b37c6cf Added push event 2023-06-21 19:29:02 +01:00
Matt Aitken c2839934b4 Added branch and tag events 2023-06-21 19:04:14 +01:00
Matt Aitken e2e3c3a9b3 Latest lock file 2023-06-21 19:04:03 +01:00
Matt Aitken 9b3f4bc1a3 Next generates a weird .next/package.json when running, so need to restrict example workspaces 2023-06-21 19:03:53 +01:00
Matt Aitken cf9c34950f Show more of the comment body 2023-06-21 18:44:08 +01:00
Matt Aitken d4a2927279 Fix for integration-kit issues 2023-06-21 18:43:58 +01:00
Matt Aitken 56172b1e5c Star examples 2023-06-21 18:24:38 +01:00
Matt Aitken 7def5437fd Try and bundle up the integration-kit with the github package 2023-06-21 18:24:38 +01:00
D-K-P d21e364f11 Added trigger cards wip 2023-06-21 18:05:26 +01:00
D-K-P 7c6440fd9a Added cards / input / button / accordian and updated page 2023-06-21 18:05:04 +01:00
Matt Aitken 0b61207ecf Latest installs 2023-06-21 17:55:16 +01:00
Matt Aitken fff2f40924 Added issue commented example job 2023-06-21 17:55:16 +01:00
Matt Aitken 2166929fa8 Issue commented event, not working properly because of truncate function issue 2023-06-21 17:55:16 +01:00
Matt Aitken 5aa53da864 Next.js project using the latest eslint setup that Vercel recommend 2023-06-21 17:55:16 +01:00
Matt Aitken 21d7ff2f74 Added a function to truncate a string with an ellipsis 2023-06-21 17:55:16 +01:00
Eric Allam 8bba30e290 Initial Docker and docker compose setup 2023-06-21 17:07:17 +01:00
Eric Allam fd94a32b95 Removing a bunch of old stuff 2023-06-21 17:07:17 +01:00
Matt Aitken 25f9aa8c2d Started fleshing out GitHub events with examples and more events 2023-06-21 17:00:29 +01:00
Matt Aitken 172bb4527c Export the EventSpecificationExample type so it can be used in integrations 2023-06-21 17:00:15 +01:00
Matt Aitken 8ad769569c Spaced the properties out a bit vertically 2023-06-21 16:59:55 +01:00
Matt Aitken 32eba22628 Remove old oauth files 2023-06-21 16:59:32 +01:00
Matt Aitken 8d4179b5df Improved the coming soon billing description 2023-06-21 14:49:20 +01:00
Matt Aitken b7385825b6 Added missing auth param to GitHub integration 2023-06-21 14:11:52 +01:00
Matt Aitken 5a20528cc8 @trigger.dev/github package now uses integration-kit 2023-06-21 14:02:16 +01:00
Matt Aitken 324cf161ec Added @trigger.dev/integration-kit package 2023-06-21 14:02:16 +01:00
D-K-P 9f84e77f57 Replaced the fire icon with the td triangle on the integrations page 2023-06-21 13:56:11 +01:00
Matt Aitken beaf0a9ecb Coming soon is now nicer 2023-06-21 13:09:46 +01:00
Matt Aitken fb308e8038 Sent a notification when the feedback form is submitted, even if that custoemr is already active 2023-06-21 13:04:38 +01:00
Matt Aitken d2d15dae9f Added coming soon pages 2023-06-21 13:04:26 +01:00
Matt Aitken 977f064157 Added “background” icon 2023-06-21 10:53:46 +01:00
Matt Aitken 8d23dd0186 Set the user id in Plain 2023-06-21 10:53:41 +01:00
Matt Aitken 90ae06df2a Improved the feedback form 2023-06-21 10:33:01 +01:00
D-K-P 2cf253a914 Added new integrations / icons 2023-06-21 10:18:59 +01:00
D-K-P 9e462cc329 Minor updates 2023-06-21 10:17:59 +01:00
Matt Aitken 36ae8910b6 Added the help type 2023-06-20 22:45:09 +01:00
Matt Aitken fe0ed0bc9a The Plain feedback form is working 2023-06-20 22:43:33 +01:00
Matt Aitken ec738515b7 Initial work on feedback form 2023-06-20 22:21:36 +01:00
Matt Aitken 287b6c6bc7 Remove user agent/platform logging 2023-06-20 21:04:09 +01:00
Matt Aitken aad93b9147 Updated the replacement data format 2023-06-20 20:15:47 +01:00
Matt Aitken 31176245bd Changed the format slightly, added timestamps as further examples 2023-06-20 20:15:39 +01:00
Matt Aitken da45c7e2b9 Added support for replacements in examples, added __CURRENT_DATE__ to start 2023-06-20 20:11:06 +01:00
Matt Aitken 3c73cb3fed Example payloads are now any so we can do replacements 2023-06-20 20:10:34 +01:00
Matt Aitken 275d01421e Improved Windows shortcut modifier display 2023-06-20 17:51:19 +01:00
Matt Aitken 75edfccc31 Shortcut keys are working 2023-06-20 17:45:24 +01:00
Matt Aitken 1d230d9fed Nicely formatted shortcut in buttons 2023-06-20 15:17:29 +01:00
Matt Aitken 7d87dbf2ed Updated packages 2023-06-20 14:48:10 +01:00
Matt Aitken 44467331a2 Improved the ShortcutKey display 2023-06-20 14:47:47 +01:00
Matt Aitken 29cbfd4fb3 Use ClipboardField for Integration package installation 2023-06-20 13:48:34 +01:00
Matt Aitken d946408105 Integration sheet: show “View docs” button and the description 2023-06-20 13:48:21 +01:00
Matt Aitken 6101372ba8 Added OpenAI integration 2023-06-20 13:48:04 +01:00
Matt Aitken 4993860a18 Added optional description to an integration 2023-06-20 13:47:56 +01:00
Matt Aitken 5e29119e83 You can now only show integrations in the integrations list 2023-06-20 13:38:54 +01:00
Matt Aitken fe14664cfd Moved the logo-icon to be in alphabetical order 2023-06-20 13:38:54 +01:00
Matt Aitken f871817a2f Use geometricPrecision on Company icon svgs 2023-06-20 13:38:54 +01:00
Matt Aitken 6ccacaa92e Switch labels now supported and more complete styling 2023-06-20 13:38:54 +01:00
D-K-P 24e0f04aca Updated pnpm-lock file 2023-06-20 13:34:32 +01:00
D-K-P f878d499a7 Added ToDoRow component 2023-06-20 13:34:02 +01:00
D-K-P c24cd1c02b Added HeroIcons 2023-06-20 13:34:01 +01:00
D-K-P c6028fca44 Updated Paragraph and TextStyling 2023-06-20 13:33:45 +01:00
Matt Aitken 25daa5b358 Added Switch component with small and large variants 2023-06-20 12:20:03 +01:00
Matt Aitken 0503a2a517 Moved ClipboardField into primities 2023-06-20 12:04:49 +01:00
Matt Aitken 28fd3d9b18 Fix for re-runs linking to the incorrect detail panels 2023-06-20 12:00:17 +01:00
Matt Aitken 1a9af1dee4 Fix for Retry table not having key and header row 2023-06-20 11:55:28 +01:00
Matt Aitken ce27de1af6 Removed todo that has been done 2023-06-20 11:55:16 +01:00
Matt Aitken c9d5334a38 Improved the style of the “Rerun job” popover 2023-06-20 11:51:03 +01:00
Matt Aitken bd20b7b8e0 Show the event name in the Run Trigger cell on the left 2023-06-20 11:39:44 +01:00
Matt Aitken 52f40c8c26 Only show the retries table if there was > 1 2023-06-20 11:33:05 +01:00
Matt Aitken 622dd6aba4 Show a table of Task retries if there were some 2023-06-20 11:30:07 +01:00
Matt Aitken 969a6a1f2c Fix for the final retry 2023-06-20 11:29:51 +01:00
Matt Aitken 08eab9622d Improved the webhook instruction text 2023-06-20 10:41:37 +01:00
Matt Aitken 107960b67c Endpoint sheet shows the webhook URL 2023-06-20 10:40:01 +01:00
Matt Aitken b394f5e43f ClipboardField works when full width, has scrolling if the content is too large 2023-06-20 10:39:43 +01:00
Matt Aitken 4d71a5c9d4 Refreshing endpoints is working 2023-06-20 10:26:35 +01:00
Matt Aitken 59e2216861 Streaming working for the endpoint sheet 2023-06-20 10:26:35 +01:00
Matt Aitken 2fba5da04e sse function makes it much easier to do ServerSentEvents 2023-06-20 10:26:35 +01:00
Matt Aitken efbe2ddbf7 The AbortController approach causes the stream endpoint to be called all the time 2023-06-20 10:26:35 +01:00
Matt Aitken 7f353723eb Improved the environments form/page 2023-06-20 10:26:35 +01:00
Matt Aitken a94d50f3cd Made the sheet wider 2023-06-20 10:26:35 +01:00
Eric Allam ae66e6f01a Run pnpm i 2023-06-19 17:22:53 +01:00
Eric Allam e4b0b1e305 Add support for background task operations, starting with fetch 2023-06-19 17:22:18 +01:00
D-K-P 65ca2560fc Started adding the first col to the page 2023-06-19 17:19:16 +01:00
D-K-P bdbf04efe9 Added clsx / tailwind-merge to the package.json 2023-06-19 17:18:55 +01:00
D-K-P 4b56671d2d Added tailwind config / clsx / tailwind-merge 2023-06-19 17:18:35 +01:00
D-K-P f2b190b21a Replaced to-do folder with a blank nextjs project 2023-06-19 16:38:30 +01:00
D-K-P 4f5cf931be Duplicated the nextjs example folder for the to-do app 2023-06-19 15:28:03 +01:00
D-K-P 68beefdaf2 Added new integration logos and upped the companyicons package number 2023-06-19 15:05:15 +01:00
Matt Aitken 4a52fd2edc Added errors in the form if creating the endpoint fails 2023-06-19 12:17:58 +01:00
Matt Aitken 47fdfb344d Progress displaying endpoints and editing them 2023-06-19 11:48:33 +01:00
Matt Aitken ebbbc85d7b Added a success Callout variant 2023-06-19 11:48:14 +01:00
Matt Aitken 22ff2c36d9 Open a sheet when you click a row 2023-06-16 19:33:40 +01:00
Matt Aitken 5db2a1bb84 Improved the spacing 2023-06-16 19:19:10 +01:00
Matt Aitken 8a4ae0df20 WIP on the Environment clients 2023-06-16 19:17:30 +01:00
Matt Aitken 80c3e48f5c Tables can now have onClick events 2023-06-16 19:17:11 +01:00
Matt Aitken a7244032e4 Updated the variant for the CLipboardField 2023-06-16 16:37:50 +01:00
Matt Aitken 7fc3ff26bc Rejigged database migrate deploy scripts 2023-06-16 16:30:35 +01:00
Matt Aitken 0648d230fc WIP on environment page 2023-06-16 16:30:35 +01:00
Matt Aitken 2626608200 Renamed “Environments” to “Environments & API Keys” 2023-06-16 16:30:35 +01:00
Matt Aitken 081ef45047 Deleted org environment page, they are at the Project level 2023-06-16 16:30:35 +01:00
James Ritchie 9aa9ebc23f Minor tweak to the button min width 2023-06-16 16:26:58 +01:00
James Ritchie f664a10024 Final style improvements for the clipboard component 2023-06-16 16:22:33 +01:00
James Ritchie 52beb2f012 Removed borders on the icon 2023-06-16 16:14:03 +01:00
James Ritchie 149e3c2d84 Style updates and you can now click the icon to focus the field 2023-06-16 16:06:50 +01:00
James Ritchie 223965757c Making the clipboard component work with icons and larger sizes 2023-06-16 15:04:03 +01:00
James Ritchie 27c1d2516b More variants to the clipboard field 2023-06-16 15:04:03 +01:00
James Ritchie 014a97fcdc improved the env labels 2023-06-16 15:04:03 +01:00
James Ritchie eb85cc5c14 Adding new variants to the clipboard component 2023-06-16 15:04:03 +01:00
James Ritchie b1d49d71ea Removed import 2023-06-16 15:04:03 +01:00
James Ritchie 9a0a6305e7 Added a clipboard field story 2023-06-16 15:04:03 +01:00
James Ritchie a218bce73b Added prisma migrate deploy to package 2023-06-16 15:04:03 +01:00
Eric Allam c94ca54aff Add chat completion task 2023-06-16 15:02:24 +01:00
Eric Allam 2e8cd805fd Fixed for the new integrations stuff 2023-06-16 14:49:52 +01:00
Eric Allam 1a9bb4ddc5 Fix for seeds 2023-06-16 14:49:37 +01:00
Eric Allam e7f7c0704a Revamped the integration schema and local only integrations now work in the UI 2023-06-16 14:15:17 +01:00
Matt Aitken b5e0fb0e97 Tightened up the API grid 2023-06-16 11:53:13 +01:00
Matt Aitken f555989a5c Added a full stop 2023-06-16 11:53:03 +01:00
Matt Aitken 2e0ea4520c Updated to the latest @trigger.dev/companyicons package 2023-06-16 11:41:38 +01:00
Matt Aitken 31a9164df1 Added API integration voting functionality 2023-06-16 11:32:38 +01:00
Matt Aitken b363a1e60d Remove coming soon message from the OAuth form 2023-06-16 11:32:26 +01:00
Matt Aitken e5714f9047 Call them APIs in the list 2023-06-16 11:32:13 +01:00
Matt Aitken e9b9c77edc Added a NoIntegrationSheet 2023-06-16 10:10:06 +01:00
Matt Aitken acd49994bd WIP on showing APIs we don’t yet have an integration for 2023-06-15 17:38:56 +01:00
Matt Aitken 5264674cda Improved the spacing of the LabelValueStack 2023-06-15 17:22:49 +01:00
Matt Aitken 2825925781 Removed a ton of unused imports 2023-06-15 17:22:38 +01:00
Matt Aitken 083bd7f1cf Limit the height of the run output in the left-hand column 2023-06-15 17:03:57 +01:00
Matt Aitken 3f90fe04b4 The OAuth form is back and a coming soon for end-user OAuth 2023-06-15 17:03:57 +01:00
Matt Aitken a63a247b22 The flow is now roughly setup 2023-06-15 17:03:57 +01:00
Matt Aitken 7bb589b8f7 Multiple samples for API Keys 2023-06-15 17:03:57 +01:00
Matt Aitken 7c94ea3ec5 Added CustomHelp 2023-06-15 17:03:57 +01:00
Matt Aitken 5789ae3c73 Sheet scrolling should now work properly… painful 2023-06-15 17:03:57 +01:00
Matt Aitken 95fad3deb8 API auth help 2023-06-15 17:03:57 +01:00
Matt Aitken 2b5b8d7931 Added Paragraph spacing 2023-06-15 17:03:57 +01:00
Matt Aitken 96e0fb81ae Updated packages 2023-06-15 17:03:57 +01:00
Matt Aitken e5a568f510 Improved the radio button styling 2023-06-15 17:03:57 +01:00
Matt Aitken 8cb3155ea6 WIP on new integration flow 2023-06-15 17:03:57 +01:00
Matt Aitken 52c5d93449 Added a RadioGroup component with the same variants as Checkbox 2023-06-15 17:03:57 +01:00
Matt Aitken 5e3502f589 Removed badge prop from Checkbox (it had badges already) 2023-06-15 17:03:56 +01:00
Matt Aitken f1fd36d3a1 Checkbox description style doesn’t use mono font 2023-06-15 17:03:56 +01:00
Eric Allam 9f49b7f4c0 Move prisma to an internal database package 2023-06-15 14:18:34 +01:00
Eric Allam eca89e538b Convert to useTypedRouteLoaderData and improve the hydrateDates function 2023-06-14 21:53:04 +01:00
Eric Allam bbac63e162 Use /trigger to select the trigger details pane, and no longer redirecting when you land on the run page (can just link directly to /trigger if we want to) 2023-06-14 21:42:39 +01:00
Eric Allam ed3123470a Replaced manual hydration with using remix-typedjson (fixes error on run page) 2023-06-14 18:04:28 +01:00
Eric Allam 170bda3158 Only show the retry job button when status is failed 2023-06-14 17:09:19 +01:00
Eric Allam b5a8eec650 Implement the continue run service 2023-06-14 17:03:15 +01:00
Eric Allam 817b4ed133 Endpoint registration and indexing now is only initiated outside of clients 2023-06-14 16:07:10 +01:00
Matt Aitken e4837cea9d Run left-hand scrollbar now positioned better 2023-06-14 15:48:06 +01:00
Matt Aitken e1cdeb284f Created the ContinueRunService for Eric to implement 2023-06-14 14:18:28 +01:00
Matt Aitken f195933cb6 Rerunning from scratch is working 2023-06-14 14:16:44 +01:00
Matt Aitken 6e1d91b413 Moved the ReRunPopover to a component to make the page easier to work with 2023-06-14 12:47:52 +01:00
Matt Aitken d4e39de67e WIP on Rerun/continue functionality 2023-06-14 12:46:17 +01:00
Matt Aitken 4903cfc689 Don’t let Callout icons shrink when the space is small 2023-06-14 12:45:56 +01:00
Matt Aitken 54a8e26c7c Organized imports 2023-06-14 12:02:17 +01:00
Matt Aitken 4cc9006c8e Added test help panel. Defaults open if there haven’t been any test runs yet 2023-06-14 12:00:43 +01:00
Matt Aitken bc6492b1c4 Improved the test layout so the examples are more obvious 2023-06-14 11:52:11 +01:00
Matt Aitken afd05f93fc Tried to improve the environment label but it’s a bit fooked 2023-06-14 11:52:11 +01:00
Matt Aitken e83f5e7cca Fix for DynamicTriggerIcon non camelCase attributes 2023-06-14 11:52:11 +01:00
Matt Aitken 7b8e02023e Default icon for an example is the beaker 2023-06-14 11:52:11 +01:00
Matt Aitken 3882f531cf Environment is now in the rounded rectangle so it’s consistent 2023-06-14 11:52:11 +01:00
Matt Aitken 0ce58b5c0f Better spacing for PageHeader buttons 2023-06-14 11:52:11 +01:00
Eric Allam f36a1c60f4 Changeset: version nextjs 2023-06-14 09:55:02 +01:00
Eric Allam 98fcc5d2d4 Make sure nextjs app routes have the correct types 2023-06-14 09:55:02 +01:00
Matt Aitken 6ff2ee23f8 Hide the test button if you’re on the /test page 2023-06-14 09:15:19 +01:00
Matt Aitken e1405b140f Better test layout 2023-06-14 09:13:15 +01:00
Matt Aitken 04ab709ddd Better test styling 2023-06-14 09:09:43 +01:00
Eric Allam 0736c68d77 Fixed duplicate export issue 2023-06-14 08:08:52 +01:00
Eric Allam 34ccf345cd Changeset version: implement task errors and retrying 2023-06-14 08:07:19 +01:00
Matt Aitken 605a38d8ab Trying to make a good layout for the test page 2023-06-13 18:16:07 +01:00
Matt Aitken c9fd8ccae2 Running tests is working 2023-06-13 16:33:42 +01:00
Matt Aitken 43482b1b96 Get some errors if you’ve used invalid JSON 2023-06-13 15:52:58 +01:00
Matt Aitken 81912b2530 Test page is working with a ref 2023-06-13 15:52:58 +01:00
Matt Aitken 2db2e7a369 First version of the Test page with example and environment selection 2023-06-13 15:52:58 +01:00
Eric Allam 3589d26fb3 Implement task retrying to recording task attempts 2023-06-13 15:48:07 +01:00
Matt Aitken e5282fb264 Get the example data for the test page 2023-06-13 11:48:39 +01:00
Matt Aitken 61fddbb8af Added name and icon to the EventExamples 2023-06-13 11:48:27 +01:00
Matt Aitken e141aa3ff4 Added an GitHub Issue opened example 2023-06-13 10:57:06 +01:00
Matt Aitken 2fa1536315 EventExamples are now in a separate table (upserted on job register) 2023-06-13 10:56:57 +01:00
Matt Aitken 5607dcfae9 Created useFilterJobs which also searches the job slug 2023-06-13 10:11:34 +01:00
Matt Aitken 0cbf427a58 Added “Test” to the breadcrumb 2023-06-12 18:18:23 +01:00
Matt Aitken e53cd477f9 Added a dynamic property to the Job page header 2023-06-12 18:15:45 +01:00
Matt Aitken df61f29a02 Added Dynamic markers to the jobs table 2023-06-12 18:12:52 +01:00
Matt Aitken feda01ab3b Don’t count internal or other project jobs in the integration table 2023-06-12 16:28:42 +01:00
Matt Aitken fb2684c0b3 Added the placeholder for help using an integration 2023-06-12 16:19:52 +01:00
Matt Aitken 89e4b80e65 Added integration breadcrumbs 2023-06-12 16:12:07 +01:00
Matt Aitken d55fe2d100 Added scopes page 2023-06-12 15:59:34 +01:00
Matt Aitken 49a510aaf9 Client Connections page is working 2023-06-12 15:34:51 +01:00
Matt Aitken 16a5504a32 Connection client jobs page 2023-06-12 14:47:47 +01:00
Matt Aitken b720c27c2a Show the slug in a ClipboardField 2023-06-12 14:12:51 +01:00
Matt Aitken cad3453e32 Created the ClipboardField 2023-06-12 14:05:38 +01:00
Matt Aitken afe0a8a159 Run page streaming runs just once if run is completed 2023-06-12 12:04:36 +01:00
Matt Aitken 2487e815a3 Use zod for integration client page params parsing 2023-06-12 12:04:36 +01:00
Eric Allam 771348dd5e Handle task errors and update the task status and add errors to the task output 2023-06-12 09:31:47 +01:00
Matt Aitken 1f76e1228a Added connection client Jobs table 2023-06-12 09:26:15 +01:00
Eric Allam fe782e5ff8 Scope finding projects and runs by the project and org 2023-06-09 21:14:40 +01:00
Eric Allam b4167a38f2 Fixed the event trigger name 2023-06-09 21:14:15 +01:00
Matt Aitken 61459a197f Started working on the Integration client page 2023-06-09 19:23:53 +01:00
Matt Aitken 30b6cc646a Improved the integrations page layout 2023-06-09 17:53:02 +01:00
Matt Aitken df95d4e29f Changed to using the job slug in the urls 2023-06-09 17:26:52 +01:00
Eric Allam 5090b64e57 Store the graphile job ID on the executions to be able to fast-forward delayed tasks 2023-06-09 16:56:45 +01:00
Matt Aitken 12d188d85a When accepting an invite it creates the RuntimeEnvironment for each project 2023-06-09 16:51:26 +01:00
Matt Aitken ab3e45d933 Import tidying 2023-06-09 16:40:47 +01:00
Matt Aitken 8c979cabd7 Added the realtime duration to the detail panel 2023-06-09 16:39:15 +01:00
Matt Aitken a617922c1d Handles closing out the realtime connection better 2023-06-09 16:37:18 +01:00
Matt Aitken fec7cd65a7 The Run page now live updates 2023-06-09 16:21:36 +01:00
Matt Aitken 10cfdb2fa2 Tweaked the countdown 2023-06-09 16:21:36 +01:00
Eric Allam 43a62d8bec Always create environments when creating a project 2023-06-09 15:40:18 +01:00
Matt Aitken 7b32b7ecd6 Delays display well now with countdowns 2023-06-09 15:25:34 +01:00
667 changed files with 20792 additions and 7136 deletions
+11
View File
@@ -0,0 +1,11 @@
---
"@trigger.dev/integration-kit": patch
"@trigger.dev/sdk": patch
"@trigger.dev/github": patch
"@trigger.dev/openai": patch
"@trigger.dev/slack": patch
"@trigger.dev/nextjs": patch
"@trigger.dev/init": patch
---
Creating the init CLI package
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Endpoint registration and indexing now is only initiated outside of clients
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/github": patch
"@trigger.dev/nextjs": patch
---
Add support for task errors and task retrying
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Fixed the eventTrigger name
+8
View File
@@ -0,0 +1,8 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/github": patch
"@trigger.dev/openai": patch
"@trigger.dev/slack": patch
---
Added support for backgroundFetch
+12 -2
View File
@@ -9,10 +9,20 @@
"emails": "1.0.0",
"@trigger.dev/internal": "0.0.1",
"@trigger.dev/nextjs": "0.1.0",
"@trigger.dev/sdk": "1.0.0"
"@trigger.dev/sdk": "1.0.0",
"@trigger.dev/openai": "1.0.0-next.0",
"@trigger.dev/database": "0.0.0",
"@trigger.dev/init": "0.2.0",
"@trigger.dev/integration-kit": "2.0.0-next.1"
},
"changesets": [
"curly-hornets-sort",
"dirty-needles-kneel",
"stupid-files-cough"
"four-ligers-perform",
"great-meals-listen",
"itchy-rockets-provide",
"modern-emus-suffer",
"stupid-files-cough",
"tough-walls-share"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/nextjs": patch
---
Make sure nextjs app routes have the correct types
-8
View File
@@ -1,8 +0,0 @@
# You need to stop and re-run docker to switch, `pnpm run docker:db`
# To login using GitHub and connect via OAuth with most integrations
AUTH_CALLBACK_URL=http://localhost:3004/oauth/callback
# To connect with Slack
# AUTH_CALLBACK_URL=https://<ngrok subdomain>.eu.ngrok.io/oauth/callback
# You only need to use this locally if you specify PIZZLY_SECRET_KEY in the webapp .env file
# PIZZLY_SECRET_KEY=<secret_key>
+70
View File
@@ -0,0 +1,70 @@
name: 🚀 Publish Trigger.dev Docker
on:
push:
branches:
- main
- dev-q22023
permissions:
id-token: write
packages: write
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
jobs:
typecheck:
name: ʦ TypeScript
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7.18
- name: ⎔ Setup node
uses: buildjet/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔎 Type check
run: pnpm run typecheck --filter webapp
publish:
needs: [typecheck]
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:latest
cache-to: type=inline
+3 -1
View File
@@ -46,4 +46,6 @@ yarn-error.log*
apps/**/public/build
.tests-container-id.txt
.sentryclirc
.buildt
.buildt
**/tmp/
+2 -1
View File
@@ -1 +1,2 @@
link-workspace-packages=false
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
+1 -1
View File
@@ -18,4 +18,4 @@ Create long-running jobs directly in your codebase with features like API integr
### _**Trigger.dev v2 is launching soon...**_
Visit the new landing page for [Trigger.dev v2](https://next.trigger.dev) and [sign up for early access.](https://bcymafitv0e.typeform.com/to/QQnotGJM#source=github)
Visit the new landing page for [Trigger.dev v2](https://trigger.dev) and [sign up for early access.](https://bcymafitv0e.typeform.com/to/QQnotGJM#source=github)
+2 -4
View File
@@ -15,8 +15,6 @@ APP_ORIGIN=http://localhost:3000
NODE_ENV=development
PULSAR_ENABLED=1
SESSION_SECRET=
@@ -25,9 +23,9 @@ SESSION_SECRET=
# ************************************* LOGIN *******************************************
#########################################################################################
GITHUB_CLIENT_ID=
AUTH_GITHUB_CLIENT_ID=
GITHUB_SECRET=
AUTH_GITHUB_CLIENT_SECRET=
LOGIN_ORIGIN=http://localhost:3000
+8 -3
View File
@@ -3,6 +3,7 @@ import "../app/tailwind.css";
import { unstable_createRemixStub } from "@remix-run/testing";
import React from "react";
import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider";
const preview: Preview = {
parameters: {
@@ -33,9 +34,13 @@ const preview: Preview = {
]);
return (
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixStub initialEntries={["/"]} />
</LocaleContextProvider>
<OperatingSystemContextProvider platform="mac">
<LocaleContextProvider
locales={window.navigator.languages as string[]}
>
<RemixStub initialEntries={["/"]} />
</LocaleContextProvider>
</OperatingSystemContextProvider>
);
},
],
-74
View File
@@ -1,74 +0,0 @@
FROM node:16-bullseye AS pruner
RUN apt-get update && apt-get install openssl -y
WORKDIR /app
RUN npm install turbo@1.7.0 -g
COPY . .
RUN turbo prune --scope=webapp --docker
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
# Base strategy to have layer caching
FROM node:16-bullseye AS base
RUN apt-get update && apt-get install openssl ca-certificates g++ make wget python3 -y
ENV PULSAR_CPP_CLIENT_VERSION=2.10.3
RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client.deb -q
RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client-dev.deb -q
RUN dpkg -i ./apache-pulsar-client*.deb
WORKDIR /app
COPY .gitignore .gitignore
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
FROM base AS production-deps
WORKDIR /app
RUN npm install turbo@1.7.0 -g
RUN corepack enable
ENV NODE_ENV production
RUN npm config set python /usr/bin/python3
COPY --from=pruner /app/out/full/apps/webapp/prisma/schema.prisma /app/apps/webapp/prisma/schema.prisma
RUN pnpm install --prod --frozen-lockfile
RUN pnpx prisma generate --schema /app/apps/webapp/prisma/schema.prisma
FROM base AS builder
WORKDIR /app
RUN npm install turbo@1.7.0 -g
COPY turbo.json turbo.json
RUN corepack enable
COPY --from=pruner /app/out/full/ .
RUN npm config set python /usr/bin/python3
ENV NODE_ENV development
RUN pnpm install --ignore-scripts --frozen-lockfile
ENV NODE_ENV production
RUN pnpm run generate
RUN pnpm run build --filter=webapp...
RUN pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma
RUN pnpm run db:seed --filter=webapp
# Runner
FROM node:16-bullseye AS runner
RUN apt-get update && apt-get install openssl ca-certificates g++ make wget python3 -y
ENV PULSAR_CPP_CLIENT_VERSION=2.10.3
RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client.deb -q
RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client-dev.deb -q
RUN dpkg -i ./apache-pulsar-client*.deb
RUN npm install turbo -g
WORKDIR /app
RUN corepack enable
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 remixjs
RUN chown -R remixjs:nodejs /app
USER remixjs
COPY --from=pruner --chown=remixjs:nodejs /app/out/full/ .
COPY --from=production-deps --chown=remixjs:nodejs /app .
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/app/styles/tailwind.css ./apps/webapp/app/styles/tailwind.css
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build/server.js ./apps/webapp/build/server.js
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build ./apps/webapp/build
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/public ./apps/webapp/public
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/schema.prisma ./apps/webapp/build/schema.prisma
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/migrations ./apps/webapp/build/migrations
COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/node_modules/.prisma/client/libquery_engine-debian-openssl-1.1.x.so.node ./apps/webapp/build/libquery_engine-debian-openssl-1.1.x.so.node
# release_command = "pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma"
ENTRYPOINT ["pnpm", "--filter", "webapp", "run", "start"]
@@ -0,0 +1,17 @@
export function DynamicTriggerIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 21 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.0151 4C2.01629 2.89543 2.91269 2.00097 4.01726 2.00216L16.0172 2.0151C17.1218 2.01629 18.0163 2.91269 18.0151 4.01726L18.0021 16.0172C18.001 17.1218 17.1046 18.0163 16 18.0151L4 18.0021C2.89543 18.001 2.00097 17.1046 2.00216 16L2.0151 4ZM9.26429 4.75782C9.26473 4.34361 9.60088 4.00818 10.0151 4.00863C10.4293 4.00907 10.7647 4.34522 10.7643 4.75944L10.7606 8.1988L13.1952 5.76942C13.4884 5.47684 13.9633 5.47735 14.2559 5.77056C14.5484 6.06377 14.5479 6.53864 14.2547 6.83122L11.8201 9.26058L15.2594 9.26429C15.6736 9.26473 16.0091 9.60088 16.0086 10.0151C16.0082 10.4293 15.672 10.7647 15.2578 10.7643L11.8185 10.7606L14.2478 13.1952C14.5404 13.4884 14.5399 13.9633 14.2467 14.2558C13.9535 14.5484 13.4786 14.5479 13.186 14.2547L10.7567 11.8201L10.753 15.2594C10.7525 15.6736 10.4164 16.0091 10.0022 16.0086C9.58794 16.0082 9.25252 15.672 9.25296 15.2578L9.25667 11.8185L6.82209 14.2478C6.52888 14.5404 6.05401 14.5399 5.76143 14.2467C5.46885 13.9535 5.46937 13.4786 5.76258 13.186L8.19718 10.7567L4.75782 10.753C4.34361 10.7525 4.00818 10.4164 4.00863 10.0022C4.00907 9.58794 4.34522 9.25252 4.75944 9.25296L8.19877 9.25667L5.76942 6.82207C5.47684 6.52886 5.47735 6.05399 5.77056 5.76141C6.06377 5.46883 6.53864 5.46935 6.83122 5.76255L9.26058 8.19716L9.26429 4.75782Z"
fill="currentColor"
/>
</svg>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { ReactNode } from "react";
import { MainCenteredContainer } from "./layout/AppLayout";
import { Header1, Header2 } from "./primitives/Headers";
import { NamedIcon, NamedIconInBox } from "./primitives/NamedIcon";
import { Paragraph } from "./primitives/Paragraph";
type ComingSoonProps = {
title: string;
description: string;
icon: ReactNode;
};
export function ComingSoon({ title, description, icon }: ComingSoonProps) {
return (
<MainCenteredContainer>
<div className="flex flex-col justify-center rounded border border-slate-800 bg-slate-850">
<div className="flex items-center gap-2 border-b border-slate-750 px-4 py-4">
{typeof icon === "string" ? (
<NamedIconInBox
name={icon}
className={"h-10 w-10 bg-midnight-800"}
/>
) : (
icon
)}
<div className="mt-0.5 flex flex-col">
<Paragraph variant="extra-extra-small/caps">Coming soon</Paragraph>
<Header1 className="-mt-0.5">{title}</Header1>
</div>
</div>
<Paragraph variant="small" className="p-4">
{description}
</Paragraph>
</div>
</MainCenteredContainer>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/solid";
import {
Form,
useActionData,
useLocation,
useNavigation,
} from "@remix-run/react";
import { useState } from "react";
import { feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
import { FormError } from "./primitives/FormError";
import { InputGroup } from "./primitives/InputGroup";
import { Label } from "./primitives/Label";
import { Paragraph } from "./primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "./primitives/Select";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
export function Feedback() {
const [open, setOpen] = useState(false);
const location = useLocation();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [form, { path, feedbackType, message }] = useForm({
id: "accept-invite",
lastSubmission,
onValidate({ formData }) {
return parse(formData, { schema });
},
});
if (
open &&
navigation.formAction === "/resources/feedback" &&
form.error === undefined &&
form.errors.length === 0
) {
setOpen(false);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild={true}>
<Button
variant="secondary/small"
LeadingIcon={ChatBubbleLeftRightIcon}
shortcut={{ key: "f" }}
>
Send us feedback
</Button>
</SheetTrigger>
<SheetContent size="sm">
<SheetHeader className="justify-between">Give us feedback</SheetHeader>
<SheetBody>
<Paragraph variant="small" className="mb-4">
We'd love to hear your feedback, good, bad or ugly.
</Paragraph>
<Form method="post" action="/resources/feedback" {...form.props}>
<Fieldset>
<input
value={location.pathname}
{...conform.input(path, { type: "hidden" })}
/>
<InputGroup>
<Label>What kind of feedback do you have?</Label>
<SelectGroup>
<Select {...conform.input(feedbackType)} defaultValue={"bug"}>
<SelectTrigger size="medium" width="full">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{Object.entries(feedbackTypeLabel).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<FormError id={feedbackType.errorId}>
{feedbackType.error}
</FormError>
</InputGroup>
<InputGroup>
<Label>Message</Label>
<TextArea {...conform.textarea(message)} />
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
className="max-w-md"
confirmButton={
<Button type="submit" variant="primary/medium">
Send
</Button>
}
/>
</Fieldset>
</Form>
</SheetBody>
</SheetContent>
</Sheet>
);
}
+19 -6
View File
@@ -11,7 +11,7 @@ import { darkTheme } from "./codeMirrorTheme";
import { cn } from "~/utils/cn";
export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
content: string;
defaultValue?: string;
language?: "json";
readOnly?: boolean;
onChange?: (value: string) => void;
@@ -33,13 +33,14 @@ const defaultProps: JSONEditorDefaultProps = {
export function JSONEditor(opts: JSONEditorProps) {
const {
content,
defaultValue = "",
language,
readOnly,
onChange,
onUpdate,
onBlur,
basicSetup,
autoFocus,
} = {
...defaultProps,
...opts,
@@ -59,15 +60,15 @@ export function JSONEditor(opts: JSONEditorProps) {
extensions,
editable: !readOnly,
contentEditable: !readOnly,
value: content,
autoFocus: false,
value: defaultValue,
autoFocus,
theme: darkTheme(),
indentWithTab: false,
basicSetup,
onChange,
onUpdate,
};
const { setContainer } = useCodeMirror(settings);
const { setContainer, state } = useCodeMirror(settings);
useEffect(() => {
if (editor.current) {
@@ -75,9 +76,21 @@ export function JSONEditor(opts: JSONEditorProps) {
}
}, [setContainer]);
//if the defaultValue changes update the editor
useEffect(() => {
if (state !== undefined) {
state.update({
changes: { from: 0, to: state.doc.length, insert: defaultValue },
});
}
}, [defaultValue, state]);
return (
<div
className={cn("no-scrollbar overflow-y-auto", opts.className)}
className={cn(
"overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700",
opts.className
)}
ref={editor}
onBlur={() => {
if (!onBlur) return;
@@ -1,7 +1,7 @@
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
type Environment = Pick<RuntimeEnvironment, "type" | "slug">;
type Environment = Pick<RuntimeEnvironment, "type">;
export function EnvironmentLabel({
environment,
@@ -13,7 +13,7 @@ export function EnvironmentLabel({
return (
<span
className={cn(
"rounded-sm px-1 py-0.5 text-xxs font-medium uppercase text-midnight-900",
"flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
environmentColorClassName(environment),
className
)}
@@ -0,0 +1,44 @@
import {
ApiAuthenticationMethodApiKey,
Integration,
} from "~/services/externalApis/types";
import { Paragraph } from "../primitives/Paragraph";
import { CodeBlock } from "../code/CodeBlock";
import { InlineCode } from "../code/InlineCode";
import { Header1, Header2 } from "../primitives/Headers";
import { ClipboardField } from "../primitives/ClipboardField";
export function ApiKeyHelp({
integration,
apiAuth,
}: {
integration: Integration;
apiAuth: ApiAuthenticationMethodApiKey;
}) {
return (
<div className="mt-4">
<Header1 className="mb-2">
How to use {integration.name} with API keys
</Header1>
<Paragraph spacing>
You can use API keys to authenticate with {integration.name}. Your API
keys won't leave your server, we'll never see them.
</Paragraph>
<Paragraph spacing>
First install the <InlineCode>{integration.packageName}</InlineCode>{" "}
package using your preferred package manager. For example:
</Paragraph>
<ClipboardField
variant="secondary/medium"
value={`npm install ${integration.packageName}`}
className="mb-4"
/>
{apiAuth.help.samples.map((sample, i) => (
<div key={i}>
<Paragraph spacing>{sample.title}</Paragraph>
<CodeBlock code={sample.code} className="mb-4" />
</div>
))}
</div>
);
}
@@ -0,0 +1,148 @@
import React, { useState } from "react";
import {
ApiAuthenticationMethodApiKey,
Integration,
} from "~/services/externalApis/types";
import { Header1, Header2 } from "../primitives/Headers";
import { NamedIconInBox } from "../primitives/NamedIcon";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "../primitives/Sheet";
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
import { ApiKeyHelp } from "./ApiKeyHelp";
import { CustomHelp } from "./CustomHelp";
import { SelectOAuthMethod } from "./SelectOAuthMethod";
import { Paragraph } from "../primitives/Paragraph";
import { LinkButton } from "../primitives/Buttons";
type IntegrationMethod = "apikey" | "oauth2" | "custom";
export function ConnectToIntegrationSheet({
integration,
organizationId,
button,
className,
callbackUrl
}: {
integration: Integration;
organizationId: string;
button: React.ReactNode;
callbackUrl: string;
className?: string;
}) {
const [integrationMethod, setIntegrationMethod] = useState<
IntegrationMethod | undefined
>(undefined);
const authMethods = Object.values(integration.authenticationMethods);
const hasApiKeyOption = authMethods.some((s) => s.type === "apikey");
const hasOAuth2Option = authMethods.some((s) => s.type === "oauth2");
return (
<Sheet>
<SheetTrigger className={className}>{button}</SheetTrigger>
<SheetContent size="lg">
<SheetHeader>
<NamedIconInBox name={integration.identifier} className="h-9 w-9" />
<div className="grow">
<Header2>{integration.name}</Header2>
{integration.description && (
<Paragraph variant="small">{integration.description}</Paragraph>
)}
</div>
<LinkButton
to={`https://trigger.dev/docs/integrations/${integration.identifier}`}
variant="secondary/small"
LeadingIcon="docs"
>
View docs
</LinkButton>
</SheetHeader>
<SheetBody>
<Header2 className="mb-2">Choose an integration method</Header2>
<RadioGroup
name="method"
className="flex gap-2"
value={integrationMethod}
onValueChange={(v) => setIntegrationMethod(v as IntegrationMethod)}
>
{hasOAuth2Option && (
<RadioGroupItem
id="oauth2"
value="oauth2"
label="OAuth"
description="We handle OAuth for you or your users."
variant="description"
/>
)}
{hasApiKeyOption && (
<RadioGroupItem
id="apikey"
value="apikey"
label="API Key"
description="Use API keys in your code. They never leave your server."
variant="description"
/>
)}
<RadioGroupItem
id="custom"
value="custom"
label="Fetch/Existing SDK"
description={`Alternatively, use ${integration.name} without our integration.`}
variant="description"
/>
</RadioGroup>
{integrationMethod && (
<SelectedIntegrationMethod
integration={integration}
organizationId={organizationId}
method={integrationMethod}
callbackUrl={callbackUrl}
/>
)}
</SheetBody>
</SheetContent>
</Sheet>
);
}
function SelectedIntegrationMethod({
integration,
organizationId,
method,
callbackUrl
}: {
integration: Integration;
organizationId: string;
method: IntegrationMethod;
callbackUrl: string;
}) {
const authMethods = Object.values(integration.authenticationMethods);
switch (method) {
case "apikey":
const apiAuth = authMethods.find((a) => a.type === "apikey");
if (!apiAuth) return null;
return (
<ApiKeyHelp
integration={integration}
apiAuth={apiAuth as ApiAuthenticationMethodApiKey}
/>
);
case "oauth2":
return (
<SelectOAuthMethod
integration={integration}
organizationId={organizationId}
callbackUrl={callbackUrl}
/>
);
case "custom":
return <CustomHelp name={integration.name} />;
}
}
@@ -0,0 +1,259 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { useFetcher, useLocation, useNavigation } from "@remix-run/react";
import cuid from "cuid";
import { useState } from "react";
import simplur from "simplur";
import { useTextFilter } from "~/hooks/useTextFilter";
import { createSchema } from "~/routes/resources.connection.$organizationId.oauth2";
import {
ApiAuthenticationMethodOAuth2,
Integration,
Scope,
} from "~/services/externalApis/types";
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Checkbox } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import type { ConnectionType } from "@trigger.dev/database";
import { useFeatures } from "~/hooks/useFeatures";
export type Status = "loading" | "idle";
export function ConnectToOAuthForm({
integration,
authMethod,
authMethodKey,
organizationId,
clientType,
callbackUrl,
}: {
integration: Integration;
authMethod: ApiAuthenticationMethodOAuth2;
authMethodKey: string;
organizationId: string;
clientType: ConnectionType;
callbackUrl: string;
}) {
const [id] = useState<string>(cuid());
const transition = useNavigation();
const fetcher = useFetcher();
const { isManagedCloud } = useFeatures();
const [
form,
{ title, scopes, hasCustomClient, customClientId, customClientSecret },
] = useForm({
lastSubmission: fetcher.data,
onValidate({ formData }) {
return parse(formData, {
// Create the schema without any constraint defined
schema: createSchema(),
});
},
});
const location = useLocation();
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(
new Set(
authMethod.scopes.filter((s) => s.defaultChecked).map((s) => s.name)
)
);
const requiresCustomOAuthApp = clientType === "EXTERNAL" || !isManagedCloud;
const [useMyOAuthApp, setUseMyOAuthApp] = useState(requiresCustomOAuthApp);
const { filterText, setFilterText, filteredItems } = useTextFilter<Scope>({
items: authMethod.scopes,
filter: (scope, text) => {
if (scope.name.toLowerCase().includes(text.toLowerCase())) return true;
if (
scope.description &&
scope.description.toLowerCase().includes(text.toLowerCase())
)
return true;
return false;
},
});
return (
<fetcher.Form
method="post"
action={`/resources/connection/${organizationId}/oauth2`}
{...form.props}
className="flex h-full max-h-full flex-grow flex-col"
>
<Fieldset>
<input type="hidden" name="id" value={id} />
<input
type="hidden"
name="integrationIdentifier"
value={integration.identifier}
/>
<input
type="hidden"
name="integrationAuthMethod"
value={authMethodKey}
/>
<input type="hidden" name="redirectTo" value={location.pathname} />
<InputGroup>
<FormError>{form.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label variant="large">Name</Label>
<Input
type="text"
fullWidth
{...conform.input(title)}
placeholder={`e.g. Personal ${integration.name}`}
/>
<FormError>{title.error}</FormError>
</InputGroup>
<input type="hidden" name="clientType" value={clientType} />
<div>
<Header2>Use my OAuth App</Header2>
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert the
details.
</Paragraph>
<Checkbox
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
disabled={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
/>
{useMyOAuthApp && (
<div className="ml-6 mt-2">
<Paragraph variant="small" className="mb-2">
Set the callback url to{" "}
<CodeBlock code={callbackUrl} showLineNumbers={false} />
</Paragraph>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<InputGroup fullWidth>
<Label variant="small">Client ID</Label>
<Input
fullWidth
{...conform.input(customClientId, { type: "text" })}
/>
</InputGroup>
<InputGroup fullWidth>
<Label variant="small">Client secret</Label>
<Input
fullWidth
{...conform.input(customClientSecret, {
type: "password",
})}
/>
</InputGroup>
</div>
<FormError>{customClientId.error}</FormError>
</div>
</div>
)}
</div>
<div>
<Header2>Scopes</Header2>
<Paragraph variant="small" className="mb-4">
Select the scopes you want to grant to {integration.name} in order
for it to access your data. Note: If you try and perform an action
in a Job that requires a scope you havent granted, that task will
fail.
</Paragraph>
{/* <Header3 className="mb-2">
Select from popular scope collections
</Header3>
<fieldset>
<Checkbox
id="allScopes"
label="Select all"
variant="button/small"
/>
</fieldset> */}
<div className="mb-2 mt-4 flex items-center justify-between">
<Header3>Select {integration.name} scopes</Header3>
<Paragraph variant="small" className="text-slate-500">
{simplur`${selectedScopes.size} scope[|s] selected`}
</Paragraph>
</div>
<Input
placeholder="Search scopes"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="flex flex-col gap-y-0.5 overflow-hidden rounded-md">
{filteredItems.length === 0 && (
<Paragraph variant="small" className="p-4">
No scopes match {filterText}. Try a different search query.
</Paragraph>
)}
{authMethod.scopes.map((s) => {
return (
<Checkbox
key={s.name}
id={s.name}
value={s.name}
name="scopes"
label={s.name}
defaultChecked={s.defaultChecked ?? false}
badges={s.annotations?.map((a) => a.label)}
description={s.description}
variant="description"
className={cn(
filteredItems.find((f) => f.name === s.name) ? "" : "hidden"
)}
onChange={(isChecked) => {
if (isChecked) {
setSelectedScopes((selected) => {
selected.add(s.name);
return new Set(selected);
});
} else {
setSelectedScopes((selected) => {
selected.delete(s.name);
return new Set(selected);
});
}
}}
/>
);
})}
</div>
</div>
</Fieldset>
<div className="flex items-center justify-end gap-x-4">
<FormError>{scopes.error}</FormError>
<Button
type="submit"
className="flex gap-2"
disabled={transition.state !== "idle"}
variant="primary/medium"
>
<>
<NamedIcon name={integration.identifier} className={"h-4 w-4"} />
Connect to {integration.name}
</>
</Button>
</div>
</fetcher.Form>
);
}
@@ -0,0 +1,84 @@
import { CodeBlock } from "../code/CodeBlock";
import { Header1, Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
export function CustomHelp({ name }: { name: string }) {
return (
<div className="mt-4">
<Header1 className="mb-2">
You can use any API with requests or an SDK
</Header1>
<Header2 className="mb-2">How to use an SDK</Header2>
<Paragraph spacing>
You can call SDK methods from inside the run function, but you should
wrap them in a Task to make sure they're resumable.
</Paragraph>
<Paragraph spacing>
Here's an example with the official GitHub SDK
</Paragraph>
<CodeBlock
code={`
new Job(client, {
id: "scheduled-job-1",
name: "Scheduled Job 1",
version: "0.1.1",
trigger: cronTrigger({
cron: "*/5 * * * *", // every 5 minutes
}),
run: async (payload, io, ctx) => {
//wrap an SDK call in io.runTask so it's resumable and displays in logs
const repo = await io.runTask(
"Get repo",
//you can add metadata to the task to improve the display in the logs
{ name: "Get repo", icon: "github" },
async () => {
//this is the regular GitHub SDK
const response = await octokit.rest.repos.get({
owner: "triggerdotdev",
repo: "trigger.dev",
});
return response.data;
}
);
},
});
`}
highlightedRanges={[[9, 22]]}
className="mb-4"
/>
<Header2 className="mb-2">How to use fetch</Header2>
<Paragraph spacing>
You can use the fetch API to make requests to any API. Or a different
request library like axios if you'd prefer. Again wrapping the request
in a Task will make sure it's resumable.
</Paragraph>
<CodeBlock
code={`
new Job(client, {
id: "scheduled-job-1",
name: "Scheduled Job 1",
version: "0.1.1",
trigger: cronTrigger({
cron: "*/5 * * * *", // every 5 minutes
}),
run: async (payload, io, ctx) => {
//wrap anything in io.runTask so it's resumable and displays in logs
const repo = await io.runTask(
"Get org",
//you can add metadata to the task to improve the display in the logs
{ name: "Get org", icon: "github" },
async () => {
//you can use fetch, axios, or any other library to make requests
const response = await fetch('https://api.github.com/orgs/nodejs');
return response.json();
}
);
},
});
`}
highlightedRanges={[[9, 19]]}
className="mb-4"
/>
</div>
);
}
@@ -0,0 +1,75 @@
import React from "react";
import { Api } from "~/services/externalApis/apis";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { Header1 } from "../primitives/Headers";
import { NamedIconInBox } from "../primitives/NamedIcon";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "../primitives/Sheet";
import { CustomHelp } from "./CustomHelp";
import { CheckIcon } from "@heroicons/react/24/solid";
import { useFetcher } from "@remix-run/react";
import { Paragraph } from "../primitives/Paragraph";
export function NoIntegrationSheet({
api,
requested,
button,
}: {
api: Api;
requested: boolean;
button: React.ReactNode;
}) {
const fetcher = useFetcher();
const isLoading = fetcher.state !== "idle";
return (
<Sheet>
<SheetTrigger>{button}</SheetTrigger>
<SheetContent size="lg">
<SheetHeader className="justify-between">
<div className="flex items-center gap-4">
<NamedIconInBox name={api.identifier} className="h-9 w-9" />
<Header1>{api.name}</Header1>
</div>
{requested ? (
<div className="flex items-center gap-1">
<CheckIcon className="h-4 w-4 text-green-500" />
<Paragraph variant="small">
We'll let you know when the integration is available.
</Paragraph>
</div>
) : (
<fetcher.Form
method="post"
action={`/resources/apivote/${api.identifier}`}
>
<Button
variant="primary/small"
disabled={isLoading}
LeadingIcon={isLoading ? "spinner-white" : undefined}
>
{isLoading
? "Saving…"
: `I want an integration for ${api.name}`}
</Button>
</fetcher.Form>
)}
</SheetHeader>
<SheetBody>
<Callout variant="info">
We dont have an integration for the {api.name} API yet but you can
request one by clicking the button above. In the meantime, connect
to {api.name} using the methods below.
</Callout>
<CustomHelp name={api.name} />
</SheetBody>
</SheetContent>
</Sheet>
);
}
@@ -1,321 +0,0 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { useFetcher, useLocation, useNavigation } from "@remix-run/react";
import cuid from "cuid";
import React, { useState } from "react";
import simplur from "simplur";
import { useTextFilter } from "~/hooks/useTextFilter";
import { createSchema } from "~/routes/resources.connection.$organizationId.oauth2";
import { Integration, Scope } from "~/services/externalApis/types";
import { cn } from "~/utils/cn";
import { CodeBlock } from "../code/CodeBlock";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { Checkbox } from "../primitives/Checkbox";
import { Fieldset } from "../primitives/Fieldset";
import { FormError } from "../primitives/FormError";
import { Header2, Header3 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { NamedIcon, NamedIconInBox } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Sheet,
SheetBody,
SheetContent,
SheetFooter,
SheetTrigger,
} from "../primitives/Sheet";
export type Status = "loading" | "idle";
export function OAuthConnectSheet({
integration,
authMethodKey,
organizationId,
button,
className,
}: {
integration: Integration;
authMethodKey: string;
organizationId: string;
button: React.ReactNode;
className?: string;
}) {
const [id] = useState<string>(cuid());
const transition = useNavigation();
const fetcher = useFetcher();
const [
form,
{ title, scopes, hasCustomClient, customClientId, customClientSecret },
] = useForm({
lastSubmission: fetcher.data,
onValidate({ formData }) {
return parse(formData, {
// Create the schema without any constraint defined
schema: createSchema(),
});
},
});
const apiAuthmethod = integration.authenticationMethods[authMethodKey];
const location = useLocation();
const options = [
{ label: "Your team", value: "DEVELOPER" },
{ label: "Your users", value: "EXTERNAL" },
];
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(
new Set(
apiAuthmethod.scopes.filter((s) => s.defaultChecked).map((s) => s.name)
)
);
const [useMyOAuthApp, setUseMyOAuthApp] = useState(false);
const [clientType, setClientType] = useState<"EXTERNAL" | "DEVELOPER">(
"DEVELOPER"
);
const { filterText, setFilterText, filteredItems } = useTextFilter<Scope>({
items: apiAuthmethod.scopes,
filter: (scope, text) => {
if (scope.name.toLowerCase().includes(text.toLowerCase())) return true;
if (
scope.description &&
scope.description.toLowerCase().includes(text.toLowerCase())
)
return true;
return false;
},
});
return (
<Sheet>
<SheetTrigger className={className}>{button}</SheetTrigger>
<SheetContent size="lg">
<fetcher.Form
method="post"
action={`/resources/connection/${organizationId}/oauth2`}
{...form.props}
className="flex h-full max-h-full flex-grow flex-col"
>
<SheetBody>
<div className="flex items-center gap-4 border-b border-slate-800 pb-3.5">
<NamedIconInBox
name={integration.identifier}
className="h-9 w-9"
/>
<div>
<Header2>Connect to {integration.name}</Header2>
<Paragraph variant="extra-small">
{apiAuthmethod.name}{" "}
{apiAuthmethod.description &&
` ${apiAuthmethod.description}`}
</Paragraph>
</div>
</div>
<Fieldset>
<input type="hidden" name="id" value={id} />
<input
type="hidden"
name="integrationIdentifier"
value={integration.identifier}
/>
<input
type="hidden"
name="integrationAuthMethod"
value={authMethodKey}
/>
<input
type="hidden"
name="redirectTo"
value={location.pathname}
/>
<InputGroup>
<FormError>{form.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label variant="large">Name</Label>
<Input
type="text"
fullWidth
{...conform.input(title)}
placeholder={`e.g. Personal ${integration.name}`}
/>
<FormError>{title.error}</FormError>
</InputGroup>
<Callout variant="info">
Coming soon create connections so you can run Jobs with your
users credentials.
</Callout>
{/* <div>
<Header2>Who will connect to this API?</Header2>
<Paragraph variant="small" className="mb-2">
Select Your team if you want create internal Jobs for your
own company. Select 'Your users' if you want to create Jobs
where your customers authenticate with this API.
</Paragraph>
<FormSegmentedControl
name="clientType"
defaultValue={clientType}
options={options}
onChange={(val) => setClientType(val as any)}
/>
</div> */}
<input type="hidden" name="clientType" value={"DEVELOPER"} />
<div>
<Header2>Use my OAuth App</Header2>
<Paragraph variant="small" className="mb-2">
To use your own OAuth app, check the option below and insert
the details.
</Paragraph>
<Checkbox
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
defaultChecked={
clientType === "EXTERNAL" ? true : useMyOAuthApp
}
disabled={clientType === "EXTERNAL"}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
/>
{useMyOAuthApp && (
<div className="ml-6 mt-2">
<Paragraph variant="small" className="mb-2">
Set the callback url to{" "}
<CodeBlock
code={`${origin}/oauth2/callback`}
showLineNumbers={false}
/>
</Paragraph>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<InputGroup fullWidth>
<Label variant="small">Client ID</Label>
<Input
fullWidth
{...conform.input(customClientId, { type: "text" })}
/>
</InputGroup>
<InputGroup fullWidth>
<Label variant="small">Client secret</Label>
<Input
fullWidth
{...conform.input(customClientSecret, {
type: "password",
})}
/>
</InputGroup>
</div>
<FormError>{customClientId.error}</FormError>
</div>
</div>
)}
</div>
<div>
<Header2>Scopes</Header2>
<Paragraph variant="small" className="mb-4">
Select the scopes you want to grant to {integration.name} in
order for it to access your data. Note: If you try and perform
an action in a Job that requires a scope you havent granted,
that task will fail.
</Paragraph>
{/* <Header3 className="mb-2">
Select from popular scope collections
</Header3>
<fieldset>
<Checkbox
id="allScopes"
label="Select all"
variant="button/small"
/>
</fieldset> */}
<div className="mb-2 mt-4 flex items-center justify-between">
<Header3>Select {integration.name} scopes</Header3>
<Paragraph variant="small" className="text-slate-500">
{simplur`${selectedScopes.size} scope[|s] selected`}
</Paragraph>
</div>
<Input
placeholder="Search scopes"
className="mb-2"
variant="medium"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="flex flex-col gap-y-0.5 overflow-hidden rounded-md">
{filteredItems.length === 0 && (
<Paragraph variant="small" className="p-4">
No scopes match {filterText}. Try a different search
query.
</Paragraph>
)}
{apiAuthmethod.scopes.map((s) => {
return (
<Checkbox
key={s.name}
id={s.name}
value={s.name}
name="scopes"
label={s.name}
defaultChecked={s.defaultChecked ?? false}
badges={s.annotations?.map((a) => a.label)}
description={s.description}
variant="description"
className={cn(
filteredItems.find((f) => f.name === s.name)
? ""
: "hidden"
)}
onChange={(isChecked) => {
if (isChecked) {
setSelectedScopes((selected) => {
selected.add(s.name);
return new Set(selected);
});
} else {
setSelectedScopes((selected) => {
selected.delete(s.name);
return new Set(selected);
});
}
}}
/>
);
})}
</div>
</div>
</Fieldset>
</SheetBody>
<SheetFooter>
<div className="flex items-center justify-end gap-x-4">
<FormError>{scopes.error}</FormError>
<Button
type="submit"
className="flex gap-2"
disabled={transition.state !== "idle"}
variant="primary/medium"
>
<>
<NamedIcon
name={integration.identifier}
className={"h-4 w-4"}
/>
Connect to {integration.name}
</>
</Button>
</div>
</SheetFooter>
</fetcher.Form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,120 @@
import { useState } from "react";
import {
ApiAuthenticationMethodOAuth2,
Integration,
} from "~/services/externalApis/types";
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
import type { ConnectionType } from "@trigger.dev/database";
import { Header2 } from "../primitives/Headers";
import { ConnectToOAuthForm } from "./ConnectToOAuthForm";
import { Paragraph } from "../primitives/Paragraph";
export function SelectOAuthMethod({
integration,
organizationId,
callbackUrl
}: {
integration: Integration;
organizationId: string;
callbackUrl: string;
}) {
const oAuthMethods = Object.entries(integration.authenticationMethods).filter(
(a): a is [string, ApiAuthenticationMethodOAuth2] => a[1].type === "oauth2"
);
const [oAuthKey, setOAuthKey] = useState<string | undefined>(
oAuthMethods.length === 1 ? oAuthMethods[0][0] : undefined
);
const [connectionType, setConnectionType] = useState<
ConnectionType | undefined
>();
const selectedOAuthMethod = oAuthKey
? (integration.authenticationMethods[
oAuthKey
] as ApiAuthenticationMethodOAuth2)
: undefined;
return (
<>
{oAuthMethods.length > 1 && (
<>
<Header2 className="mb-2 mt-4">Select an OAuth option</Header2>
<RadioGroup
name="oauth-method"
className="flex gap-2"
value={oAuthKey}
onValueChange={(v: any) => setOAuthKey(v)}
>
{oAuthMethods.map(([key, auth]) => (
<RadioGroupItem
key={key}
id={key}
value={key}
label={auth.name}
description={auth.description}
variant="description"
/>
))}
</RadioGroup>
</>
)}
{selectedOAuthMethod && (
<>
<Header2 className="mb-2 mt-4">
Who is connecting to {integration.name}
</Header2>
<RadioGroup
name="connection-type"
className="flex gap-2"
value={connectionType}
onValueChange={(v: any) => setConnectionType(v as ConnectionType)}
>
<RadioGroupItem
id="DEVELOPER"
value="DEVELOPER"
label="Developers"
description={`You will connect using an internal ${integration.name} account.`}
variant="description"
/>
<RadioGroupItem
id="EXTERNAL"
value="EXTERNAL"
label="Your users"
description="We will give you OAuth React components so you can connect as your users."
variant="description"
/>
</RadioGroup>
</>
)}
{selectedOAuthMethod &&
connectionType &&
oAuthKey &&
(connectionType === "DEVELOPER" ? (
<ConnectToOAuthForm
integration={integration}
authMethod={selectedOAuthMethod}
authMethodKey={oAuthKey}
organizationId={organizationId}
clientType={connectionType}
callbackUrl={callbackUrl}
/>
) : (
<>
<Header2 className="mb-1 mt-4">User OAuth coming soon</Header2>
<Paragraph spacing>
End-user OAuth is going to be released soon. If you are interested
in being an early beta tester then please{" "}
<a
href="mailto:founders@trigger.dev"
className="text-indigo-500 underline"
>
message us
</a>
.
</Paragraph>
</>
))}
</>
);
}
@@ -0,0 +1,10 @@
import type { ConnectionType } from "@trigger.dev/database";
export function connectionType(type: ConnectionType) {
switch (type) {
case "DEVELOPER":
return "Developer";
case "EXTERNAL":
return "Your users";
}
}
@@ -0,0 +1,168 @@
import { jobPath } from "~/utils/pathBuilder";
import { DateTime } from "../primitives/DateTime";
import { LabelValueStack } from "../primitives/LabelValueStack";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "../primitives/Table";
import { SimpleTooltip } from "../primitives/Tooltip";
import { runStatusTitle } from "../runs/RunStatuses";
import { ProjectJob, useProject } from "~/hooks/useProject";
import { useOrganization } from "~/hooks/useOrganizations";
import { JobRunStatus } from "~/models/job.server";
export function JobsTable({
jobs,
noResultsText,
}: {
jobs: ProjectJob[];
noResultsText: string;
}) {
const organization = useOrganization();
const project = useProject();
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Job</TableHeaderCell>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Integrations</TableHeaderCell>
<TableHeaderCell>Properties</TableHeaderCell>
<TableHeaderCell>Last run</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{jobs.length > 0 ? (
jobs.map((job) => {
const path = jobPath(organization, project, job);
return (
<TableRow key={job.id}>
<TableCell to={path}>
<span className="flex items-center gap-2">
<NamedIcon name={job.event.icon} className="h-8 w-8" />
<LabelValueStack
label={job.title}
value={
job.dynamic ? (
<span className="flex items-center gap-0.5">
<NamedIcon name="dynamic" className="h-4 w-4" />{" "}
<span className="uppercase">Dynamic:</span>{" "}
{job.event.title}
</span>
) : (
job.event.title
)
}
variant="primary"
/>
</span>
</TableCell>
<TableCell to={path}>
<LabelValueStack
label={job.slug}
value={`v${job.version}`}
variant="primary"
/>
</TableCell>
<TableCell to={path}>
{job.integrations.map((integration) => (
<SimpleTooltip
key={integration.key}
button={
<NamedIcon
name={integration.icon}
className="h-6 w-6"
/>
}
content={`${integration.title}: ${integration.key}`}
/>
))}
</TableCell>
<TableCell to={path}>
{job.properties && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
{job.properties.map((property, index) => (
<LabelValueStack
key={index}
label={property.label}
value={property.text}
className=" last:truncate"
/>
))}
</div>
}
content={
<div className="flex flex-col gap-2">
{job.properties.map((property, index) => (
<LabelValueStack
key={index}
label={property.label}
value={property.text}
/>
))}
</div>
}
/>
)}
</TableCell>
<TableCell to={path}>
{job.lastRun ? (
<LabelValueStack
label={
<span className={classForJobStatus(job.lastRun.status)}>
{runStatusTitle(job.lastRun.status)}
</span>
}
value={
<DateTime
date={job.lastRun.createdAt}
className={classForJobStatus(job.lastRun.status)}
/>
}
/>
) : (
<LabelValueStack label={"Never run"} value={""} />
)}
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<Paragraph
variant="small"
className="flex items-center justify-center"
>
{noResultsText}
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function classForJobStatus(status: JobRunStatus) {
switch (status) {
case "FAILURE":
case "TIMED_OUT":
case "WAITING_ON_CONNECTIONS":
case "PENDING":
return "text-rose-500";
default:
return "";
}
}
@@ -64,6 +64,10 @@ export function PageBody({
);
}
export function PageBodyPadding({ children }: { children: React.ReactNode }) {
return <div className="p-4">{children}</div>;
}
export function MainCenteredContainer({
children,
}: {
@@ -1,31 +1,44 @@
import { useMatches } from "@remix-run/react";
import { Fragment } from "react";
import {
useIntegrationClient,
useOptionalIntegrationClient,
} from "~/hooks/useIntegrationClient";
import { useJob, useOptionalJob } from "~/hooks/useJob";
import {
useOptionalOrganization,
useOrganization,
} from "~/hooks/useOrganizations";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { useOptionalRun, useRun } from "~/hooks/useRun";
import {
integrationClientConnectionsPath,
integrationClientPath,
integrationClientScopesPath,
jobPath,
jobTestPath,
projectEnvironmentsPath,
projectIntegrationsPath,
projectPath,
runPath,
runDashboardPath,
} from "~/utils/pathBuilder";
import { BreadcrumbIcon } from "../primitives/BreadcrumbIcon";
import { JobsMenu } from "./JobsMenu";
import { BreadcrumbLink } from "./NavBar";
import { ProjectsMenu } from "./ProjectsMenu";
import { useOptionalRun, useRun } from "~/hooks/useRun";
export type Breadcrumb = {
slug:
| "projects"
| "jobs"
| "integrations"
| "integration"
| "integration-job"
| "integration-connections"
| "integration-scopes"
| "environments"
| "job"
| "test"
| "runs"
| "run";
link?: {
@@ -49,6 +62,7 @@ export function Breadcrumb() {
const project = useOptionalProject();
const job = useOptionalJob();
const run = useOptionalRun();
const client = useOptionalIntegrationClient();
return (
<div className="hidden items-center md:flex">
@@ -61,6 +75,7 @@ export function Breadcrumb() {
project={project}
job={job}
run={run}
client={client}
/>
</Fragment>
))}
@@ -74,12 +89,14 @@ function BreadcrumbItem({
project,
job,
run,
client,
}: {
breadcrumb: Breadcrumb;
organization?: ReturnType<typeof useOrganization>;
project?: ReturnType<typeof useProject>;
job?: ReturnType<typeof useJob>;
run?: ReturnType<typeof useRun>;
client?: ReturnType<typeof useIntegrationClient>;
}) {
switch (breadcrumb.slug) {
case "projects":
@@ -108,6 +125,45 @@ function BreadcrumbItem({
title="Integrations"
/>
);
case "integration":
return (
<Fragment key={breadcrumb.slug}>
<BreadcrumbLink
to={projectIntegrationsPath(organization!, project!)}
title="Integrations"
/>
<BreadcrumbIcon />
<BreadcrumbLink
to={integrationClientPath(organization!, project!, client!)}
title={client!.title}
/>
</Fragment>
);
case "integration-job":
return (
<BreadcrumbLink
to={integrationClientPath(organization!, project!, client!)}
title={"Jobs"}
/>
);
case "integration-connections":
return (
<BreadcrumbLink
to={integrationClientConnectionsPath(
organization!,
project!,
client!
)}
title={"Connections"}
/>
);
case "integration-scopes":
return (
<BreadcrumbLink
to={integrationClientScopesPath(organization!, project!, client!)}
title={"Scopes"}
/>
);
case "job":
return (
<Fragment key={breadcrumb.slug}>
@@ -119,6 +175,14 @@ function BreadcrumbItem({
<JobsMenu key={breadcrumb.slug} />
</Fragment>
);
case "test":
return (
<BreadcrumbLink
key={breadcrumb.slug}
to={jobTestPath(organization!, project!, job!)}
title="Test"
/>
);
case "runs":
return (
<BreadcrumbLink
@@ -136,7 +200,7 @@ function BreadcrumbItem({
/>
<BreadcrumbIcon />
<BreadcrumbLink
to={runPath(organization!, project!, job!, run!)}
to={runDashboardPath(organization!, project!, job!, run!)}
title={`Run #${run!.number}`}
/>
</Fragment>
@@ -28,7 +28,11 @@ export function JobsMenu() {
<PopoverArrowTrigger isOpen={isOpen}>
{currentJob?.title ?? "Select a job"}
</PopoverArrowTrigger>
<PopoverContent className="w-80 p-0" align="start">
<PopoverContent
className="w-80 overflow-y-auto p-0"
align="start"
collisionPadding={20}
>
<PopoverSectionHeader title="Jobs" />
<div className="flex flex-col gap-1 p-1">
{project.jobs.map((job) => {
@@ -1,12 +1,11 @@
import { Popover, Transition } from "@headlessui/react";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/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 { BreadcrumbIcon } from "../primitives/BreadcrumbIcon";
import { Button, LinkButton } from "../primitives/Buttons";
import { LinkButton } from "../primitives/Buttons";
import { Breadcrumb } from "./Breadcrumb";
export function NavBar() {
@@ -27,14 +26,7 @@ export function NavBar() {
>
Documentation
</LinkButton>
<Button
variant="secondary/small"
data-attr="posthog-feedback-button"
LeadingIcon={ChatBubbleLeftRightIcon}
shortcut="F"
>
Send us feedback
</Button>
<Feedback />
</div>
</div>
);
@@ -17,6 +17,7 @@ import { NavLinkButton } from "../primitives/Buttons";
import type { IconNames } from "../primitives/NamedIcon";
import { SimpleTooltip } from "../primitives/Tooltip";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { useOptionalIntegrationClient } from "~/hooks/useIntegrationClient";
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
return (
@@ -26,14 +27,17 @@ export function SideMenuContainer({ children }: { children: React.ReactNode }) {
);
}
const expandedWith = "14rem";
const collapsedWith = "2.81rem";
const menuVariants = {
expanded: {
minWidth: "11rem",
width: "11rem",
minWidth: expandedWith,
width: expandedWith,
},
collapsed: {
minWidth: "2.81rem",
width: "2.81rem",
minWidth: collapsedWith,
width: collapsedWith,
},
};
@@ -44,7 +48,8 @@ export function ProjectSideMenu() {
//we collapse the menu if we're in a job or an integration
const job = useOptionalJob();
const isCollapsed = job !== undefined;
const client = useOptionalIntegrationClient();
const isCollapsed = job !== undefined || client !== undefined;
const jobsActive =
job !== undefined ||
@@ -75,7 +80,7 @@ export function ProjectSideMenu() {
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environments"
name="Environments & API Keys"
icon="environment"
to={projectEnvironmentsPath(organization, project)}
isCollapsed={isCollapsed}
@@ -89,7 +94,7 @@ export function ProjectSideMenu() {
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Billing"
name="Usage & Billing"
icon="billing"
to={organizationBillingPath(organization)}
isCollapsed={isCollapsed}
@@ -149,7 +154,7 @@ function SideMenuItem({
}}
>
<motion.span
className="pl-1"
className="shrink-0 pl-1"
animate={isCollapsed ? "collapsed" : "expanded"}
variants={itemVariants}
initial={isCollapsed ? "collapsed" : "expanded"}
@@ -1,9 +1,14 @@
import { LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
import { Link } from "@remix-run/react";
import React from "react";
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
import React, {
ReactComponentElement,
forwardRef,
useImperativeHandle,
useRef,
} from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import type { IconNames } from "./NamedIcon";
import { NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
const variant = {
"primary/small": {
@@ -12,8 +17,9 @@ const variant = {
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",
shortcutVariant: "small" as const,
shortcut:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-bright/40 text-bright group-hover:border-bright/60 transition",
"ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60 justify-self-center",
},
"secondary/small": {
textColor:
@@ -21,8 +27,9 @@ const variant = {
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",
shortcutVariant: "small" as const,
shortcut:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60 transition",
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
},
"tertiary/small": {
textColor:
@@ -30,8 +37,9 @@ const variant = {
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",
shortcutVariant: "small" as const,
shortcut:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60 transition",
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
},
"danger/small": {
textColor:
@@ -39,8 +47,9 @@ const variant = {
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",
shortcutVariant: "small" as const,
shortcut:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-bright/40 text-bright group-hover:border-bright/60 transition",
"ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"primary/medium": {
textColor:
@@ -48,8 +57,9 @@ const variant = {
button:
"h-8 px-2 text-sm bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
icon: "h-4",
shortcutVariant: "medium" as const,
shortcut:
"text-[0.6rem] leading-[1.13rem] min-w-[20px] px-1 rounded-[3px] ml-1.5 -mr-0.5 border border-bright/40 text-bright group-hover:border-bright/60 transition",
"ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"secondary/medium": {
textColor:
@@ -57,8 +67,9 @@ const variant = {
button:
"h-8 px-2 text-sm bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
icon: "h-4",
shortcutVariant: "medium" as const,
shortcut:
"text-[0.6rem] leading-[1.13rem] min-w-[20px] px-1 rounded-[3px] ml-1.5 -mr-0.5 border border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright transition",
"ml-1.5 -mr-0.5 border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright",
},
"tertiary/medium": {
textColor:
@@ -66,8 +77,9 @@ const variant = {
button:
"h-8 px-2 text-sm bg-transparent group-hover:bg-slate-850 disabled:opacity-50",
icon: "h-4",
shortcutVariant: "medium" as const,
shortcut:
"text-[0.6rem] leading-[1.13rem] min-w-[20px] px-1 rounded-[3px] ml-1.5 -mr-0.5 border border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright transition",
"ml-1.5 -mr-0.5 border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright",
},
"danger/medium": {
textColor:
@@ -75,8 +87,9 @@ const variant = {
button:
"h-8 px-2 text-sm bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50",
icon: "h-4",
shortcutVariant: "medium" as const,
shortcut:
"text-[0.6rem] leading-[1.13rem] min-w-[20px] px-1 rounded-[3px] ml-1.5 -mr-0.5 border border-bright/40 text-bright group-hover:border-bright/60 transition",
"ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"primary/large": {
textColor:
@@ -84,6 +97,7 @@ const variant = {
button:
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
icon: "h-5",
shortcutVariant: undefined,
shortcut: undefined,
},
"secondary/large": {
@@ -91,6 +105,7 @@ const variant = {
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",
shortcutVariant: undefined,
shortcut: undefined,
},
"menu-item": {
@@ -98,6 +113,7 @@ const variant = {
button:
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-slate-800 transition",
icon: "h-5",
shortcutVariant: undefined,
shortcut: undefined,
},
};
@@ -116,7 +132,7 @@ type ButtonContentPropsType = {
fullWidth?: boolean;
textAlignLeft?: boolean;
className?: string;
shortcut?: string;
shortcut?: ShortcutDefinition;
variant: keyof typeof variant;
};
@@ -132,15 +148,14 @@ export function ButtonContent(props: ButtonContentPropsType) {
textAlignLeft,
className,
} = props;
const variation = allVariants.variant[props.variant];
// Based on the size prop, we'll use the corresponding variant classnames
const btnClassName = cn(
allVariants.$all,
allVariants.variant[props.variant].button
);
const iconClassName = allVariants.variant[props.variant].icon;
const shortcutClassName = allVariants.variant[props.variant].shortcut;
const textColorClassName = allVariants.variant[props.variant].textColor;
const btnClassName = cn(allVariants.$all, variation.button);
const iconClassName = variation.icon;
const shortcutClassName = variation.shortcut;
const textColorClassName = variation.textColor;
return (
<div
className={cn(
@@ -181,7 +196,6 @@ export function ButtonContent(props: ButtonContentPropsType) {
<span
className={cn(
"mx-auto grow self-center truncate px-1",
textColorClassName
)}
>
@@ -211,7 +225,13 @@ export function ButtonContent(props: ButtonContentPropsType) {
)}
/>
))}
{shortcut && <span className={cn(shortcutClassName)}>{shortcut}</span>}
{shortcut && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
</div>
</div>
);
@@ -223,8 +243,23 @@ type ButtonPropsType = Pick<
> &
React.ComponentProps<typeof ButtonContent>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, ...props }, ref) => {
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled,
});
}
return (
<button
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
@@ -233,7 +268,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
onClick={onClick}
name={props.name}
value={props.value}
ref={ref}
ref={innerRef}
form={props.form}
>
<ButtonContent {...props} />
@@ -245,10 +280,23 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<LinkProps, "to" | "target"> &
React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
if (to.toString().startsWith("http")) {
return (
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
>
<ButtonContent {...props} />
@@ -258,6 +306,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
return (
<Link
to={to}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
>
<ButtonContent {...props} />
@@ -299,16 +348,19 @@ type ExtLinkProps = JSX.IntrinsicElements["a"] & {
href: string;
};
function ExtLink({ className, href, children, ...props }: ExtLinkProps) {
return (
<a
className={cn(className)}
target="_blank"
rel="noopener noreferrer"
href={href}
{...props}
>
{children}
</a>
);
}
const ExtLink = forwardRef<HTMLAnchorElement, ExtLinkProps>(
({ className, href, children, ...props }, ref) => {
return (
<a
className={cn(className)}
target="_blank"
rel="noopener noreferrer"
href={href}
ref={ref}
{...props}
>
{children}
</a>
);
}
);
@@ -6,26 +6,36 @@ import {
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
const variantClasses = {
info: {
className: "border-blue-400/20 bg-blue-400/30",
icon: <InformationCircleIcon className="h-5 w-5 text-blue-400" />,
icon: <InformationCircleIcon className="h-5 w-5 shrink-0 text-blue-400" />,
text: "text-blue-200",
},
warning: {
className: "border-yellow-400/20 bg-yellow-400/30",
icon: <ExclamationCircleIcon className="h-5 w-5 text-yellow-400" />,
icon: (
<ExclamationCircleIcon className="h-5 w-5 shrink-0 text-yellow-400" />
),
text: "text-yellow-200",
},
error: {
className: "border-[hsl(347,77%,50%)]/30 bg-[hsl(347,77%,50%)]/20",
icon: <ExclamationTriangleIcon className="h-5 w-5 text-rose-400" />,
icon: (
<ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-rose-400" />
),
text: "text-rose-200",
},
idea: {
className: "border-green-400/20 bg-green-400/30",
icon: <LightBulbIcon className="h-5 w-5 text-green-400" />,
icon: <LightBulbIcon className="h-5 w-5 shrink-0 text-green-400" />,
text: "text-green-200",
},
success: {
className: "border-green-400/20 bg-green-400/30",
icon: <CheckCircleIcon className="h-5 w-5 shrink-0 text-green-400" />,
text: "text-green-200",
},
} as const;
@@ -46,22 +56,20 @@ export function Callout({
return (
<div
className={cn(
`flex w-full gap-4 rounded-md border py-3 pl-3 pr-4 shadow-md backdrop-blur-sm`,
`flex w-full items-start gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
variantDefinition.className,
className
)}
>
<div className="flex items-start justify-start gap-2.5">
{icon ? icon : variantDefinition.icon}
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.text}>
{children}
</Paragraph>
) : (
children
)}
</div>
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.text}>
{children}
</Paragraph>
) : (
children
)}
</div>
);
}
@@ -41,7 +41,7 @@ const variants = {
},
description: {
button: "w-full py-2 pl-3 pr-4 checked:hover:bg-slate-850 transition",
label: "text-bright font-mono",
label: "text-bright font-semibold",
description: "text-dimmed",
inputPosition: "mt-1",
isChecked: "bg-slate-850",
@@ -59,7 +59,6 @@ export type CheckboxProps = Omit<
variant?: keyof typeof variants;
label?: string;
description?: string;
badge?: string;
badges?: string[];
className?: string;
onChange?: (isChecked: boolean) => void;
@@ -76,7 +75,6 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
label,
description,
defaultChecked,
badge,
badges,
disabled,
className,
@@ -0,0 +1,148 @@
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
import { useCallback, useRef, useState } from "react";
import { CheckIcon } from "@heroicons/react/20/solid";
import { IconNames, NamedIcon } from "./NamedIcon";
const variations = {
"primary/small": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-800 bg-slate-850 text-xs transition hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-slate-850 border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "primary/small" as const,
button: "rounded-l-none min-w-[3.1rem]",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"secondary/small": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-750 bg-slate-850 text-xs transition hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-slate-850 border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "secondary/small" as const,
button: "rounded-l-none border-l border-slate-750 min-w-[3.1rem]",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"tertiary/small": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-850 bg-transparent text-xs transition hover:border-slate-800 hover:bg-slate-950 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "tertiary/small" as const,
button: "rounded-l-none border-l border-slate-850 min-w-[3.1rem]",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"primary/medium": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-800 bg-slate-850 text-sm transition hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-slate-850 border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "primary/medium" as const,
button: "rounded-l-none min-w-[4rem]",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
"secondary/medium": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-750 bg-slate-850 text-sm transition hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-slate-850 border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "secondary/medium" as const,
button: "rounded-l-none border-l border-slate-750 min-w-[4rem]",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
"tertiary/medium": {
container:
"flex items-center text-dimmed font-mono rounded border border-slate-850 bg-transparent text-sm transition hover:border-slate-800 hover:bg-slate-950 focus:border-4 focus:border-solid focus:border-l-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "tertiary/medium" as const,
button: "rounded-l-none border-l border-slate-850 min-w-[4rem]",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
};
type ClipboardFieldProps = {
value: string;
secure?: boolean;
variant: keyof typeof variations;
className?: string;
icon?: IconNames | React.ReactNode;
};
export function ClipboardField({
value,
secure = false,
variant,
className,
icon,
}: ClipboardFieldProps) {
const [isSecure, setIsSecure] = useState(secure);
const [copied, setCopied] = useState(false);
const copy = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[value]
);
const { container, input, buttonVariant, button } = variations[variant];
const iconClassName = variations[variant].iconSize;
const iconPosition = variations[variant].iconPadding;
const inputIcon = useRef<HTMLInputElement>(null);
return (
<div className={cn(container, className)}>
{icon && (
<div
onClick={() => inputIcon.current && inputIcon.current.focus()}
className={cn(iconPosition, "flex items-center")}
>
{typeof icon === "string" ? (
<NamedIcon name={icon} className={iconClassName} />
) : (
icon
)}
</div>
)}
<input
type="text"
ref={inputIcon}
value={isSecure ? "•".repeat(value.length) : value}
readOnly={true}
className={cn("shrink grow select-all overflow-x-auto", input)}
size={value.length}
onFocus={(e) => {
if (secure) {
setIsSecure((i) => false);
}
e.currentTarget.select();
}}
onBlur={() => {
if (secure) {
setIsSecure((i) => true);
}
}}
/>
<Button
variant={buttonVariant}
onClick={copy}
className={cn("shrink-0 grow-0", button)}
>
{copied ? <CheckIcon className="h-4 w-4 text-green-500" /> : "Copy"}
</Button>
</div>
);
}
@@ -1,12 +1,16 @@
import { cn } from "~/utils/cn";
export function FormButtons({
cancelButton,
confirmButton,
className,
}: {
cancelButton?: React.ReactNode;
confirmButton: React.ReactNode;
className?: string;
}) {
return (
<div className="flex w-full items-center justify-between">
<div className={cn("flex w-full items-center justify-between", className)}>
{cancelButton ? cancelButton : <div />} {confirmButton}
</div>
);
@@ -40,12 +40,13 @@ export function LabelValueStack({
<div
className={cn(
"flex items-baseline",
layout === "vertical" ? "flex-col" : "gap-1",
layout === "vertical" && "flex-col",
variant === "primary" ? "gap-x-1 gap-y-0" : "gap-x-1 gap-y-0.5",
className
)}
>
<Paragraph variant={variation.label}>{label}</Paragraph>
<div>
<>
{href ? (
<SimpleTooltip
side="bottom"
@@ -53,7 +54,7 @@ export function LabelValueStack({
<Paragraph variant={variation.value}>
<a
href={href}
className=" underline underline-offset-2"
className="underline underline-offset-2"
target="_blank"
>
{value}
@@ -66,7 +67,7 @@ export function LabelValueStack({
) : (
<Paragraph variant={variation.value}>{value}</Paragraph>
)}
</div>
</>
</div>
);
}
@@ -10,6 +10,7 @@ import {
ArrowRightIcon,
BeakerIcon,
BellAlertIcon,
BoltIcon,
BookOpenIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
@@ -20,6 +21,7 @@ import {
ChevronRightIcon,
ChevronUpIcon,
ClockIcon,
CloudIcon,
CodeBracketSquareIcon,
Cog8ToothIcon,
CreditCardIcon,
@@ -49,6 +51,8 @@ import { WebhookIcon } from "~/assets/icons/WebhookIcon";
import { cn } from "~/utils/cn";
import { LogoIcon } from "../LogoIcon";
import { Spinner } from "./Spinner";
import { HourglassIcon } from "lucide-react";
import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon";
const icons = {
account: (className: string) => (
@@ -60,6 +64,9 @@ const icons = {
"arrow-left": (className: string) => (
<ArrowLeftIcon className={cn("text-white", className)} />
),
background: (className: string) => (
<CloudIcon className={cn("text-sky-400", className)} />
),
beaker: (className: string) => (
<BeakerIcon className={cn("text-purple-500", className)} />
),
@@ -84,6 +91,9 @@ const icons = {
"chevron-right": (className: string) => (
<ChevronRightIcon className={cn("text-dimmed", className)} />
),
countdown: (className: string) => (
<HourglassIcon className={cn("text-amber-400", className)} />
),
clock: (className: string) => (
<ClockIcon className={cn("text-cyan-500", className)} />
),
@@ -96,6 +106,9 @@ const icons = {
docs: (className: string) => (
<BookOpenIcon className={cn("text-slate-400", className)} />
),
dynamic: (className: string) => (
<DynamicTriggerIcon className={cn("text-cyan-500", className)} />
),
error: (className: string) => (
<ExclamationCircleIcon className={cn("text-rose-500", className)} />
),
@@ -144,12 +157,10 @@ const icons = {
log: (className: string) => (
<ChatBubbleLeftEllipsisIcon className={cn("text-slate-400", className)} />
),
"logo-icon": (className: string) => <LogoIcon className={cn(className)} />,
organization: (className: string) => (
<BuildingOffice2Icon className={cn("text-fuchsia-600", className)} />
),
search: (className: string) => (
<MagnifyingGlassIcon className={cn("text-dimmed", className)} />
),
plus: (className: string) => (
<PlusIcon className={cn("text-green-600", className)} />
),
@@ -159,6 +170,12 @@ const icons = {
property: (className: string) => (
<Cog8ToothIcon className={cn("text-slate-600", className)} />
),
search: (className: string) => (
<MagnifyingGlassIcon className={cn("text-dimmed", className)} />
),
settings: (className: string) => (
<Cog8ToothIcon className={cn("text-slate-600", className)} />
),
spinner: (className: string) => (
<Spinner className={className} color="blue" />
),
@@ -171,7 +188,9 @@ const icons = {
team: (className: string) => (
<UserGroupIcon className={cn("text-blue-500", className)} />
),
"logo-icon": (className: string) => <LogoIcon className={cn(className)} />,
trigger: (className: string) => (
<BoltIcon className={cn("text-orange-500", className)} />
),
user: (className: string) => (
<UserIcon className={cn("text-blue-600", className)} />
),
@@ -224,6 +243,9 @@ export function NamedIcon({
name={name}
className={"h-full w-full p-[7%]"}
variant="light"
style={{
shapeRendering: "geometricPrecision",
}}
/>
</div>
);
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { useContext } from "react";
import { createContext } from "react";
export type OperatingSystemPlatform = "mac" | "windows";
type OperatingSystemContext = {
platform: OperatingSystemPlatform;
};
type OperatingSystemContextProviderProps = {
platform: OperatingSystemPlatform;
children: ReactNode;
};
const Context = createContext<OperatingSystemContext | null>(null);
export const OperatingSystemContextProvider = ({
platform,
children,
}: OperatingSystemContextProviderProps) => {
return <Context.Provider value={{ platform }}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error(
"Please wrap your application in an OperatingSystemContextProvider."
);
};
export const useOperatingSystem = () => {
const { platform } = useContext(Context) ?? throwIfNoProvider();
return { platform };
};
@@ -65,7 +65,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
}
export function PageButtons({ children }: WithChildren) {
return <div className="flex gap-2">{children}</div>;
return <div className="flex items-center gap-3">{children}</div>;
}
export function PageDescription({ children }: WithChildren) {
@@ -102,7 +102,7 @@ export function PageInfoProperty({
value,
}: {
icon?: string | React.ReactNode;
label: string;
label?: string;
value: React.ReactNode;
}) {
return (
@@ -112,12 +112,14 @@ export function PageInfoProperty({
) : (
icon
)}
<Paragraph
variant="extra-small/caps"
className="mt-0.5 whitespace-nowrap"
>
{label}:
</Paragraph>
{label && (
<Paragraph
variant="extra-small/caps"
className="mt-0.5 whitespace-nowrap"
>
{label}:
</Paragraph>
)}
{typeof value === "string" || typeof value === "number" ? (
<Paragraph variant="small">{value}</Paragraph>
) : (
@@ -1,24 +1,62 @@
import { cn } from "~/utils/cn";
const paragraphVariants = {
base: "font-sans text-base font-normal text-dimmed",
"base/bright": "font-sans text-base font-normal text-bright",
small: "font-sans text-sm font-normal text-dimmed",
"small/bright": "font-sans text-sm font-normal text-bright",
"extra-small": "font-sans text-xs font-normal text-dimmed",
"extra-small/bright": "font-sans text-xs font-normal text-bright",
"extra-small/mono": "font-mono text-xs font-normal text-dimmed",
"extra-small/bright/mono": "font-mono text-xs text-bright",
"extra-small/caps":
"font-sans text-xs uppercase tracking-wider font-normal text-dimmed",
"extra-small/bright/caps":
"font-sans text-xs uppercase tracking-wider font-normal text-bright",
"extra-extra-small": "font-sans text-xxs font-normal text-dimmed",
"extra-extra-small/bright": "font-sans text-xxs font-normal text-bright",
"extra-extra-small/caps":
"font-sans text-xxs uppercase tracking-wider font-normal text-dimmed",
"extra-extra-small/bright/caps":
"font-sans text-xxs uppercase tracking-wider font-normal text-bright",
base: {
text: "font-sans text-base font-normal text-dimmed",
spacing: "mb-3",
},
"base/bright": {
text: "font-sans text-base font-normal text-bright",
spacing: "mb-3",
},
small: {
text: "font-sans text-sm font-normal text-dimmed",
spacing: "mb-2",
},
"small/bright": {
text: "font-sans text-sm font-normal text-bright",
spacing: "mb-2",
},
"extra-small": {
text: "font-sans text-xs font-normal text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright": {
text: "font-sans text-xs font-normal text-bright",
spacing: "mb-1.5",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/mono": {
text: "font-mono text-xs text-bright",
spacing: "mb-1.5",
},
"extra-small/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-bright",
spacing: "mb-1.5",
},
"extra-extra-small": {
text: "font-sans text-xxs font-normal text-dimmed",
spacing: "mb-1",
},
"extra-extra-small/bright": {
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",
},
"extra-extra-small/bright/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-bright",
spacing: "mb-1",
},
};
export type ParagraphVariant = keyof typeof paragraphVariants;
@@ -26,17 +64,26 @@ export type ParagraphVariant = keyof typeof paragraphVariants;
type ParagraphProps = {
variant?: ParagraphVariant;
className?: string;
spacing?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLParagraphElement>;
export function Paragraph({
variant = "base",
className,
spacing = false,
children,
...props
}: ParagraphProps) {
return (
<p className={cn(paragraphVariants[variant], className)} {...props}>
<p
className={cn(
paragraphVariants[variant].text,
spacing === true && paragraphVariants[variant].spacing,
className
)}
{...props}
>
{children}
</p>
);
@@ -0,0 +1,130 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Paragraph } from "./Paragraph";
const variants = {
"simple/small": {
button: "w-fit pr-4 data-[disabled]:opacity-70",
label: "text-sm text-bright mt-0.5 select-none",
description: "text-dimmed",
inputPosition: "mt-1",
},
simple: {
button: "w-fit pr-4 data-[disabled]:opacity-70",
label: "text-bright select-none",
description: "text-dimmed",
inputPosition: "mt-1",
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-slate-800 hover:bg-slate-850 hover:border-slate-750 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-slate-850",
label: "text-sm text-bright select-none",
description: "text-dimmed",
inputPosition: "mt-0",
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-slate-800 hover:bg-slate-850 hover:border-slate-750 transition data-[state=checked]:bg-slate-850 data-[disabled]:opacity-70",
label: "text-bright select-none",
description: "text-dimmed",
inputPosition: "mt-1",
},
description: {
button:
"w-full py-2 pl-2 pr-3 hover:bg-slate-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-slate-850 border-slate-800 border rounded-sm",
label: "text-bright font-semibold -mt-1 text-left",
description: "text-dimmed -mt-0.5 text-left",
inputPosition: "mt-0",
},
};
export const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root className={className} {...props} ref={ref} />
);
});
type RadioGroupItemProps = Omit<
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>,
"onChange"
> & {
variant?: keyof typeof variants;
label?: string;
description?: string;
badges?: string[];
className?: string;
};
export const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
RadioGroupItemProps
>(
(
{
className,
children,
variant = "simple",
label,
description,
badges,
...props
},
ref
) => {
const variation = variants[variant];
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"group flex cursor-pointer items-start gap-x-2 transition",
variation.button,
className
)}
{...props}
>
<div
className={cn(
"aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-sm border border-slate-700 ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center bg-indigo-700">
<Circle className="h-1.5 w-1.5 fill-white text-white" />
</RadioGroupPrimitive.Indicator>
</div>
<div>
<div className="flex items-center gap-x-2">
<label
htmlFor={props.id}
className={cn("cursor-pointer", variation.label)}
>
{label}
</label>
{badges && (
<span className="-mr-2 flex gap-x-1.5">
{badges.map((badge) => (
<Badge key={badge}>{badge}</Badge>
))}
</span>
)}
</div>
{variant === "description" && (
<Paragraph
variant="small"
className={cn("mt-0.5", variation.description)}
>
{description}
</Paragraph>
)}
</div>
</RadioGroupPrimitive.Item>
);
}
);
@@ -104,7 +104,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 relative my-0.5 flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition first-of-type:my-1 last-of-type:my-1 hover:bg-slate-750 focus:bg-slate-750/50",
"relative my-0.5 flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition first-of-type:my-1 last-of-type:my-1 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-slate-750 focus:bg-slate-750/50",
className
)}
{...props}
@@ -1,10 +1,9 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
import { NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
@@ -159,15 +158,17 @@ const SheetContent = React.forwardRef<
className={cn(sheetVariants({ position, size }), className)}
{...props}
>
<div className="grid h-full grid-rows-[2.75rem_1fr]">
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
<div className="flex items-center gap-2 border-b border-slate-800 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>
</SheetPrimitive.Close>
<ShortcutKey shortcut="esc" variant="medium" />
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
<div className="flex max-h-full flex-col overflow-hidden">
{children}
</div>
<div className="overflow-hidden">{children}</div>
</div>
</SheetPrimitive.Content>
</SheetPortal>
@@ -180,7 +181,20 @@ export const SheetBody = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"grow overflow-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700",
"overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700",
className
)}
{...props}
/>
);
export const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"mx-4 flex shrink-0 items-center gap-4 border-b border-slate-800 py-3.5",
className
)}
{...props}
@@ -197,4 +211,4 @@ export const SheetFooter = ({
</div>
);
export { Sheet, SheetTrigger, SheetContent };
export { Sheet, SheetContent, SheetTrigger };
@@ -1,16 +1,19 @@
import { Fragment } from "react";
import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
const variants = {
small:
"text-xxs font-medium py-0.25 min-w-[14px] rounded-[2px] px-0.5 border border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright transition",
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60 transition uppercase",
medium:
"text-[0.6rem] leading-[1.13rem] min-w-[20px] px-1 rounded-[3px] border border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright transition",
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60 transition uppercase",
};
export type ShortcutKeyVariant = keyof typeof variants;
type ShortcutKeyProps = {
shortcut: string;
shortcut: ShortcutDefinition;
variant: ShortcutKeyVariant;
className?: string;
};
@@ -20,5 +23,32 @@ export function ShortcutKey({
variant,
className,
}: ShortcutKeyProps) {
return <span className={cn(variants[variant], className)}>{shortcut}</span>;
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
let relevantShortcut =
"mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers ?? [];
const character = relevantShortcut.key;
return (
<span className={cn(variants[variant], className)}>
{modifiers.map((k) => (
<Fragment key={k}>{modifierString(k, isMac)}</Fragment>
))}
{character}
</span>
);
}
function modifierString(modifier: Modifier, isMac: boolean) {
switch (modifier) {
case "alt":
return isMac ? "⌥" : "Alt+";
case "ctrl":
return isMac ? "⌃" : "Ctrl+";
case "meta":
return isMac ? "⌘" : "⊞";
case "shift":
return isMac ? "⇧" : "Shift+";
}
}
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "~/utils/cn";
const variations = {
large: {
container: "flex items-center gap-x-2 rounded-md hover:bg-slate-850 p-2",
root: "h-6 w-11",
thumb:
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-slate-400 group-hover:text-slate-200",
},
small: {
container: "flex items-center gap-x-1.5 rounded hover:bg-slate-850 p-1.5",
root: "h-3 w-6",
thumb:
"h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-slate-400 group-hover:text-slate-200",
},
};
type SwitchProps = React.ComponentPropsWithoutRef<
typeof SwitchPrimitives.Root
> & {
label?: React.ReactNode;
variant: keyof typeof variations;
};
export const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
SwitchProps
>(({ className, variant, label, ...props }, ref) => {
const { container, root, thumb, text } = variations[variant];
return (
<SwitchPrimitives.Root
className={cn("group", container, className)}
{...props}
ref={ref}
>
{label ? (
<label className={cn("text", text)}>
{typeof label === "string" ? <span>{label}</span> : label}
</label>
) : null}
<div
className={cn(
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-primary group-data-[state=unchecked]:bg-slate-700 focus-visible:outline-none",
root
)}
>
<SwitchPrimitives.Thumb
className={cn(
thumb,
"pointer-events-none block rounded-full bg-slate-200 shadow-lg ring-0 transition-transform"
)}
/>
</div>
</SwitchPrimitives.Root>
);
});
+70 -48
View File
@@ -86,6 +86,7 @@ type TableCellBasicProps = {
className?: string;
alignment?: "left" | "center" | "right";
children: ReactNode;
colSpan?: number;
};
type TableHeaderCellProps = TableCellBasicProps & {
@@ -95,38 +96,11 @@ type TableHeaderCellProps = TableCellBasicProps & {
export const TableHeaderCell = forwardRef<
HTMLTableCellElement,
TableHeaderCellProps
>(({ className, alignment = "left", children, hiddenLabel = false }, ref) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
alignmentClassName = "text-center";
break;
case "right":
alignmentClassName = "text-right";
break;
}
return (
<th
ref={ref}
scope="col"
className={cn(
"px-4 py-3 align-middle text-xs font-semibold uppercase text-slate-400",
alignmentClassName,
className
)}
>
{hiddenLabel ? <span className="sr-only">{children}</span> : children}
</th>
);
});
type TableCellProps = TableCellBasicProps & {
to?: string;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, to }, ref) => {
>(
(
{ className, alignment = "left", children, colSpan, hiddenLabel = false },
ref
) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -137,30 +111,68 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
break;
}
return (
<th
ref={ref}
scope="col"
className={cn(
"px-4 py-3 align-middle text-xs font-semibold uppercase text-slate-400",
alignmentClassName,
className
)}
colSpan={colSpan}
>
{hiddenLabel ? <span className="sr-only">{children}</span> : children}
</th>
);
}
);
type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, colSpan, to, onClick }, ref) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
alignmentClassName = "text-center";
break;
case "right":
alignmentClassName = "text-right";
break;
}
const flexClasses = cn(
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-slate-400",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
? "justify-center text-center"
: "justify-end text-right"
);
return (
<td
ref={ref}
className={cn(
"text-xs text-slate-400 transition group-hover:bg-slate-850/50",
to ? "cursor-pointer" : "px-4 py-3 align-middle",
!to && alignmentClassName,
to || onClick ? "cursor-pointer" : "px-4 py-3 align-middle",
!to && !onClick && alignmentClassName,
className
)}
colSpan={colSpan}
>
{to ? (
<Link
to={to}
className={cn(
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-slate-400",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
? "justify-center text-center"
: "justify-end text-right"
)}
>
<Link to={to} className={flexClasses}>
{children}
</Link>
) : onClick ? (
<button onClick={onClick} className={flexClasses}>
{children}
</button>
) : (
<>{children}</>
)}
@@ -171,10 +183,20 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{ className?: string; to: string }
>(({ className, to }, ref) => {
{
className?: string;
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
>(({ className, to, onClick }, ref) => {
return (
<TableCell className={className} to={to} ref={ref} alignment="right">
<TableCell
className={className}
to={to}
onClick={onClick}
ref={ref}
alignment="right"
>
<ChevronRightIcon className="h-4 w-4 text-slate-700 transition group-hover:text-bright" />
</TableCell>
);
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
type TextAreaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {};
export function TextArea({ className, rows, ...props }: TextAreaProps) {
return (
<textarea
{...props}
rows={rows ?? 6}
className={cn(
"w-full rounded-md border border-slate-800 bg-slate-850 px-3 text-sm text-bright ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
/>
);
}
@@ -1,4 +1,4 @@
import { JobRunStatus } from ".prisma/client";
import type { JobRunStatus } from "@trigger.dev/database";
import {
CheckCircleIcon,
ClockIcon,
@@ -1,11 +1,11 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
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 { formatDateTime, formatDuration } from "~/utils";
import { runPath } from "~/utils/pathBuilder";
import { runDashboardPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { Callout } from "../primitives/Callout";
import { Spinner } from "../primitives/Spinner";
@@ -64,7 +64,7 @@ export function RunsTable({
</TableBlankRow>
) : (
runs.map((run) => {
const path = runPath(organization, project, job, run);
const path = runDashboardPath(organization, project, job, run);
return (
<TableRow key={run.id}>
<TableCell to={path}>#{run.number}</TableCell>
@@ -85,16 +85,22 @@ function ButtonList({ primary }: { primary: string }) {
</div>
<div className="flex flex-col items-start gap-2">
<Header3 className="mb-1 uppercase">Shortcut</Header3>
<Button variant="primary/small" shortcut="⌘S">
<Button
variant="primary/small"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Primary button
</Button>
<Button variant="secondary/small" shortcut="K">
<Button variant="secondary/small" shortcut={{ key: "f" }}>
Secondary button
</Button>
<Button variant="tertiary/small" shortcut="I">
<Button variant="tertiary/small" shortcut={{ key: "i" }}>
Tertiary button
</Button>
<Button variant="danger/small" shortcut="⌘S">
<Button
variant="danger/small"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Danger button
</Button>
</div>
@@ -195,16 +201,28 @@ function ButtonList({ primary }: { primary: string }) {
</div>
<div className="flex flex-col items-start gap-2">
<Header3 className="mb-1 uppercase">Shortcut</Header3>
<Button variant="primary/medium" shortcut="⌘S">
<Button
variant="primary/medium"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Primary button
</Button>
<Button variant="secondary/medium" shortcut="F">
<Button
variant="secondary/medium"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Secondary button
</Button>
<Button variant="tertiary/medium" shortcut="⌘S">
<Button
variant="tertiary/medium"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Tertiary button
</Button>
<Button variant="danger/medium" shortcut="⌘S">
<Button
variant="danger/medium"
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Danger button
</Button>
</div>
@@ -55,7 +55,7 @@ function BasicCheckBox() {
id="check2"
variant="button"
label="This is a button checkbox with a badge"
badge="This is a badge"
badges={["This is a badge"]}
disabled={isDisabled}
/>
<Checkbox
@@ -71,7 +71,7 @@ function BasicCheckBox() {
name="Description checkbox"
id="check3"
variant="description"
badge="This is a badge"
badges={["This is a badge"]}
label="This is a checkbox with a description and badge"
description="This is a long checkbox description that goes full width. Grants full access to public and private repositories including read and write access to code, commit statuses, repository invitations, collaborators, deployment statuses, and repository webhooks. Note: In addition to repository related resources, the repo scope also grants access to manage organization-owned resources including projects, invitations, team memberships and webhooks. This scope also grants the ability to manage projects owned by users."
disabled={isDisabled}
@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react";
import { withDesign } from "storybook-addon-designs";
import { ClipboardField } from "../primitives/ClipboardField";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
const meta: Meta = {
title: "Primitives/ClipboardField",
decorators: [withDesign],
};
export default meta;
type Story = StoryObj<typeof ClipboardFieldExample>;
export const Basic: Story = {
render: () => <ClipboardFieldExample />,
};
function ClipboardFieldExample() {
return (
<div className="flex gap-8">
<div className="flex flex-col items-start gap-y-8 p-8">
<ClipboardField value="copy paste me" variant="primary/small" />
<ClipboardField value="copy paste me" variant="secondary/small" />
<ClipboardField value="copy paste me" variant="tertiary/small" />
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon={<EnvironmentLabel environment={{ type: "PRODUCTION" }} />}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon="search"
/>
<ClipboardField value="copy paste me" variant="primary/medium" />
<ClipboardField value="copy paste me" variant="secondary/medium" />
<ClipboardField value="copy paste me" variant="tertiary/medium" />
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon="search"
/>
</div>
<div className="flex flex-col items-start gap-y-8 p-8">
<ClipboardField
value="copy paste me"
variant="primary/small"
secure={true}
/>
<ClipboardField
value="copy paste me"
variant="secondary/small"
secure={true}
/>
<ClipboardField
value="copy paste me"
variant="tertiary/small"
secure={true}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon={<EnvironmentLabel environment={{ type: "STAGING" }} />}
secure={true}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon="search"
secure={true}
/>
<ClipboardField
value="copy paste me"
variant="primary/medium"
secure={true}
/>
<ClipboardField
value="copy paste me"
variant="secondary/medium"
secure={true}
/>
<ClipboardField
value="copy paste me"
variant="tertiary/medium"
secure={true}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon={<EnvironmentLabel environment={{ type: "PRODUCTION" }} />}
secure={true}
/>
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon="search"
secure={true}
/>
</div>
</div>
);
}
@@ -128,7 +128,7 @@ function LoginForm() {
Continue with Email
</Button>
<Paragraph variant="small" className="mt-2 text-center">
By connecting your GitHub account you agree to our{" "}
By creating an account you agree to our{" "}
<TextLink href="#">terms</TextLink> and{" "}
<TextLink href="#">privacy</TextLink> policies.
</Paragraph>
@@ -42,7 +42,11 @@ function PageHeaders() {
<PageTitleRow>
<PageTitle title="Organizations" />
<PageButtons>
<LinkButton to={""} variant="primary/small" shortcut="N">
<LinkButton
to={""}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new Organization
</LinkButton>
</PageButtons>
@@ -61,7 +65,11 @@ function PageHeaders() {
backButton={{ to: "#", text: "Orgs" }}
/>
<PageButtons>
<LinkButton to={""} variant="primary/small" shortcut="N">
<LinkButton
to={""}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new Organization
</LinkButton>
</PageButtons>
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react";
import { withDesign } from "storybook-addon-designs";
import { useState } from "react";
import { Button } from "../primitives/Buttons";
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
const meta: Meta = {
title: "Primitives/RadioGroup",
decorators: [withDesign],
};
export default meta;
type Story = StoryObj<typeof RadioGroupExample>;
export const Basic: Story = {
render: () => <RadioGroupExample />,
};
function RadioGroupExample() {
const [isDisabled, setIsDisabled] = useState(false);
return (
<div className="flex flex-col items-start gap-y-8 p-8">
<Button
onClick={() => setIsDisabled((d) => !d)}
variant="primary/medium"
className="max-w-fit"
>
{isDisabled ? "Enable" : "Disable"}
</Button>
<form>
<RadioGroup name="simple" disabled={isDisabled} className="grid gap-2">
<RadioGroupItem
id="r2"
label="Simple small"
value={"1"}
variant="simple/small"
/>
<RadioGroupItem id="r3" label="Simple" value={"2"} variant="simple" />
<RadioGroupItem
id="r4"
label="Button small"
value={"3"}
variant="button/small"
/>
<RadioGroupItem id="r5" label="Button" value={"4"} variant="button" />
<RadioGroupItem
id="r6"
label="This is a label"
description="This is a description"
value={"5"}
variant="description"
/>
</RadioGroup>
</form>
</div>
);
}
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Switch } from "../primitives/Switch";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { Button } from "../primitives/Buttons";
import { OperatingSystemContextProvider } from "../primitives/OperatingSystemProvider";
import { Header1 } from "../primitives/Headers";
const meta: Meta = {
title: "Primitives/ShortcutKey",
};
export default meta;
type Story = StoryObj<typeof Collection>;
export const ShortcutKeys: Story = {
render: () => <Collection />,
};
const shortcuts: ShortcutDefinition[] = [
{ key: "esc" },
{ key: "f" },
{ key: "f", modifiers: ["meta"] },
{ key: "k", modifiers: ["meta"] },
{ key: "del", modifiers: ["ctrl", "alt"] },
];
function Collection() {
return (
<div className="flex flex-col items-start gap-y-4">
<Set platform="mac" />
<Set platform="windows" />
</div>
);
}
function Set({ platform }: { platform: "mac" | "windows" }) {
return (
<OperatingSystemContextProvider platform={platform}>
<Header1>{platform}</Header1>
{shortcuts.map((shortcut, index) => (
<div key={index} className="flex items-center gap-x-2">
<ShortcutKey shortcut={shortcut} variant="small" />
<ShortcutKey shortcut={shortcut} variant="medium" />
<Button variant="primary/small" shortcut={shortcut}>
Primary small
</Button>
<Button variant="secondary/small" shortcut={shortcut}>
Secondary small
</Button>
<Button variant="tertiary/small" shortcut={shortcut}>
Tertiary small
</Button>
<Button variant="danger/small" shortcut={shortcut}>
Danger small
</Button>
<Button variant="primary/medium" shortcut={shortcut}>
Primary medium
</Button>
<Button variant="secondary/medium" shortcut={shortcut}>
Secondary medium
</Button>
<Button variant="tertiary/medium" shortcut={shortcut}>
Tertiary medium
</Button>
<Button variant="danger/medium" shortcut={shortcut}>
Danger medium
</Button>
</div>
))}
</OperatingSystemContextProvider>
);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Switch } from "../primitives/Switch";
const meta: Meta = {
title: "Primitives/Switch",
};
export default meta;
type Story = StoryObj<typeof Collection>;
export const Switches: Story = {
render: () => <Collection />,
};
function Collection() {
return (
<div className="flex flex-col items-start gap-y-4 p-4">
<Switch variant="large" />
<Switch variant="large" disabled />
<Switch variant="large" label="Toggle me" />
<Switch variant="large" label="Toggle me" disabled />
<Switch variant="small" />
<Switch variant="small" disabled />
<Switch variant="small" label="Toggle me" />
<Switch variant="small" label="Toggle me" disabled />
</div>
);
}
+3 -3
View File
@@ -1,10 +1,10 @@
import { PrismaClient, Prisma } from ".prisma/client";
import { PrismaClient, Prisma } from "@trigger.dev/database";
import invariant from "tiny-invariant";
import { z } from "zod";
export type PrismaTransactionClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use"
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>;
export type PrismaClientOrTransaction = PrismaClient | PrismaTransactionClient;
@@ -96,7 +96,7 @@ function getClient() {
}
export { prisma };
export type { PrismaClient } from ".prisma/client";
export type { PrismaClient } from "@trigger.dev/database";
export const PrismaErrorSchema = z.object({
code: z.string(),
+8 -26
View File
@@ -1,35 +1,17 @@
import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
import { hydrateRoot } from "react-dom/client";
import * as Sentry from "@sentry/remix";
import { useEffect } from "react";
import posthog from "posthog-js";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
hydrateRoot(
document,
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
<OperatingSystemContextProvider
platform={window.navigator.userAgent.includes("Mac") ? "mac" : "windows"}
>
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
</OperatingSystemContextProvider>
);
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
tracesSampleRate: 1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.remixRouterInstrumentation(
useEffect,
useLocation,
useMatches
),
}),
//casted because TypeScript is unhappy about the type from PostHog
new posthog.SentryIntegration(
posthog,
"triggerdev",
4504419607052288
) as any,
],
});
}
+37 -20
View File
@@ -1,4 +1,3 @@
import * as Sentry from "~/services/sentry.server";
import * as Worker from "~/services/worker.server";
import { PassThrough } from "stream";
import { renderToPipeableStream } from "react-dom/server";
@@ -8,6 +7,10 @@ import type { EntryContext, Headers } from "@remix-run/node"; // or cloudflare/d
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import {
OperatingSystemContextProvider,
OperatingSystemPlatform,
} from "./components/primitives/OperatingSystemProvider";
const ABORT_DELAY = 30000;
@@ -22,6 +25,13 @@ export default function handleRequest(
validate: Intl.DateTimeFormat.supportedLocalesOf,
});
//get whether it's a mac or pc from the headers
const platform: OperatingSystemPlatform = request.headers
.get("user-agent")
?.includes("Mac")
? "mac"
: "windows";
// If the request is from a bot, we want to wait for the full
// response to render before sending it to the client. This
// ensures that bots can see the full page content.
@@ -31,7 +41,8 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext,
locales
locales,
platform
);
}
@@ -40,7 +51,8 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext,
locales
locales,
platform
);
}
@@ -49,17 +61,20 @@ function serveTheBots(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
// Use onAllReady to wait for the entire document to be ready
onAllReady() {
@@ -87,18 +102,21 @@ function serveBrowsers(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
<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() {
@@ -125,5 +143,4 @@ function serveBrowsers(
});
}
Sentry.init();
Worker.init().catch(console.error);
+3 -3
View File
@@ -17,7 +17,6 @@ const EnvironmentSchema = z.object({
.string()
// eslint-disable-next-line turbo/no-undeclared-env-vars
.default(process.env.FC_URL ?? "https://app.trigger.dev"),
SENTRY_DSN: z.string().optional(),
APP_ENV: z
.union([
z.literal("development"),
@@ -29,12 +28,13 @@ const EnvironmentSchema = z.object({
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
POSTHOG_PROJECT_KEY: z.string().optional(),
MAGIC_LINK_SECRET: z.string(),
GITHUB_CLIENT_ID: z.string(),
GITHUB_SECRET: z.string(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
FROM_EMAIL: z.string(),
REPLY_TO_EMAIL: z.string(),
RESEND_API_KEY: z.string(),
SESSION_SECRET: z.string(),
PLAIN_API_KEY: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+18
View File
@@ -0,0 +1,18 @@
import { requestUrl } from "./utils";
export type TriggerFeatures = {
isManagedCloud: boolean;
};
// If the request host is cloud.trigger.dev then we are on the managed cloud
// or if env.NODE_ENV is development
export function featuresForRequest(request: Request): TriggerFeatures {
const url = requestUrl(request);
const isManagedCloud =
url.host === "cloud.trigger.dev" || process.env.NODE_ENV === "development";
return {
isManagedCloud,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { useTypedRouteLoaderData } from "remix-typedjson";
import { loader } from "../root";
import type { TriggerFeatures } from "~/features.server";
export function useFeatures(): TriggerFeatures {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.features ?? { isManagedCloud: false };
}
+32
View File
@@ -0,0 +1,32 @@
import { ProjectJob } from "./useProject";
import { useTextFilter } from "./useTextFilter";
export function useFilterJobs(jobs: ProjectJob[]) {
const { filterText, setFilterText, filteredItems } =
useTextFilter<ProjectJob>({
items: jobs,
filter: (job, text) => {
if (job.slug.toLowerCase().includes(text.toLowerCase())) return true;
if (job.title.toLowerCase().includes(text.toLowerCase())) return true;
if (job.event.title.toLowerCase().includes(text.toLowerCase()))
return true;
if (
job.integrations.some((integration) =>
integration.title.toLowerCase().includes(text.toLowerCase())
)
)
return true;
if (
job.properties &&
job.properties.some((property) =>
property.text.toLowerCase().includes(text.toLowerCase())
)
)
return true;
return false;
},
});
return { filterText, setFilterText, filteredItems };
}
@@ -0,0 +1,22 @@
import {
UseDataFunctionReturn,
useTypedRouteLoaderData,
} from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam/route";
export type MatchedClient = UseDataFunctionReturn<typeof loader>["client"];
export function useOptionalIntegrationClient() {
const routeMatch = useTypedRouteLoaderData<typeof loader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam"
);
return routeMatch?.client;
}
export function useIntegrationClient() {
const client = useOptionalIntegrationClient();
invariant(client, "Client must be defined");
return client;
}
+11 -16
View File
@@ -1,34 +1,29 @@
import type { UseDataFunctionReturn } from "remix-typedjson";
import type { loader as jobLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
import { hydrateObject, useMatchesData } from "~/utils";
import { useOptionalProject } from "./useProject";
import {
UseDataFunctionReturn,
useTypedRouteLoaderData,
} from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
import { useOptionalProject } from "./useProject";
export type MatchedJob = UseDataFunctionReturn<typeof jobLoader>["job"];
export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
export function useOptionalJob() {
const project = useOptionalProject();
const routeMatch = useMatchesData(
const routeMatch = useTypedRouteLoaderData<typeof loader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam"
);
if (!project || !routeMatch || !routeMatch.data.job) {
if (!project || !routeMatch || !routeMatch.job) {
return undefined;
}
if (routeMatch.data.job == null) {
if (routeMatch.job == null) {
return undefined;
}
const result = hydrateObject<UseDataFunctionReturn<typeof jobLoader>["job"]>(
routeMatch.data.job
);
if (result == null) return undefined;
//get the job from the list on the project
const job = project.jobs.find((j) => j.id === result.id);
return job;
return project.jobs.find((j) => j.id === routeMatch.job.id);
}
export function useJob() {
+13 -15
View File
@@ -1,8 +1,11 @@
import type { UseDataFunctionReturn } from "remix-typedjson";
import type { loader as appLoader } from "~/routes/_app/route";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { hydrateObject, useMatchesData } from "~/utils";
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";
export type MatchedOrganization = UseDataFunctionReturn<
typeof appLoader
@@ -20,24 +23,19 @@ export function useOrganizations() {
export function useOptionalOrganization() {
const orgs = useOptionalOrganizations();
const routeMatch = useMatchesData("routes/_app.orgs.$organizationSlug");
const routeMatch = useTypedRouteLoaderData<typeof orgLoader>(
"routes/_app.orgs.$organizationSlug"
);
if (!orgs || !routeMatch || !routeMatch.data.organization) {
if (!orgs || !routeMatch || !routeMatch.organization) {
return undefined;
}
if (routeMatch.data.organization == null) {
if (routeMatch.organization === null) {
return undefined;
}
const result = hydrateObject<
UseDataFunctionReturn<typeof orgLoader>["organization"]
>(routeMatch.data.organization);
if (result == null) return undefined;
const org = orgs.find((o) => o.id === result.id);
return org;
return orgs.find((o) => o.id === routeMatch.organization.id);
}
export function useOrganization() {
+1
View File
@@ -14,6 +14,7 @@ export const usePostHog = (apiKey?: string, logging = false): void => {
//start PostHog once
useEffect(() => {
if (apiKey === undefined) return;
if (apiKey === "") return;
if (postHogInitialized.current === true) return;
if (logging) console.log("posthog.init", apiKey);
postHogInitialized.current = true;
+8 -17
View File
@@ -1,29 +1,20 @@
import type { UseDataFunctionReturn } from "remix-typedjson";
import {
UseDataFunctionReturn,
useTypedRouteLoaderData,
} from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as projectLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
import { hydrateObject, useMatchesData } from "~/utils";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
export type MatchedProject = UseDataFunctionReturn<
typeof projectLoader
>["project"];
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
export type ProjectJob = MatchedProject["jobs"][number];
export function useOptionalProject() {
const routeMatch = useMatchesData(
const routeMatch = useTypedRouteLoaderData<typeof loader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam"
);
if (!routeMatch || !routeMatch.data.project) {
return undefined;
}
const result = hydrateObject<
UseDataFunctionReturn<typeof projectLoader>["project"]
>(routeMatch.data.project);
if (result == null) return undefined;
return result;
return routeMatch?.project;
}
export function useProject() {
+10 -14
View File
@@ -1,32 +1,28 @@
import type { UseDataFunctionReturn } from "remix-typedjson";
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route";
import { hydrateObject, useMatchesData } from "~/utils";
import { useOptionalProject } from "./useProject";
import {
UseDataFunctionReturn,
useTypedRouteLoaderData,
} 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";
import { useOptionalProject } from "./useProject";
export type MatchedRun = UseDataFunctionReturn<typeof runLoader>["run"];
export function useOptionalRun() {
const project = useOptionalProject();
const routeMatch = useMatchesData(
const routeMatch = useTypedRouteLoaderData<typeof runLoader>(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam"
);
if (!project || !routeMatch || !routeMatch.data.run) {
if (!project || !routeMatch || !routeMatch.run) {
return undefined;
}
if (routeMatch.data.run == null) {
if (routeMatch.run == null) {
return undefined;
}
const run = hydrateObject<UseDataFunctionReturn<typeof runLoader>["run"]>(
routeMatch.data.run
);
if (run == null) return undefined;
return run;
return routeMatch.run;
}
export function useRun() {
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider";
export type Modifier = "alt" | "ctrl" | "meta" | "shift";
export type Shortcut = {
key: string;
modifiers?: Modifier[];
};
export type ShortcutDefinition =
| {
windows: Shortcut;
mac: Shortcut;
}
| Shortcut;
type useShortcutKeysProps = {
shortcut: ShortcutDefinition;
action: (event: KeyboardEvent) => void;
disabled?: boolean;
};
export function useShortcutKeys({
shortcut,
action,
disabled = false,
}: useShortcutKeysProps) {
const keys = createKeysFromShortcut(shortcut);
useHotkeys(keys, action, { enabled: !disabled });
}
function createKeysFromShortcut(shortcut: ShortcutDefinition) {
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
let relevantShortcut =
"mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers;
const character = relevantShortcut.key;
return modifiers ? modifiers.map((k) => k).join("+") + "+" : "" + character;
}
@@ -1,5 +0,0 @@
export type {
ApiConnectionType,
ApiConnectionClient,
ApiConnection,
} from ".prisma/client";
@@ -0,0 +1,14 @@
import { z } from "zod";
const IndexEndpointStatsSchema = z.object({
jobs: z.number(),
sources: z.number(),
dynamicTriggers: z.number(),
dynamicSchedules: z.number(),
});
export type IndexEndpointStats = z.infer<typeof IndexEndpointStatsSchema>;
export function parseEndpointIndexStats(stats: unknown): IndexEndpointStats {
return IndexEndpointStatsSchema.parse(stats);
}
+15 -9
View File
@@ -1,18 +1,24 @@
import type { Job } from ".prisma/client";
import type { User } from "./user.server";
import { prisma } from "~/db.server";
export type { Job } from ".prisma/client";
export type { JobRunStatus } from ".prisma/client";
export type { Job, JobRunStatus } from "@trigger.dev/database";
export function getJob({
export function findJobByParams({
userId,
id,
}: Pick<Job, "id"> & {
userId: User["id"];
slug,
projectSlug,
organizationSlug,
}: {
userId: string;
slug: string;
projectSlug: string;
organizationSlug: string;
}) {
//just the very basic info because we already fetched it for the Jobs list
return prisma.job.findFirst({
select: { id: true, title: true },
where: { id, organization: { members: { some: { userId } } } },
where: {
slug,
project: { slug: projectSlug },
organization: { slug: organizationSlug, members: { some: { userId } } },
},
});
}
+18 -2
View File
@@ -1,4 +1,5 @@
import { prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
export async function getTeamMembersAndInvites({
userId,
@@ -157,7 +158,11 @@ export async function acceptInvite({
id: inviteId,
},
include: {
organization: true,
organization: {
include: {
projects: true,
},
},
},
});
@@ -170,7 +175,18 @@ export async function acceptInvite({
},
});
//3. Check for other invites
// 3. Create an environment for each project
for (const project of invite.organization.projects) {
await createEnvironment(
invite.organization,
project,
"DEVELOPMENT",
member,
tx
);
}
// 4. Check for other invites
const remainingInvites = await tx.orgMemberInvite.findMany({
where: {
email: invite.email,
+14 -31
View File
@@ -4,14 +4,14 @@ import type {
Project,
RuntimeEnvironment,
User,
} from ".prisma/client";
} from "@trigger.dev/database";
import { customAlphabet } from "nanoid";
import slug from "slug";
import { prisma } from "~/db.server";
import { prisma, PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { generateTwoRandomWords } from "~/utils/randomWords";
import { createProject } from "./project.server";
export type { Organization } from ".prisma/client";
export type { Organization };
const nanoid = customAlphabet("1234567890abcdef", 4);
const apiKeyId = customAlphabet(
@@ -72,23 +72,18 @@ export async function createOrganization(
attemptCount = 0
): Promise<Organization & { projects: Project[] }> {
const uniqueOrgSlug = `${slug(title)}-${nanoid(4)}`;
const uniqueProjectSlug = `${slug(projectName)}-${nanoid(4)}`;
const orgWithSameSlug = await prisma.organization.findFirst({
where: { slug: uniqueOrgSlug },
});
const projectWithSameSlug = await prisma.project.findFirst({
where: { slug: uniqueProjectSlug },
});
if (attemptCount > 100) {
throw new Error(
`Unable to create organization with slug ${uniqueOrgSlug} after 100 attempts`
);
}
if (orgWithSameSlug || projectWithSameSlug) {
if (orgWithSameSlug) {
return createOrganization(
{
title,
@@ -109,48 +104,36 @@ export async function createOrganization(
role: "ADMIN",
},
},
projects: {
create: {
name: projectName,
slug: uniqueProjectSlug,
},
},
},
include: {
members: true,
projects: true,
},
});
const adminMember = organization.members[0];
const defaultProject = organization.projects[0];
// Create the dev and prod environments
await createEnvironment(organization, defaultProject, "PRODUCTION");
await createEnvironment(
organization,
defaultProject,
"DEVELOPMENT",
adminMember
);
const project = await createProject({
organizationSlug: organization.slug,
name: projectName,
userId,
});
await workerQueue.enqueue("organizationCreated", {
id: organization.id,
});
return organization;
return { ...organization, projects: [project] };
}
export async function createEnvironment(
organization: Organization,
project: Project,
type: RuntimeEnvironment["type"],
member?: OrgMember
member?: OrgMember,
prismaClient: PrismaClientOrTransaction = prisma
) {
const slug = envSlug(type);
const apiKey = createApiKeyForEnv(type);
return await prisma.runtimeEnvironment.create({
return await prismaClient.runtimeEnvironment.create({
data: {
slug,
apiKey,
+18 -5
View File
@@ -1,9 +1,9 @@
import { nanoid } from "nanoid";
import slug from "slug";
import { prisma } from "~/db.server";
import type { Project } from ".prisma/client";
import { Organization } from "./organization.server";
export type { Project } from ".prisma/client";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
export type { Project } from "@trigger.dev/database";
export async function createProject(
{
@@ -50,7 +50,7 @@ export async function createProject(
);
}
return prisma.project.create({
const project = await prisma.project.create({
data: {
name,
slug: uniqueProjectSlug,
@@ -61,7 +61,20 @@ export async function createProject(
},
},
include: {
organization: true,
organization: {
include: {
members: true,
},
},
},
});
// Create the dev and prod environments
await createEnvironment(organization, project, "PRODUCTION");
for (const member of project.organization.members) {
await createEnvironment(organization, project, "DEVELOPMENT", member);
}
return project;
}
+22 -14
View File
@@ -1,39 +1,47 @@
import type { RunConnection } from ".prisma/client";
import type { Integration, RunConnection } from "@trigger.dev/database";
import type { ConnectionAuth } from "@trigger.dev/internal";
import type { ApiConnectionWithSecretReference } from "~/services/externalApis/apiAuthenticationRepository.server";
import { apiAuthenticationRepository } from "~/services/externalApis/apiAuthenticationRepository.server";
import type { ConnectionWithSecretReference } from "~/services/externalApis/integrationAuthRepository.server";
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
export type RunConnectionWithApiConnection = RunConnection & {
apiConnection: ApiConnectionWithSecretReference | null;
export type ResolvableRunConnection = RunConnection & {
integration: Integration;
connection: ConnectionWithSecretReference | null;
};
export async function resolveRunConnections(
connections: Array<RunConnectionWithApiConnection>
): Promise<Record<string, ConnectionAuth>> {
connections: Array<ResolvableRunConnection>
): Promise<{ auth: Record<string, ConnectionAuth>; success: boolean }> {
let allResolved = true;
const result: Record<string, ConnectionAuth> = {};
for (const connection of connections) {
if (connection.integration.authSource === "LOCAL") {
continue;
}
const auth = await resolveRunConnection(connection);
if (!auth) {
allResolved = false;
continue;
}
result[connection.key] = auth;
}
return result;
return { auth: result, success: allResolved };
}
export async function resolveRunConnection(
connection: RunConnectionWithApiConnection
connection: ResolvableRunConnection
): Promise<ConnectionAuth | undefined> {
if (!connection.apiConnection) {
if (!connection.connection) {
return;
}
const response = await apiAuthenticationRepository.getCredentials(
connection.apiConnection
const response = await integrationAuthRepository.getCredentials(
connection.connection
);
if (!response) {
@@ -48,13 +56,13 @@ export async function resolveRunConnection(
}
export async function resolveApiConnection(
connection?: ApiConnectionWithSecretReference
connection?: ConnectionWithSecretReference
): Promise<ConnectionAuth | undefined> {
if (!connection) {
return;
}
const response = await apiAuthenticationRepository.getCredentials(connection);
const response = await integrationAuthRepository.getCredentials(connection);
if (!response) {
return;
@@ -1,4 +1,4 @@
import type { RuntimeEnvironment } from ".prisma/client";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export type { RuntimeEnvironment };
+29
View File
@@ -0,0 +1,29 @@
import type { Task, TaskAttempt } from "@trigger.dev/database";
import { ServerTask } from "@trigger.dev/internal";
export type TaskWithAttempts = Task & { attempts: TaskAttempt[] };
export function taskWithAttemptsToServerTask(
task: TaskWithAttempts
): ServerTask {
return {
id: task.id,
name: task.name,
icon: task.icon,
noop: task.noop,
startedAt: task.startedAt,
completedAt: task.completedAt,
delayUntil: task.delayUntil,
status: task.status,
description: task.description,
params: task.params as any,
output: task.output as any,
properties: task.properties as any,
style: task.style as any,
error: task.error,
parentId: task.parentId,
attempts: task.attempts.length,
idempotencyKey: task.idempotencyKey,
operation: task.operation,
};
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Prisma, User } from ".prisma/client";
import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import { prisma } from "~/db.server";
export type { User } from ".prisma/client";
export type { User } from "@trigger.dev/database";
type FindOrCreateMagicLink = {
authenticationMethod: "MAGIC_LINK";
@@ -0,0 +1,222 @@
import { PrismaClient, prisma } from "~/db.server";
import {
IndexEndpointStats,
parseEndpointIndexStats,
} from "~/models/indexEndpoint.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import {
Endpoint,
EndpointIndex,
RuntimeEnvironment,
RuntimeEnvironmentType,
} from "../../../../packages/database/src";
import { env } from "~/env.server";
export type Client = {
slug: string;
endpoints: {
DEVELOPMENT: ClientEndpoint;
PRODUCTION: ClientEndpoint;
};
};
export type ClientEndpoint =
| {
state: "unconfigured";
environment: {
id: string;
apiKey: string;
type: RuntimeEnvironmentType;
};
}
| {
state: "configured";
id: string;
slug: string;
url: string;
indexWebhookPath: string;
latestIndex?: {
source: string;
updatedAt: Date;
stats: IndexEndpointStats;
};
environment: {
id: string;
apiKey: string;
type: RuntimeEnvironmentType;
};
};
export class EnvironmentsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
baseUrl,
}: {
userId: User["id"];
projectSlug: Project["slug"];
baseUrl: string;
}) {
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
apiKey: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
endpoints: {
select: {
id: true,
slug: true,
url: true,
indexingHookIdentifier: true,
indexings: {
select: {
source: true,
updatedAt: true,
stats: true,
},
take: 1,
orderBy: {
updatedAt: "desc",
},
},
},
},
},
where: {
project: {
slug: projectSlug,
},
organization: {
members: {
some: {
userId,
},
},
},
},
});
//filter out environments the only development ones belong to the current user
const filtered = environments.filter((environment) => {
if (environment.type === "DEVELOPMENT") {
return environment.orgMember?.userId === userId;
}
return true;
});
//get all the possible client slugs
const clientSlugs = new Set<string>();
for (const environment of filtered) {
for (const endpoint of environment.endpoints) {
clientSlugs.add(endpoint.slug);
}
}
//build up list of clients for display, with endpoints by type
const clients: Client[] = [];
for (const slug of clientSlugs) {
const developmentEnvironment = filtered.find(
(environment) => environment.type === "DEVELOPMENT"
);
if (!developmentEnvironment) {
throw new Error(
"Development environment not found, this should not happen"
);
}
const productionEnvironment = filtered.find(
(environment) => environment.type === "PRODUCTION"
);
if (!productionEnvironment) {
throw new Error(
"Production environment not found, this should not happen"
);
}
const client: Client = {
slug,
endpoints: {
DEVELOPMENT: {
state: "unconfigured",
environment: developmentEnvironment,
},
PRODUCTION: {
state: "unconfigured",
environment: productionEnvironment,
},
},
};
const devEndpoint = developmentEnvironment.endpoints.find(
(endpoint) => endpoint.slug === slug
);
if (devEndpoint) {
client.endpoints.DEVELOPMENT = endpointClient(
devEndpoint,
developmentEnvironment,
baseUrl
);
}
const prodEndpoint = productionEnvironment.endpoints.find(
(endpoint) => endpoint.slug === slug
);
if (prodEndpoint) {
client.endpoints.PRODUCTION = endpointClient(
prodEndpoint,
productionEnvironment,
baseUrl
);
}
clients.push(client);
}
return {
environments: filtered.map((environment) => ({
id: environment.id,
apiKey: environment.apiKey,
type: environment.type,
slug: environment.slug,
})),
clients,
};
}
}
function endpointClient(
endpoint: Pick<Endpoint, "id" | "slug" | "url" | "indexingHookIdentifier"> & {
indexings: Pick<EndpointIndex, "source" | "updatedAt" | "stats">[];
},
environment: Pick<RuntimeEnvironment, "id" | "apiKey" | "type">,
baseUrl: string
): ClientEndpoint {
return {
state: "configured" as const,
id: endpoint.id,
slug: endpoint.slug,
url: endpoint.url,
indexWebhookPath: `${baseUrl}/api/v1/endpoints/${environment.id}/${endpoint.slug}/index/${endpoint.indexingHookIdentifier}`,
latestIndex: endpoint.indexings[0]
? {
source: endpoint.indexings[0].source,
updatedAt: endpoint.indexings[0].updatedAt,
stats: parseEndpointIndexStats(endpoint.indexings[0].stats),
}
: undefined,
environment: environment,
};
}
@@ -1,8 +1,3 @@
import {
DisplayPropertySchema,
StyleSchema,
} from "@/../../packages/internal/src";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
type DetailsProps = {
@@ -21,24 +16,24 @@ export class EventDetailsPresenter {
this.#prismaClient = prismaClient;
}
public async call({ id, userId }: DetailsProps) {
const event = await this.#prismaClient.eventRecord.findFirst({
select: {
id: true,
name: true,
payload: true,
timestamp: true,
deliveredAt: true,
},
public async call(runId: string) {
const { event } = await this.#prismaClient.jobRun.findUniqueOrThrow({
where: {
id,
id: runId,
},
select: {
event: {
select: {
id: true,
name: true,
payload: true,
timestamp: true,
deliveredAt: true,
},
},
},
});
if (!event) {
return undefined;
}
return event;
}
}
@@ -0,0 +1,74 @@
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 {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
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(
{
select: {
id: true,
expiresAt: true,
metadata: true,
connectionType: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
runConnections: true,
},
},
},
where: {
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
integration: {
slug: clientSlug,
},
},
orderBy: {
createdAt: "desc",
},
}
);
return {
connections: connections.map((c) => ({
id: c.id,
expiresAt: c.expiresAt,
metadata:
c.metadata != null
? ConnectionMetadataSchema.parse(c.metadata)
: null,
type: c.connectionType,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
runCount: c._count.runConnections,
})),
};
}
}
@@ -0,0 +1,57 @@
import { User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
//Only get the job ids, otherwise we're just fetching data that's already been fetched
export class IntegrationClientJobsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
organizationSlug,
projectSlug,
clientSlug,
}: {
userId: User["id"];
organizationSlug: Organization["slug"];
projectSlug: Project["slug"];
clientSlug: string;
}) {
const jobs = await this.#prismaClient.job.findMany({
select: {
id: true,
},
where: {
internal: false,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
project: {
slug: projectSlug,
},
integrations: {
some: {
integration: {
slug: clientSlug,
},
},
},
},
orderBy: [{ title: "asc" }],
});
return {
jobs: jobs.map((j) => j),
};
}
}
@@ -0,0 +1,115 @@
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 { OAuthClientSchema } from "~/services/externalApis/types";
import { getSecretStore } from "~/services/secrets/secretStore.server";
export class IntegrationClientPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
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({
select: {
id: true,
title: true,
slug: true,
authMethod: {
select: {
type: true,
name: true,
},
},
definition: {
select: {
id: true,
name: true,
},
},
connectionType: true,
customClientReference: {
select: {
key: true,
},
},
createdAt: true,
_count: {
select: {
jobIntegrations: {
where: {
job: {
project: {
slug: projectSlug,
},
internal: false,
},
},
},
},
},
},
where: {
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
slug: clientSlug,
},
});
if (!integration) {
return undefined;
}
const secretStore = getSecretStore(env.SECRET_STORE, {
prismaClient: this.#prismaClient,
});
let clientId: String | undefined = undefined;
if (integration.customClientReference) {
const clientConfig = await secretStore.getSecret(
OAuthClientSchema,
integration.customClientReference.key
);
clientId = clientConfig?.id;
}
return {
id: integration.id,
title: integration.title ?? integration.slug,
slug: integration.slug,
integrationIdentifier: integration.definition.id,
jobCount: integration._count.jobIntegrations,
createdAt: integration.createdAt,
customClientId: clientId,
type: integration.connectionType,
integration: {
identifier: integration.definition.id,
name: integration.definition.name,
},
authMethod: {
type: integration.authMethod?.type ?? "local",
name: integration.authMethod?.name ?? "Local Auth",
},
};
}
}
@@ -0,0 +1,63 @@
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 { Scope } from "~/services/externalApis/types";
export class IntegrationClientScopesPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
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({
select: {
authMethod: true,
scopes: true,
},
where: {
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
slug: clientSlug,
},
});
if (!integration) {
throw new Error("Client not found");
}
const authMethodScopes = (integration.authMethod?.scopes ?? []) as Scope[];
return {
scopes: integration.scopes.map((s) => {
const matchingScope = authMethodScopes.find(
(scope) => scope.name === s
);
return {
name: s,
description: matchingScope?.description,
};
}),
};
}
}
@@ -1,15 +1,19 @@
import { User } from ".prisma/client";
import { User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import {
Organization,
getOrganizationFromSlug,
} from "~/models/organization.server";
import { apiAuthenticationRepository } from "~/services/externalApis/apiAuthenticationRepository.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { Api, apisList } from "~/services/externalApis/apis";
import { integrationCatalog } from "~/services/externalApis/integrationCatalog.server";
import { OAuthClientSchema } from "~/services/externalApis/types";
import { Integration, OAuthClientSchema } from "~/services/externalApis/types";
import { getSecretStore } from "~/services/secrets/secretStore.server";
export type IntegrationOrApi =
| ({
type: "integration";
} & Integration)
| ({ type: "api" } & Api & { voted: boolean });
export class IntegrationsPresenter {
#prismaClient: PrismaClient;
@@ -19,20 +23,33 @@ export class IntegrationsPresenter {
public async call({
userId,
projectSlug,
organizationSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
}) {
const clients = await this.#prismaClient.apiConnectionClient.findMany({
const clients = await this.#prismaClient.integration.findMany({
select: {
id: true,
title: true,
slug: true,
description: true,
integrationAuthMethod: true,
integrationIdentifier: true,
clientType: true,
authMethod: {
select: {
type: true,
name: true,
},
},
definition: {
select: {
id: true,
name: true,
},
},
authSource: true,
connectionType: true,
scopes: true,
customClientReference: {
select: {
@@ -43,7 +60,16 @@ export class IntegrationsPresenter {
_count: {
select: {
connections: true,
jobIntegrations: true,
jobIntegrations: {
where: {
job: {
project: {
slug: projectSlug,
},
internal: false,
},
},
},
},
},
},
@@ -68,9 +94,6 @@ export class IntegrationsPresenter {
const enrichedClients = await Promise.all(
clients.map(async (c) => {
const { integration, authMethod } =
apiAuthenticationRepository.getIntegrationAndAuthMethod(c);
let clientId: String | undefined = undefined;
if (c.customClientReference) {
const clientConfig = await secretStore.getSecret(
@@ -82,9 +105,9 @@ export class IntegrationsPresenter {
return {
id: c.id,
title: c.title,
title: c.title ?? c.slug,
slug: c.slug,
integrationIdentifier: c.integrationIdentifier,
integrationIdentifier: c.definition.id,
description: c.description,
scopesCount: c.scopes.length,
connectionsCount: c._count.connections,
@@ -92,29 +115,54 @@ export class IntegrationsPresenter {
createdAt: c.createdAt,
customClientId: clientId,
integration: {
identifier: integration.identifier,
name: integration.name,
identifier: c.definition.id,
name: c.definition.name,
},
authMethod: {
type: authMethod.type,
name: authMethod.name,
type: c.authMethod?.type ?? "local",
name: c.authMethod?.name ?? "Local Only",
},
authSource: c.authSource,
};
})
);
//filter out the ones that have no connections
const clientsWithConnections = enrichedClients.filter(
(c) => c.connectionsCount > 0
(c) => c.authSource === "LOCAL" || c.connectionsCount > 0
);
const integrations = Object.values(
integrationCatalog.getIntegrations()
).sort((a, b) => a.name.localeCompare(b.name));
).map((i) => ({ type: "integration" as const, ...i }));
//get all apis, some don't have integrations yet.
//get whether the user has voted for them or not
const votes = await this.#prismaClient.apiIntegrationVote.findMany({
select: {
apiIdentifier: true,
},
where: {
userId,
},
});
const apis = apisList
.filter((a) => !integrations.some((i) => i.identifier === a.identifier))
.map((a) => ({
type: "api" as const,
...a,
voted: votes.some((v) => v.apiIdentifier === a.identifier),
}));
const options = [...integrations, ...apis].sort((a, b) =>
a.name.localeCompare(b.name)
);
return {
clients: clientsWithConnections,
integrations,
options,
callbackUrl: `${env.APP_ORIGIN}/oauth2/callback`
};
}
}
@@ -52,7 +52,13 @@ export class ProjectPresenter {
},
integrations: {
select: {
metadata: true,
key: true,
integration: {
select: {
slug: true,
definition: true,
},
},
},
},
},
@@ -72,6 +78,11 @@ export class ProjectPresenter {
name: "latest",
},
},
dynamicTriggers: {
select: {
type: true,
},
},
},
where: {
internal: false,
@@ -148,15 +159,17 @@ export class ProjectPresenter {
const eventSpecification = EventSpecificationSchema.parse(
alias.version.eventSpecification
);
const lastRun =
alias.version.runs[0] != null ? alias.version.runs[0] : undefined;
const integrations = z
.array(IntegrationMetadataSchema)
.parse(
alias.version.integrations.map(
(integration) => integration.metadata
)
);
const integrations = alias.version.integrations.map(
(integration) => ({
key: integration.key,
title: integration.integration.slug,
icon: integration.integration.definition.id,
})
);
let properties: DisplayProperty[] = [];
@@ -176,6 +189,7 @@ export class ProjectPresenter {
slug: job.slug,
title: job.title,
version: alias.version.version,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
@@ -6,7 +6,9 @@ export type Direction = z.infer<typeof DirectionSchema>;
type RunListOptions = {
userId: string;
jobId: string;
jobSlug: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
cursor?: string;
};
@@ -24,7 +26,9 @@ export class RunListPresenter {
public async call({
userId,
jobId,
jobSlug,
organizationSlug,
projectSlug,
direction = "forward",
cursor,
}: RunListOptions) {
@@ -57,8 +61,13 @@ export class RunListPresenter {
},
},
where: {
jobId,
organization: { members: { some: { userId } } },
job: {
slug: jobSlug,
},
project: {
slug: projectSlug,
},
organization: { slug: organizationSlug, members: { some: { userId } } },
environment: {
OR: [
{
@@ -6,7 +6,7 @@ import {
ErrorWithStackSchema,
EventSpecificationSchema,
StyleSchema,
} from "@/../../packages/internal/src";
} from "@trigger.dev/internal";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
@@ -42,14 +42,11 @@ const taskSelect = {
parentId: true,
runConnection: {
select: {
apiConnection: {
integration: {
select: {
client: {
select: {
integrationIdentifier: true,
title: true,
},
},
definitionId: true,
title: true,
slug: true,
},
},
},
@@ -128,9 +125,17 @@ export class RunPresenter {
let runOutput: string | null | undefined = run.output
? JSON.stringify(run.output, null, 2)
: null;
if (run.status === "FAILURE") {
runError = ErrorWithStackSchema.parse(run.output);
runOutput = undefined;
const error = ErrorWithStackSchema.safeParse(run.output);
if (error.success) {
runError = error.data;
runOutput = null;
} else {
runError = { message: "Unknown error" };
runOutput = null;
}
}
return {
@@ -228,20 +233,13 @@ export class RunPresenter {
select: {
id: true,
key: true,
apiConnection: {
integration: {
select: {
metadata: true,
connectionType: true,
client: {
select: {
title: true,
slug: true,
description: true,
scopes: true,
integrationIdentifier: true,
integrationAuthMethod: true,
},
},
title: true,
slug: true,
description: true,
scopes: true,
definition: true,
},
},
},
@@ -1,8 +1,4 @@
import {
DisplayPropertiesSchema,
StyleSchema,
} from "@/../../packages/internal/src";
import { z } from "zod";
import { DisplayPropertiesSchema, StyleSchema } from "@trigger.dev/internal";
import { PrismaClient, prisma } from "~/db.server";
type DetailsProps = {
@@ -30,18 +26,23 @@ export class TaskDetailsPresenter {
select: {
id: true,
key: true,
apiConnection: {
connection: {
select: {
metadata: true,
connectionType: true,
client: {
integration: {
select: {
title: true,
slug: true,
description: true,
scopes: true,
integrationIdentifier: true,
integrationAuthMethod: true,
definition: true,
authMethod: {
select: {
type: true,
name: true,
},
},
},
},
},
@@ -62,6 +63,18 @@ export class TaskDetailsPresenter {
completedAt: true,
style: true,
parentId: true,
attempts: {
select: {
number: true,
status: true,
error: true,
runAt: true,
updatedAt: true,
},
orderBy: {
number: "asc",
},
},
},
where: {
id,
@@ -0,0 +1,126 @@
import { User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { Job } from "~/models/job.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
import {
ConnectionMetadataSchema,
OAuthClientSchema,
} from "~/services/externalApis/types";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { replacements } from "@trigger.dev/internal";
export class TestJobPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
organizationSlug,
projectSlug,
jobSlug,
}: {
userId: User["id"];
organizationSlug: Organization["slug"];
projectSlug: Project["slug"];
jobSlug: Job["slug"];
}) {
const job = await this.#prismaClient.job.findFirst({
select: {
aliases: {
select: {
version: {
select: {
id: true,
version: true,
examples: {
select: {
id: true,
name: true,
icon: true,
payload: true,
},
},
},
},
environment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
},
_count: {
select: {
runs: {
where: {
isTest: true,
},
},
},
},
},
where: {
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
slug: jobSlug,
},
});
if (!job) {
throw new Error("Job not found");
}
return {
environments: job.aliases.map((alias) => ({
id: alias.environment.id,
type: alias.environment.type,
slug: alias.environment.slug,
userId: alias.environment.orgMember?.userId,
versionId: alias.version.id,
examples: alias.version.examples.map((example) => ({
...example,
payload: JSON.stringify(example.payload, exampleReplacer, 2),
})),
})),
hasTestRuns: job._count.runs > 0,
};
}
}
function exampleReplacer(key: string, value: any) {
replacements.forEach((replacement) => {
if (value === replacement.marker) {
value = replacement.replace({
match: {
key,
value,
},
data: { now: new Date() },
});
}
});
return value;
}
+4 -2
View File
@@ -10,7 +10,6 @@ import {
isRouteErrorResponse,
useRouteError,
} from "@remix-run/react";
import { withSentry } from "@sentry/remix";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import type { ToastMessage } from "~/models/message.server";
import { commitSession, getSession } from "~/models/message.server";
@@ -26,6 +25,7 @@ import { env } from "./env.server";
import { usePostHog } from "./hooks/usePostHog";
import { getUser } from "./services/session.server";
import { friendlyErrorDisplay } from "./utils/httpErrors";
import { featuresForRequest } from "./features.server";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
@@ -41,12 +41,14 @@ export const loader = async ({ request }: LoaderArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const features = featuresForRequest(request);
return typedjson(
{
user: await getUser(request),
toastMessage,
posthogProjectKey,
features,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
@@ -128,4 +130,4 @@ function App() {
);
}
export default withSentry(App);
export default App;
@@ -10,6 +10,7 @@ import {
import { useOrganization } from "~/hooks/useOrganizations";
import {
newProjectPath,
organizationBillingPath,
organizationPath,
organizationTeamPath,
organizationsPath,
@@ -32,7 +33,7 @@ export function OrgAdminHeader() {
<LinkButton
to={newProjectPath(organization)}
variant="primary/small"
shortcut="N"
shortcut={{ key: "n" }}
>
Create a new project
</LinkButton>
@@ -43,6 +44,10 @@ export function OrgAdminHeader() {
tabs={[
{ label: "Projects", to: organizationPath(organization) },
{ label: "Team", to: organizationTeamPath(organization) },
{
label: "Usage & billing",
to: organizationBillingPath(organization),
},
]}
/>
</PageHeader>
@@ -0,0 +1,18 @@
import { ComingSoon } from "~/components/ComingSoon";
import { PageContainer, PageBody } from "~/components/layout/AppLayout";
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
export default function Page() {
return (
<PageContainer>
<OrgAdminHeader />
<PageBody>
<ComingSoon
title="Usage & billing"
description="View your usage, tier and billing information. During the beta we will display usage and start billing if you exceed your limits. But don't worry, we'll give you plenty of warning."
icon="billing"
/>
</PageBody>
</PageContainer>
);
}

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