Compare commits

...

156 Commits

Author SHA1 Message Date
Matt Aitken 864498ea85 Latest lockfile 2024-05-13 17:46:02 +01:00
github-actions[bot] 2e21bc1563 chore: Update version for release (beta) (#1094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 17:38:50 +01:00
Matt Aitken 1477a2e309 Set the timeout for canceling checkpoints 2024-05-13 16:50:53 +01:00
Matt Aitken 2081cb15b8 Removed docs for dashboard as they’re empty at the moment. Moved reference docs for Runs and schedules up 2024-05-12 18:29:46 +01:00
Matt Aitken ff551c5cbd Ticked off alerts (in preparation for putting it live) 2024-05-12 18:27:39 +01:00
Matt Aitken 794b25a988 Some fixes for the v3 docs showing v2 items in the sidebar 2024-05-12 18:27:26 +01:00
Matt Aitken 5fdb43ecd1 Bring alerts side menu item back 2024-05-12 18:05:01 +01:00
Matt Aitken 80997bcd6d .env.example Redis env vars are uncommented 2024-05-12 18:02:42 +01:00
Matt Aitken 194f336b84 Added an image of where to set the v3 GitHub action secret 2024-05-12 18:01:27 +01:00
Eric Allam 2e02743170 Resume rate limiting run executions, but reschedule throttled jobs 10 seconds into the future instead of their original run_at, which will help with database load 2024-05-10 15:44:20 -04:00
nicktrn 1642fd7baf perf: migrate to graphile worker v0.16.6 (#1097)
* migrate to graphile worker v0.16.6

* remove stale docs link

* fix jobs cleanup query
2024-05-10 18:07:16 +01:00
Matt Aitken c6449126b1 Don’t start runs if they have runs disabled 2024-05-10 16:11:06 +01:00
Matt Aitken 1ccf38e440 Return a 404 so the webhook provider doesn’t retry 2024-05-10 16:00:42 +01:00
Matt Aitken 29b773cd36 Prevent ingesting events if the org has runs disabled 2024-05-10 15:59:04 +01:00
Matt Aitken 73fb7cfeb3 Prevent HTTP source requests if the org has runs disabled 2024-05-10 15:58:51 +01:00
nicktrn 5431638926 Revert "Revert "Revert "Revert "disable advisory locks""""
This reverts commit 933a14b44c.
2024-05-10 15:09:35 +01:00
Eric Allam 933a14b44c Revert "Revert "Revert "disable advisory locks"""
This reverts commit 4ff07f8a94.
2024-05-10 09:35:53 -04:00
Eric Allam 4ff07f8a94 Revert "Revert "disable advisory locks""
This reverts commit af9957c085.
2024-05-10 09:30:32 -04:00
Eric Allam 0afdb4cc7c Help out the DB 2024-05-10 09:15:20 -04:00
Eric Allam af9957c085 Revert "disable advisory locks"
This reverts commit 892fd9f212.
2024-05-10 09:12:33 -04:00
Eric Allam 5c4275619b v3.1 experiments 2024-05-10 09:11:54 -04:00
nicktrn 892fd9f212 disable advisory locks 2024-05-10 10:21:48 +01:00
James Ritchie cbe9317a86 Added padding to the email body 2024-05-09 17:39:39 +01:00
James Ritchie 6baf9e5294 Dismiss the dev user tooltip in the task page when your mouse leaves the target 2024-05-09 17:05:17 +01:00
James Ritchie a823421324 Small style improvements to the emails 2024-05-09 16:49:45 +01:00
Eric Allam 0e919f56f2 Alerts v1 (#1065)
* Alerts v1

* Encrypt alert webhook secrets and allow them to be generated by the server

* Alert v1 UI

* Remove unnecessary emails

* Move to using `@react-email/components`

* WIP slack alerts

* More slack alerts WIP

* Update pnpm lock after rebase

* Finish implementing Slack alerts

* Use a more error like emoji

* New secondary variant for the segmented control

* Added a simple checkbox style variant to storybook

* Style tweak to the segmented control

* UI improvements to the alert modal

* Use searchable Select for alerts. Changed default variant for SegmentedControl

* Secondary button now using secondary colour

* segmented control style tweak

* Improved the channel column in the alerts table

* Updated logo-mono.png

* Updated email styles

* Email templates updated to new styles

* Don’t log the decrypted secret

* await enqueing the deployment alert when an index fails

* await enqueing the timeout alert

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-09 12:12:00 +01:00
Matt Aitken f90960fc13 Better display when there are lots of dev environment (#1093)
* Created an EnvironmentLabels component that puts extra dev environments behind a +

* Added new environment labels components to schedules pages
2024-05-09 10:32:38 +01:00
nicktrn 2306217697 v3: automatic pod cleanup (#1092)
* add automatic pod cleaner

* fix task monitor namespace override
2024-05-09 10:16:00 +01:00
nicktrn cfe4b5409a Fix lockfile 2024-05-08 17:27:34 +01:00
github-actions[bot] 223768a025 chore: Update version for release (beta) (#1091)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-08 17:25:22 +01:00
Ramadan Omar 1fd26ff98b Improvement: Added more context to Shopify webhook registering errors (#1090)
* Improvement: Added more context to Shopify webhook registering errors

Co-authored-by: Ahmed Ramadan <ahmedramadan1337@gmail.com>

* revert: shopify reference jobs

* fix: added error handling to some shopify client edge cases

* small changes to crud error handling

---------

Co-authored-by: Ahmed Ramadan <ahmedramadan1337@gmail.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-05-08 17:22:59 +01:00
Eric Allam 8d34c63c2d Fix issue when concurrency limit is set to 0 on a queue 2024-05-08 10:19:06 +01:00
Zafer Cesur ff04c986a1 Add notice on sideEffects in deployment guide (#680)
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-07 22:58:27 +01:00
Matt Aitken 7d6430dc46 New filtered dropdown and run filters (#1089)
* Extract out useFilterTasks

* WIP on Combobox

* Revert "WIP on Combobox"

This reverts commit f0a9b2e57c67f24d5e0876630e17a54252ceb86b.

* Added ariakit

* Started adding a Listbox component

* Listbox WIP

* More WIP on the list box

* More work on the Listbox

* WIP on filterable Listbox using headless… but it’s going to be a nightmare to make it good. Wrong approach

* WIP using Ariakit

* Multiple selection working

* WIP in reworking it so search is built in and it’s easier to use

* Nicer API for listbox

* Allow sections to be rendered with filtering

* More progress on the styling of the menu

* The empty state

* Removed focus outline

* Allow filtering based on the section title

* Render the shortcut correctly

* Allow passing static children to Select and render a separator if a section has no title

* Static example and added subtle focus

* Allow static data with no items array passed in

* Allow shortcuts on select items

* Only how heading if true

* Tooltip for keyboard shortcut

* useShortcutKeys fixes

- Can be passed an undefined definition (so we render the same number of hooks always).
- Fix for enabledOnInputElements not being used…

* Shortcut keys working with lists

* Don’t render shortcut numbers higher than 0

* More improvements

* WIP on a Link row

* Nicer API for the children function

* Much nicer shortcut key support

* Separated SelectTrigger out

* Heading separated out

* More styled elements created

* Removed unused element

* Reordered statuses

* Added search context

* Fixes for default values

* Removed tab elements

* Combo box

* Simplified it more

* Started work on Filter menu

* Experiments with filter menu

* Filter menu proof of concept working

* Nice hook for using a search param

* The escape key goes back to the previous menu

* Added filter page to storybook

* Added other variants

* Renamed Select file to OldSelect, implemented Feedback form

* Added placeholder support

* Added an optional dropdown icon (defaults to a chevron down)

* Feedback new Select

* Feedback just use static items

* New project page

* Renamed storybook pages

* Lots of progress on the new run filters

* Added new tasks filters

* Show the applied filters

* Added period filtering

* Fix for escape resetting the period value to the previous value when escape is pressed

* Show the Clear button sooner

* Removed unused code

* Reworked useSearchParams so multiple values can be set at once…

* Renamed file: OldSelect -> SImpleSelect

* Fix for new Select not working in forms because props weren’t being passed through

* New Select on the schedules page

* Better layout when there are lots of run filters

* Many style improvements to run filters

* Status filter allows you to select as well

* Rolled out the menus for the other files

* Minor improvements to schedule page and page padding

* Latest lockfile
2024-05-07 22:55:31 +01:00
Dan b289aa31da Readme updates - removed old roadmap and added dev preview EA link (#1078)
* Removed outdated roadmap

* Added link to the dev preview article
2024-05-07 22:55:00 +01:00
Eric Allam 0508de6258 Release 3.0.0-beta.28 2024-05-06 12:35:27 +01:00
github-actions[bot] 56a063e586 chore: Update version for release (beta) (#1087)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-06 12:34:43 +01:00
Eric Allam d490bc5cbf Add the "log" level back in as an alias to "info" 2024-05-06 12:28:17 +01:00
Eric Allam 6d9dfbc75d Add configure function to be able to configure the SDK manually
And move the ApiClient configuration away from AsyncLocalStorage and use our globals system instead
2024-05-06 12:12:30 +01:00
Eric Allam 6406924b02 Ensure @trigger.dev/sdk and @trigger.dev/core are always in the list of deployed dependencies 2024-05-06 11:01:49 +01:00
Eric Allam 2156e15266 Adding some additional telemetry during deploy to help debug issues 2024-05-06 10:34:54 +01:00
Eric Allam 49b4d47726 Release 3.0.0-beta.27 packages 2024-05-05 08:20:07 +01:00
github-actions[bot] 2e4f5d893c chore: Update version for release (beta) (#1086)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-05 08:19:13 +01:00
Eric Allam 203e002087 Add runs.retrieve management API method to get info about a run by run ID 2024-05-04 22:24:42 +01:00
Eric Allam 1670c4c41d Remove "log" Log Level, unify log and info messages under the "info" log level 2024-05-03 14:55:24 +01:00
Eric Allam 9d6c424236 Add deploy admin debug tooltips 2024-05-03 11:22:51 +01:00
nicktrn cffc88aeac Increase indexing specs 2024-05-03 09:45:18 +01:00
Eric Allam ae13757703 Release 3.0.0-beta.26 (fix server-only imports) 2024-05-03 09:40:32 +01:00
github-actions[bot] 0ee39e6101 chore: Update version for release (beta) (#1085)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-03 09:39:34 +01:00
nicktrn bce398b892 increase indexing specs 2024-05-03 09:36:16 +01:00
Eric Allam e667028d4a Strip out server-only package from worker builds 2024-05-02 21:28:41 +01:00
Eric Allam ae99807047 Allow admin tooltips while impersonating 2024-05-02 16:39:01 +01:00
Eric Allam bf4e505c70 Release 3.0.0-beta.25 2024-05-02 16:00:43 +01:00
github-actions[bot] c8538c236c chore: Update version for release (beta) (#1080)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-02 15:58:42 +01:00
Eric Allam ff6e2461d5 Add admin tooltips to make support/debugging easier for admin users 2024-05-02 14:12:16 +01:00
Eric Allam 3ed016bc2b Fixed SharedQueueConsumer internal otel logging 2024-05-02 12:31:55 +01:00
Eric Allam 68dd3804a7 Improve the fair queue selection strategy by paginating through all the queues in the parent queue 2024-05-02 11:56:35 +01:00
Eric Allam 95f0c7b780 increase queue selection count to 36 2024-05-01 22:41:50 +01:00
Eric Allam 339c39d739 Fix type issues and unit tests 2024-05-01 22:23:38 +01:00
Eric Allam bc0ca01319 Fix fair queue priority strategy off-by one error 2024-05-01 22:13:52 +01:00
Eric Allam c37c822314 Use locked package versions when resolving dependencies in deployed workers 2024-05-01 15:51:39 +01:00
Eric Allam 2dfe191c55 Fixed some docs that were missing imports 2024-05-01 14:47:50 +01:00
Eric Allam e337b21650 Add a postInstall option to allow running scripts after dependencies have been installed in deployed images 2024-05-01 14:16:52 +01:00
Eric Allam 9e5382951b Improve the display of non-object return types in the run trace viewer 2024-05-01 13:52:05 +01:00
nicktrn ed03f4bc15 fix lockfile 2024-05-01 11:33:10 +01:00
github-actions[bot] 9feb0f70b0 chore: Update version for release (beta) (#1079)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-01 11:32:15 +01:00
nicktrn 25de9e59af run workflows 2024-05-01 10:57:09 +01:00
nicktrn 83dc871550 add changeset 2024-05-01 10:48:27 +01:00
nicktrn 62700245a3 v3: fix consecutive waits (#1073)
* fix spacing for delete hints

* don't try to resume deps on wait resume

* sending duration wait resumes is not an error anymore

* set correct status with new wait resume flow

* cancel checkpoint schema v2

* don't mix messages and schemas

* prevent unintended case fallthrough in tree view

* completely switch to platform-led duration wait resumes

* prevent infinite restores

* some entries for the catalog

* add pg to additional packages

* add checkpoint safe timeout

* prevent duplicate spans after restore

* wait for post start

* add sdk version to deploy tab

* fail on impossible checkpoint scenarios

* remove debug logs
2024-05-01 10:33:30 +01:00
Matt Aitken 6ce820cb45 Test tasks that return different types 2024-04-30 19:07:44 +01:00
Matt Aitken 0f0a6884e8 Environment variables pasting uses dotenv (#1075)
* Add dotenv package to the webapp frontend, required some polyfills

* Use dotenv to parse the pasted env vars. Make the panel wider on larger screens
2024-04-30 14:23:49 +01:00
Matt Aitken 7ff8f0ebab Task and run page improvements (#1076)
* TaskListPresenter: if there are no tasks then don’t do stats queries

* RunListPresenter, use BasePresenter and the read replica

* Added populate script

* Simplified the Runs list query, added live timer

* Added TaskRun indexes for the RunList

* Status can’t be null now we’re using the TaskRun status

* Use defer so the page loads and shows a spinner

* Improved the loading style

* Get rid of latest run info from the tasks table super slow

* Fix for the activity graph tooltip getting clipped

* Added a code comment crediting the GitHub issue with the portal fix

* Add search to the tasks list

* Padding

* Fix for the schedules columns not being UTC

* Remove unused function
2024-04-30 14:19:50 +01:00
Matt Aitken 68455c796a Fixes/run filtered keyboard nav (#1074)
* Ensure each switch condition returns a state

* Fix for scrolling when filtered

* Fix for up/down navigation when filtered
2024-04-29 18:52:46 +01:00
Eric Allam b0a2c42e0e Add some additional logging around nacking messages 2024-04-29 17:53:38 +01:00
Andreas Thomas cac3c32f6a docs: add 'retry' import in code snippet (#1071)
* docs: add 'retry' import in code snippet

* docs: import all primitives
2024-04-29 17:42:36 +01:00
Matt Aitken ed8d24fd3d Run page performance improvements (#1072)
* lotsOfLogs task now outputs much larger logs

* Removed tree view collapse/expand animation

* JSDocs for useDebounce

* WIP moving filtering into the state

* Reworked the reducer to do the filtering
2024-04-29 17:05:26 +01:00
nicktrn d0ef36260a stop trying to pull init image when already present 2024-04-29 15:33:46 +01:00
Eric Allam 8eb68dd852 Fix pnpm lock file 2024-04-29 14:50:28 +01:00
github-actions[bot] a42037da03 chore: Update version for release (beta) (#1070)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-29 14:48:01 +01:00
Eric Allam 43bc7ed94e Hoist uncaughtException handler to the top of workers to better report error messages 2024-04-29 14:22:55 +01:00
Matt Aitken 4fdb7f8288 Bulk insert environment variables (#1069)
* Fix for #1066. Correct environment username if dev

* WIP changing the form

* WIP on the form

* WIP on repository

* Adding environment variables en masse is working

* WIP on pasting

* useList hook with reducer

* Got bulk insert working with pasting… it was a pain

* Allow overwriting of values

* Set a max height on the new env var form
2024-04-28 18:31:31 +01:00
Matt Aitken 37b9b056c4 Upload payload packets outside of the db transaction 2024-04-28 18:18:33 +01:00
Matt Aitken 801c86bf73 Added search to the test tasks list 2024-04-28 16:10:46 +01:00
Matt Aitken a1de11a001 Default the test page to the “DEV” tab 2024-04-28 15:59:45 +01:00
Praveen Pendyala 29e9e372ee Added example of github workflow for deploy to staging (#1068) 2024-04-26 18:39:34 +01:00
Eric Allam affc128161 Try to fix prisma generate errors 2024-04-26 18:28:17 +01:00
Eric Allam 40ba8ad0ee Fix the test page when a scheduled task has recent run payloads that aren’t scheduled payloads 2024-04-26 18:20:04 +01:00
Matt Aitken 96168eb383 Fixes for test page problems 2024-04-26 17:45:06 +01:00
Eric Allam 2c68473cc8 Fix pnpm lock file 2024-04-26 17:14:11 +01:00
github-actions[bot] 469c8a2532 chore: Update version for release (beta) (#1067)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-26 17:13:20 +01:00
Eric Allam ebeb790522 Add typescript as a dependency so the esbuild-decorator will work even when running in npx 2024-04-26 17:00:14 +01:00
Matt Aitken e765240370 Latest lockfile 2024-04-26 14:37:46 +01:00
github-actions[bot] 38770a91c7 chore: Update version for release (beta) (#1061)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-26 14:35:30 +01:00
Matt Aitken de188a22c5 Option for users to opt-in to a staging environment (#1064) 2024-04-26 11:29:20 +01:00
Matt Aitken fae93ac747 New tasks page (with activity) and multi-user fixes (#1063)
* Fix for “overridden” spelling mistake

* Make the task function names grey instead of yellow

* The task page now shows each task once

* Blankline

* WIP on activity bars

* Correct size of the graphs

* Add data for all days, even if there are no runs

* Activity graph is working and has a tooltip

* No activity label

* Got the bar bg working

* Activity chart now has a bg

* Added a compound id to make the activity graphs appear faster

* Improvements to the activity graph

* Tried to disable the bg from animating

* Added read replica support to the app, using Nick’s code from his batching PR

* Added the read replica to BasePresenter

* Added the queued and running columns to the Tasks list

* Added avg duration column

* Included completed but failed runs in the average duration

* Get rid of the old useDevEnvironment hook and remove API keys from the projects query

* Unified getting environment info to using a function and doing it in presenters

* Removed the path from the tasks table

* Onboarding for dev for 2nd+ user. Improved environment sorting.

* Removed log

* Improved the spacing

* Enable staging for new v3 projects
2024-04-26 10:44:39 +01:00
Eric Allam c3f6557eb6 Create publish preprelease script 2024-04-25 11:26:14 +01:00
Eric Allam 9491a1649c v3: Support decorators (#1060)
* Adds support for `emitDecoratorMetadata: true` and `experimentalDecorators: true` in your tsconfig

* Implement task.onSuccess/onFailure and config.onSuccess/onFailure

* Added onStart and more docs for lifecycle functions

* Use onStart instead of init for TypeORM
2024-04-25 11:17:13 +01:00
Eric Allam 0a5aa2dc15 Fix slow job retries by optimizing how errored tasks are deleted 2024-04-24 22:07:12 +01:00
Eric Allam 4a1a5b2e4a fix pnpm lock file 2024-04-24 15:07:22 +01:00
github-actions[bot] 503882762c chore: Update version for release (beta) (#1059)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-24 15:06:25 +01:00
Eric Allam e3db257397 Fix error stack traces 2024-04-24 14:18:53 +01:00
Eric Allam de1cc868e3 Fix dev CLI output when not printing update messages 2024-04-24 14:06:49 +01:00
Eric Allam ff7fa9e19a Clear out errored tasks when re-attempting a job run (instead of using the cached error) 2024-04-24 13:00:02 +01:00
Eric Allam ba4f04db37 Fix pnpm lock file 2024-04-24 12:49:06 +01:00
github-actions[bot] d7911892ee chore: Update version for release (beta) (#1058)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-24 12:48:00 +01:00
Eric Allam f888f85a09 Make the update trigger packages not print anything in dev 2024-04-24 12:16:49 +01:00
Eric Allam e9a63a4868 Lock SDK and CLI deps on exact core version 2024-04-24 12:12:24 +01:00
Eric Allam 93ecbc5e05 Fix pnpm lock file 2024-04-24 11:52:12 +01:00
Eric Allam 60a8a5777b Merge pull request #1057 from triggerdotdev/changeset-release/main
chore: Update version for release (beta)
2024-04-24 11:50:13 +01:00
github-actions[bot] ed1cd16753 chore: Update version for release (beta) 2024-04-24 10:43:28 +00:00
Eric Allam 6bb4dd7b6f Fixed messaging when trying to login with an existing invalid access token 2024-04-24 11:41:38 +01:00
nicktrn 0b5a0be807 update CI deploy error 2024-04-24 11:23:20 +01:00
nicktrn 74d1e61e42 v3: fix for fresh CLI logins after CLI token revoke (#1056)
* prettier login command output

* fix for trying to login again after revoking cli token

* changeset

* improve errors when logging in with revoked or invalid token

* fix builds.. again
2024-04-24 10:44:34 +01:00
Eric Allam 12c83a56af Skip e2e github workflows for now because playwright is broken 2024-04-24 10:33:11 +01:00
nicktrn 75a54540a4 use full image refs for init containers 2024-04-24 10:13:28 +01:00
Eric Allam b68012f81c v3: Various fixes for Next.js projects and projects that use v2 and v3 together (#1051)
* Fixes an issue that was treating v2 trigger directories as v3

* Make msw a normal dependency (for now) to fix Module Not Found error in Next.js.

* Extract out all the zod* stuff from core so the SDK does not import it

* Add a changeset

* Fixing typecheck errors in the webapp

* Export the Task and TaskOptions types

* Extract additional exports from core/v3 that aren’t used in the SDK

* Move to our global system from AsyncLocalStorage for the current task context storage

* Update the esbuild core bundling plugin for the new core v3 exports

* Fix v3 CLI telemetry

* Add support for tasks located in subdirectories inside trigger dirs

* Remove the env var check during deploy (too many false negatives)
2024-04-24 10:09:36 +01:00
nicktrn fb83d58703 fix builds 2024-04-24 08:48:32 +01:00
nicktrn 52b2a8289c fix private package installs (#1055) 2024-04-24 07:58:14 +01:00
nicktrn 1e93ec4216 Revert "change action runner location" (resolved)
This reverts commit cb27b7278a.
2024-04-24 07:45:08 +01:00
nicktrn cb27b7278a change action runner location ⬆️⬆️⬇️⬇️⬅️➡️⬅️➡️🅱️🅰️ 2024-04-23 18:13:04 +01:00
Eric Allam e85fc501a6 Fix pnpm lock file 2024-04-23 17:03:53 +01:00
github-actions[bot] 0bfeb0816f chore: Update version for release (beta) (#1054)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-23 16:58:48 +01:00
nicktrn b207601732 v3: semi-automatic package update command (#1052)
* fix package manager detection

* fix cli update check while in beta

* add update command and warn on mismatches

* update fixes and better integration with dev and deploy

* fix for ci

* update docs

* changeset

* be more specific about prompts before updates

* fail on version mismatch during ci

* update github actions docs

* fix package manager copypasta

* remove ncu

* ignore companyicons

* improve warnings and errors
2024-04-23 16:08:46 +01:00
Matt Aitken 3913e57ef4 Trace view fixes and improvements (#1046)
* Query param for span using history.replaceState is working

* When clicking again on a node, don’t collapse it

* Close the span view using the same replacing of the search param

* Conditional rendering of the resize panels was causing the tree view re-rendering and collapsing…

* Live reloading moved to where the parent label is

* Span action bar is now deeper

* WIP on trace view navigation changes with shortcuts

* Shortcuts for expanding and collapsing en masse

* Number keys expand/collapse levels

* Changed duration toggle to a shortcut key

* Option + click expands/collapse at that level

* Option/alt left/right expands/collapse at that level

* Removed unused imports

* Link from the runs table to the specific span

* Latest lockfile

* Sorted imports

* When doing a test link directly to a span

* Replay links to the span

* CLI log links go directly to a span

* Keyboard shortcuts are in a popover if the width is narrow

* If holding alt only collapse level

* Don’t expand the individual node if you’re holding alt
2024-04-22 13:46:08 +01:00
James Ritchie 26f310397a Fixed the grid layout for v2 runs page 2024-04-22 13:06:56 +01:00
github-actions[bot] 0a845767a0 chore: Update version for release (beta) (#1049)
* chore: Update version for release (beta)

* update lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-04-21 15:07:11 +01:00
nicktrn ed2a26c865 v3: fix additionalFiles, uncaught exceptions, and improve errors (#1048)
* fix additionalFiles for paths up the tree

* warn if additionalFiles pattern produced no matches

* improve deploy warnings and errors, fail early

* changeset
2024-04-21 15:03:00 +01:00
Eric Allam 4a68e71583 Fix pnpm lock file 2024-04-19 19:20:01 +01:00
github-actions[bot] b657eb6555 chore: Update version for release (beta) (#1044)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-19 19:19:11 +01:00
Eric Allam 62c9a5b712 Fix restoring after waiting for task/batch 2024-04-19 18:59:02 +01:00
Matt Aitken f339b41ef3 Added some basic Defer migration details for CRON 2024-04-19 18:30:58 +01:00
Eric Allam ae40ce3995 Fix the management file 2024-04-19 16:14:57 +01:00
Eric Allam 374edef020 Updates the trigger, batchTrigger and their *AndWait variants to use the first parameter for the payload/items, and the second parameter for options (#1045)
Also always returns a `TaskRunResult` object from `triggerAndWait` instead of rethrowing subtask errors in the parent
2024-04-19 14:51:51 +01:00
Eric Allam b82db67b81 Add additional logging around cleaning up dev workers, and always kill them after 5 seconds if they haven't already exited 2024-04-19 11:14:20 +01:00
Eric Allam 26093896d2 v3: Fixes for using (batch)triggerAndWait with idempotency keys (#1043)
* Fixes various issues with triggerAndWait and batchTriggerAndWait

When using idempotency keys, triggerAndWait and batchTriggerAndWait will still work even if the existing runs have already been completed (or even partially completed, in the case of batchTriggerAndWait)

- TaskRunExecutionResult.id is now the run friendlyId, not the attempt friendlyId
- A single TaskRun can now have many batchItems, in the case of batchTriggerAndWait while using idempotency keys
- A run’s idempotencyKey is now added to the ctx as well as the TaskEvent and displayed in the span view
- When resolving batchTriggerAndWait, the runtimes no longer reject promises, leading to an error in the parent task

* Remove the default queue concurrency limit as we now have env and org concurrency limits

* Use the run friendlyId in the completion result id

* Added some error logging
2024-04-19 10:54:43 +01:00
github-actions[bot] e7bd1ee676 chore: Update version for release (beta) (#1042)
* chore: Update version for release (beta)

* update lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-04-18 17:53:33 +01:00
nicktrn 584c7da5df v3: prod worker graceful shutdown (#1034)
* graceful exit with timeout

* handle and display graceful timeout errors

* fix for very long waits

* changeset

* increase termination grace period to an hour
2024-04-18 15:46:14 +01:00
nicktrn c9e1a3e9c5 v3: fix dependency trigger and wait (#1030)
* fix dev dependecy resumes

* add catalog entry with multiple trigger waits

* update docker provider lifecycle command

* switch to x for clearing run filters

* Revert "fix dev dependecy resumes"

This reverts commit b4061f2ae71f21889adcf061928e2e77c436f0ed.

* fix dependency resumes, properly this time

* add catalog entry for dependency waits in loops

* advice in docs re parallel dependency waits

* fix link from v3 to v2 docs

* move lifecycle command logging to debug only

* Removed batchOptions from the trigger options and the docs

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-04-18 14:48:43 +01:00
nicktrn 2f5b4a8471 v3: fix raw queries for custom schemas (#1033)
* add custom validation to db url env vars

* extract schema from db url and use in all raw queries

* use qualified names in scheduling raw queries

* cook a few raw queries

* Added missing raw query schema specifier to DeploymentListPresenter

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-04-18 12:01:38 +01:00
Eric Allam 69f6891687 Fixed rebalancing when message is acked
I’ve also added a “rebalancer worker” that will periodically rebalance parent queues, in case have been any issues with rebalancing (basically a “sweeper of entropy” process)
2024-04-17 21:00:39 +01:00
Matt Aitken acd7681e58 Scheduled task docs (#1039)
* Scheduled docs v1

* Improved copy

* Rejigged the docs some more. Added a dynamic section
2024-04-17 17:41:16 +01:00
Eric Allam 180a5ef01d Fix pnpm lock file 2024-04-17 16:34:14 +01:00
github-actions[bot] 62544d3234 chore: Update version for release (beta) (#1038)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-17 16:33:23 +01:00
Eric Allam 9b045071d8 Better errors when trying to delete a schedule that doesn’t exist 2024-04-17 16:05:51 +01:00
Eric Allam 618d2207c6 Fixed the deployed scheduled task check by looking at the slug, not the task.id 2024-04-17 15:38:39 +01:00
Eric Allam 36d8bee14a Don’t delete task runs if the attached task schedule or instance is deleted 2024-04-17 15:26:35 +01:00
Eric Allam 44e1b87547 v3: various schedule fixes (#1040)
* Improve the SDK function types and expose a new APIError instead of the APIResult type

* Skip triggering scheduled tasks if the task isn’t found in the current deployment

Also fixes an issue when editing the environments of a schedule
2024-04-17 15:10:23 +01:00
Matt Aitken 71b0ef8f77 Don’t allow editing the Deduplication key when updating a schedule 2024-04-17 14:42:18 +01:00
Matt Aitken f66543c9c7 When updating a schedule in the UI, it now says “Schedule updated” 2024-04-17 14:33:11 +01:00
Matt Aitken 03b104a3d5 Added JSDocs to the schedule options in the SDK 2024-04-17 14:25:40 +01:00
Eric Allam fde939a30e Fixed empty strings making their way into schedule externalId 2024-04-17 11:59:29 +01:00
Matt Aitken 4986bfda2e Scheduled tasks (#1036)
* Database schema and migrations for schedules

* Added schedules to the side menu

* The pagination can optionally hide the page numbers for a compactive mode

* Filters for the schedule page

* Added triggerSource (“STANDARD”, “SCHEDULED”) to BackgroundWorkerTask

* Added the ability to disabled a LinkButton

* Started work on the schedule page

* Environment buttons

* The new schedule form styles

* Added a cxouple of extra fields

* Allow a checkbox to have a rich label

* Added cronstrue package to the webapp

* WIP creating tasks using the form

* Improved the form styling

* Creating schedules is working in the UI

* Minor improvements

* Basic schedule table is displaying

* Creating tasks with triggerSource = scheduled. Refactored how task metadata is stored and accessed to be cleaner

* Resource route

* Fixes in the form

* Added a gap between the environments

* WIP on OpenAI generating CRON expressions

* AI generated CRON expressions is working

* Fix for the CRON field being uneditable after an AI generation

* Improvements

* Table padding

* useThrottle now behaves correctly

* Added filtering to the schedules list

* Improved the layout and fixed CRON search

* Fixed pagination for the schedules list. Just use a regular Prisma query

* Page size of 20

* Added links to the schedule rows

* Get rid of the Last run column for now

* Implement triggered scheduled tasks

Also implemented superjson payloads and dev runtime environment “presence” with RuntimeEnvironmentSession

* Move CronPattern and CreateSchedule into a common client-accessible file

* Latest UI changes

* Fix for creating a task schedule with a blank dedup key

* Refactor the human to cron stuff into a separate file and use json_object OpenAI response format

* Fix for trying to use a hook on the server-side…

* A couple of fixes to the new schedule form

* WIP on viewing a scheduled run

* Make the filters all optional

* Use the RunListPresenter from the schedule presenter

* Display a table of runs… the wrong runs but still

* Runs from the schedule

* Deleting schedules from the UI

* Tidied imports and fixed name of options object

* Disabling a schedule

* Editing schedules

* Tidied imports

* Added icons to the task list, needs some design love

* Added a tooltip for CRON pattersn

* Show the last run in the schedules table

* Some tweaks

* Added a placeholder to the CRON AI field

* Improved the trigger source icon

* Dim out disabled schedules

* Scheduled tasks have the correct icon in a run

* Added the task source icon to the test task list

* Added the date field component to storybook

* Style improvements to the date field

* Implement Task Schedule API

* added a medium sized variant to the date field

* Fixed replay run for superjson payload types

Also now linking from the replaying run to the original run (using span links)

Also added a project metrics prometheus endpoint to detect the state of the queues

* WIP on allowing different forms for testing

* If you pass a string to prettyPrintPacket which is json or superjson, safeParse it first

* Test page, deal json and superjson. Started splitting UI for schedules

* WIP on schedule form

* Removed the Label from the DateField

* WIP on test schedule form

* Fix for the runs page showing the wrong message when there are no runs from time filtering

* Removed labels from the DateField

* Fixes for the form

* Test runs are working for schedules

* Fix for nextScheduledTimestamps in triggerScheduledTask

* Implement idempotency key support and fix issue with cancelled runs causing concurrency usage

* Added API documentation for the schedule API

* Remove log

* Removed console log from runs page

* Transform the recent runs test data on the server

* Fix for hydration mismatch

* Current date as the default for the test form

* Recent payloads working

* Delete schedule modal

* Deal with empty strings from the form

* Set the initial value for the scheduled test form

* Add option to print console logs in the dev CLI locally (issue #1014)

* Export queue from the SDK

* Fix for schedules list when you have no schedule tasks

* Blank states improved

* Make task schedules more generic, to support additional schedule generators in the future

* Removed log from maqrs

* Removed “v3/schedules” export from the SDK

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2024-04-17 11:14:23 +01:00
Matt Aitken a3abe4ca08 Documentation edits made through Mintlify web editor 2024-04-17 09:08:08 +01:00
Matt Aitken fad32a79dd Documentation edits made through Mintlify web editor 2024-04-17 09:07:16 +01:00
Matt Aitken 9d843caf83 Documentation edits made through Mintlify web editor 2024-04-16 14:33:07 +01:00
nicktrn 5eaf7bca34 don't require git for build 2024-04-12 19:02:38 +01:00
540 changed files with 31767 additions and 6072 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Fix additionalFiles that aren't decendants
- Stop swallowing uncaught exceptions in prod
- Improve warnings and errors, fail early on critical warnings
- New arg to --save-logs even for successful builds
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
v3 CLI update command and package manager detection fix
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Hoist uncaughtException handler to the top of workers to better report error messages
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/shopify": patch
---
improved error messages when a shopify webhook fails to register
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Fix error stack traces
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Implement task.onSuccess/onFailure and config.onSuccess/onFailure
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Remove "log" Log Level, unify log and info messages under the "info" log level
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Add option to print console logs in the dev CLI locally (issue #1014)
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Strip out server-only package from worker builds
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Remove the env var check during deploy (too many false negatives)
+9
View File
@@ -0,0 +1,9 @@
---
"@trigger.dev/sdk": patch
---
Make msw a normal dependency (for now) to fix Module Not Found error in Next.js.
It turns out that webpack will "hoist" dynamically imported modules and attempt to resolve them at build time, even though it's an optional peer dep:
https://x.com/maverickdotdev/status/1782465214308319404
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Add runs.retrieve management API method to get info about a run by run ID
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Export queue from the SDK
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Fix issues with consecutive waits
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Adding some additional telemetry during deploy to help debug issues
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add the "log" level back in as an alias to "info"
+83
View File
@@ -0,0 +1,83 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Adds support for `emitDecoratorMetadata: true` and `experimentalDecorators: true` in your tsconfig using the [`@anatine/esbuild-decorators`](https://github.com/anatine/esbuildnx/tree/main/packages/esbuild-decorators) package. This allows you to use libraries like TypeORM:
```ts orm/index.ts
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity()
export class Photo {
@PrimaryColumn()
id!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column()
filename!: string;
@Column()
views!: number;
@Column()
isPublished!: boolean;
}
export const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "v3-catalog",
entities: [Photo],
synchronize: true,
logging: false,
});
```
And then in your trigger.config.ts file you can initialize the datasource using the new `init` option:
```ts trigger.config.ts
import type { TriggerConfig } from "@trigger.dev/sdk/v3";
import { AppDataSource } from "@/trigger/orm";
export const config: TriggerConfig = {
// ... other options here
init: async (payload, { ctx }) => {
await AppDataSource.initialize();
},
};
```
Now you are ready to use this in your tasks:
```ts
import { task } from "@trigger.dev/sdk/v3";
import { AppDataSource, Photo } from "./orm";
export const taskThatUsesDecorators = task({
id: "taskThatUsesDecorators",
run: async (payload: { message: string }) => {
console.log("Creating a photo...");
const photo = new Photo();
photo.id = 2;
photo.name = "Me and Bears";
photo.description = "I am near polar bears";
photo.filename = "photo-with-bears.jpg";
photo.views = 1;
photo.isPublished = true;
await AppDataSource.manager.save(photo);
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixes an issue that was treating v2 trigger directories as v3
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Better handle uncaught exceptions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Improve the SDK function types and expose a new APIError instead of the APIResult type
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Add configure function to be able to configure the SDK manually
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix dev CLI output when not printing update messages
+45 -1
View File
@@ -44,40 +44,84 @@
"@trigger.dev/yalt": "2.3.18"
},
"changesets": [
"angry-eagles-trade",
"beige-pens-dance",
"big-tomatoes-deliver",
"blue-pumas-whisper",
"breezy-gorillas-mate",
"chilled-hornets-move",
"clean-pianos-listen",
"clever-apes-collect",
"clever-carrots-travel",
"clever-donkeys-hunt",
"cool-glasses-bake",
"cuddly-feet-approve",
"dry-walls-check",
"dull-mangos-press",
"eight-pumas-float",
"eleven-paws-join",
"few-students-share",
"funny-swans-destroy",
"gorgeous-gorillas-compete",
"green-bags-wink",
"khaki-apricots-design",
"khaki-poems-lay",
"late-icons-lie",
"late-steaks-behave",
"lazy-files-lay",
"lemon-jobs-repair",
"light-bulldogs-press",
"light-dragons-complain",
"little-crabs-cross",
"long-fireants-search",
"loud-actors-remember",
"lovely-drinks-flash",
"many-ligers-pump",
"mighty-camels-joke",
"mighty-flowers-train",
"nasty-jars-pump",
"new-rivers-tell",
"nice-bulldogs-turn",
"ninety-pets-travel",
"odd-poets-own",
"pink-pumas-rhyme",
"polite-ducks-switch",
"polite-rockets-matter",
"poor-flowers-cross",
"purple-garlics-shop",
"rare-lamps-promise",
"rare-roses-float",
"real-planets-stare",
"rich-kangaroos-unite",
"rotten-beers-refuse",
"rotten-dryers-exercise",
"selfish-ducks-sort",
"shaggy-spoons-taste",
"sharp-emus-compare",
"sharp-zebras-serve",
"shiny-coats-cry",
"silly-suits-switch",
"six-ligers-exist",
"slow-buses-own",
"smart-needles-move",
"smart-olives-eat",
"spicy-lamps-smoke",
"strange-ghosts-matter",
"strong-lemons-add",
"stupid-bulldogs-applaud",
"sweet-lizards-press",
"swift-dragons-peel",
"tall-bees-wave",
"tame-guests-know",
"tender-moose-tell",
"tender-oranges-rhyme",
"thin-parents-heal",
"tidy-balloons-suffer",
"tiny-doors-type"
"tidy-dryers-sleep",
"tiny-doors-type",
"tiny-elephants-scream",
"tricky-bulldogs-heal",
"tricky-ladybugs-unite",
"two-pumas-wait"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add typescript as a dependency so the esbuild-decorator will work even when running in npx
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Add a postInstall option to allow running scripts after dependencies have been installed in deployed images
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix a bug where revoking the CLI token would prevent you from ever logging in again with the CLI.
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add git to prod worker image which fixes private package installs
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Adding task with a triggerSource of schedule
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
---
Lock SDK and CLI deps on exact core version
+56
View File
@@ -0,0 +1,56 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Updates the `trigger`, `batchTrigger` and their `*AndWait` variants to use the first parameter for the payload/items, and the second parameter for options.
Before:
```ts
await yourTask.trigger({ payload: { foo: "bar" }, options: { idempotencyKey: "key_1234" } });
await yourTask.triggerAndWait({ payload: { foo: "bar" }, options: { idempotencyKey: "key_1234" } });
await yourTask.batchTrigger({ items: [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }] });
await yourTask.batchTriggerAndWait({ items: [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }] });
```
After:
```ts
await yourTask.trigger({ foo: "bar" }, { idempotencyKey: "key_1234" });
await yourTask.triggerAndWait({ foo: "bar" }, { idempotencyKey: "key_1234" });
await yourTask.batchTrigger([{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }]);
await yourTask.batchTriggerAndWait([{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }]);
```
We've also changed the API of the `triggerAndWait` result. Before, if the subtask that was triggered finished with an error, we would automatically "rethrow" the error in the parent task.
Now instead we're returning a `TaskRunResult` object that allows you to discriminate between successful and failed runs in the subtask:
Before:
```ts
try {
const result = await yourTask.triggerAndWait({ foo: "bar" });
// result is the output of your task
console.log("result", result);
} catch (error) {
// handle subtask errors here
}
```
After:
```ts
const result = await yourTask.triggerAndWait({ foo: "bar" });
if (result.ok) {
console.log(`Run ${result.id} succeeded with output`, result.output);
} else {
console.log(`Run ${result.id} failed with error`, result.error);
}
```
+13
View File
@@ -0,0 +1,13 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
When using idempotency keys, triggerAndWait and batchTriggerAndWait will still work even if the existing runs have already been completed (or even partially completed, in the case of batchTriggerAndWait)
- TaskRunExecutionResult.id is now the run friendlyId, not the attempt friendlyId
- A single TaskRun can now have many batchItems, in the case of batchTriggerAndWait while using idempotency keys
- A runs idempotencyKey is now added to the ctx as well as the TaskEvent and displayed in the span view
- When resolving batchTriggerAndWait, the runtimes no longer reject promises, leading to an error in the parent task
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add additional logging around cleaning up dev workers, and always kill them after 5 seconds if they haven't already exited
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Improve the display of non-object return types in the run trace viewer
+8
View File
@@ -0,0 +1,8 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
"@trigger.dev/cli": patch
---
Move to our global system from AsyncLocalStorage for the current task context storage
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Remove unimplemented batchOptions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Make optional schedule object fields nullish
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Added JSDocs to the schedule SDK types
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Use locked package versions when resolving dependencies in deployed workers
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Ensure @trigger.dev/sdk and @trigger.dev/core are always in the list of deployed dependencies
+10
View File
@@ -0,0 +1,10 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Fixes an issue that caused failed tasks when resuming after calling `triggerAndWait` or `batchTriggerAndWait` in prod/staging (this doesn't effect dev).
The version of Node.js we use for deployed workers (latest 20) would crash with an out-of-memory error when the checkpoint was restored. This crash does not happen on Node 18x or Node21x, so we've decided to upgrade the worker version to Node.js21x, to mitigate this issue.
You'll need to re-deploy to production to fix the issue.
+7
View File
@@ -0,0 +1,7 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Add graceful exit for prod workers
- Prevent overflow in long waits
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Added a new global - Task Catalog - to better handle task metadata
+8
View File
@@ -0,0 +1,8 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core-apps": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Extracting out all the non-SDK related features from the main @trigger.dev/core/v3 export
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/cli": patch
---
Add support for tasks located in subdirectories inside trigger dirs
+4 -4
View File
@@ -12,10 +12,10 @@ APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# Redis is used for concurrency control
# REDIS_HOST="localhost"
# REDIS_PORT="6379"
# REDIS_TLS_DISABLED="true"
# Redis is used for the v3 queuing and v2 concurrency control
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_TLS_DISABLED="true"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
+3 -3
View File
@@ -27,6 +27,6 @@ jobs:
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
e2e:
uses: ./.github/workflows/e2e.yml
secrets: inherit
# e2e:
# uses: ./.github/workflows/e2e.yml
# secrets: inherit
+3 -1
View File
@@ -4,13 +4,15 @@ on:
jobs:
publish:
runs-on: ubuntu-latest
env:
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: 1
outputs:
version: ${{ steps.get_version.outputs.version }}
short_sha: ${{ steps.get_commit.outputs.sha_short }}
steps:
- name: Setup Depot CLI
uses: depot/setup-action@v1
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
+4 -4
View File
@@ -49,11 +49,11 @@ jobs:
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
e2e:
uses: ./.github/workflows/e2e.yml
secrets: inherit
# e2e:
# uses: ./.github/workflows/e2e.yml
# secrets: inherit
publish:
needs: [typecheck, units, e2e]
needs: [typecheck, units]
uses: ./.github/workflows/publish-docker.yml
secrets: inherit
+4 -26
View File
@@ -5,7 +5,7 @@
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
</picture>
### The open source background jobs framework
### The open source background jobs platform
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
@@ -14,7 +14,9 @@
</div>
# About Trigger.dev
> The Trigger.dev v3 developer preview is now open. For more information and to get early access, check out our [developer preview launch post](https://trigger.dev/blog/v3-developer-preview-launch/).
## About Trigger.dev
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
@@ -48,30 +50,6 @@ View every Task in every Run so you can tell exactly what happened.
Easily integrate with hundreds of third-party APIs including your own. Use API keys (which never leave your server) or let us handle OAuth for you. Install our integration packages and easily subscribe to webhooks and perform common tasks, or you can easily use your existing favorite Node.JS SDKs and get resumability and idempotency through our `runTask` function.
## Our progress
Were building the most comprehensive and easy-to-use background jobs framework for developers.
Click the links to join the discussions about our upcoming features.
| Feature | What it does | Status |
| ------------------------------------------------------------------------------------ | --------------------------------------------------- | ------ |
| Integration kit | Official Trigger.dev integrations or build your own | ✅ |
| Self-hosting | Host the platform yourself | ✅ |
| Cloud | Just write code, no deployment required | ✅ |
| Dashboard | View every Task in every Run | ✅ |
| Serverless | Long-running Jobs on your serverless backend | ✅ |
| React hooks | Easily update your UI with Job progress | ✅ |
| React frameworks | Support for Remix, Astro, RedwoodJS & more | ✅ |
| [Background tasks](https://github.com/triggerdotdev/trigger.dev/discussions/400) | Offload long or intense Tasks to our infrastructure | 🛠️ |
| [Long-running servers](https://github.com/triggerdotdev/trigger.dev/discussions/430) | Run Jobs on your long-running backend | 🛠️ |
| Polling Triggers | Subscribe to changes without webhooks | 🕝 |
| Vercel integration | Easy deploy and preview environment support | 🕝 |
| Streaming | Receive data from your Jobs in realtime | 🕝 |
| 100+ integrations | Comprehensive support for popular APIs | 🕝 |
| [Trigger.dev Connect](https://github.com/triggerdotdev/trigger.dev/discussions/441) | Use integrations signed in as your users | 🕝 |
| File IO | Create Tasks that have file outputs | 🕝 |
# Getting started
Visit our docs [here](https://trigger.dev/docs).
+2 -2
View File
@@ -21,8 +21,8 @@
"execa": "^8.0.1",
"nanoid": "^5.0.6",
"prom-client": "^15.1.0",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4"
"socket.io": "4.7.4",
"socket.io-client": "4.7.4"
},
"devDependencies": {
"@types/node": "^18",
+19 -7
View File
@@ -8,9 +8,9 @@ import {
PlatformToCoordinatorMessages,
ProdWorkerSocketData,
ProdWorkerToCoordinatorMessages,
ZodNamespace,
ZodSocketConnection,
} from "@trigger.dev/core/v3";
import { ZodNamespace } from "@trigger.dev/core/v3/zodNamespace";
import { ZodSocketConnection } from "@trigger.dev/core/v3/zodSocket";
import { HttpReply, getTextBody, SimpleLogger } from "@trigger.dev/core-apps";
import { collectDefaultMetrics, register, Gauge } from "prom-client";
@@ -157,16 +157,18 @@ class Checkpointer {
return this.#abortControllers.has(runId);
}
cancelCheckpoint(runId: string) {
cancelCheckpoint(runId: string): boolean {
const controller = this.#abortControllers.get(runId);
if (!controller) {
logger.debug("Nothing to cancel", { runId });
return;
return false;
}
controller.abort("cancelCheckpointing()");
this.#abortControllers.delete(runId);
return true;
}
async #checkpointAndPush({
@@ -725,10 +727,18 @@ class TaskCoordinator {
checkpointable.resolve();
});
socket.on("CANCEL_CHECKPOINT", async (message) => {
socket.on("CANCEL_CHECKPOINT", async (message, callback) => {
logger.log("[CANCEL_CHECKPOINT]", message);
this.#cancelCheckpoint(socket.data.runId);
if (message.version === "v1") {
this.#cancelCheckpoint(socket.data.runId);
// v1 has no callback
return;
}
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
callback({ version: "v2", checkpointCanceled });
});
socket.on("WAIT_FOR_DURATION", async (message, callback) => {
@@ -933,7 +943,9 @@ class TaskCoordinator {
}
// Cancel checkpointing procedure
this.#checkpointer.cancelCheckpoint(runId);
const checkpointCanceled = this.#checkpointer.cancelCheckpoint(runId);
return checkpointCanceled;
}
#createHttpServer() {
+1
View File
@@ -257,6 +257,7 @@ class DockerTaskOperations implements TaskOperations {
return await execa("docker", [
"exec",
containerName,
"busybox",
"wget",
"-q",
"-O-",
+15 -4
View File
@@ -10,6 +10,7 @@ import {
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
import { randomUUID } from "crypto";
import { TaskMonitor } from "./taskMonitor";
import { PodCleaner } from "./podCleaner";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const NODE_NAME = process.env.NODE_NAME || "local";
@@ -80,8 +81,8 @@ class KubernetesTaskOperations implements TaskOperations {
],
resources: {
limits: {
cpu: "250m",
memory: "0.5G",
cpu: "1",
memory: "1G",
"ephemeral-storage": "2Gi",
},
},
@@ -133,6 +134,7 @@ class KubernetesTaskOperations implements TaskOperations {
},
spec: {
...this.#defaultPodSpec,
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
name: this.#getRunContainerName(opts.runId),
@@ -210,7 +212,8 @@ class KubernetesTaskOperations implements TaskOperations {
},
{
name: "populate-taskinfo",
image: "busybox",
image: "docker.io/library/busybox",
imagePullPolicy: "IfNotPresent",
command: ["/bin/sh", "-c"],
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
env: [
@@ -409,7 +412,7 @@ class KubernetesTaskOperations implements TaskOperations {
`for i in $(seq ${retries}); do sleep 1; busybox wget -q -O- 127.0.0.1:8000/${type}?cause=${cause} && break; done`,
];
logger.log("getLifecycleCommand()", { exec });
logger.debug("getLifecycleCommand()", { exec });
return exec;
}
@@ -541,3 +544,11 @@ const taskMonitor = new TaskMonitor({
});
taskMonitor.start();
const podCleaner = new PodCleaner({
runtimeEnv: RUNTIME_ENV,
namespace: "default",
intervalInSeconds: 300,
});
podCleaner.start();
+264
View File
@@ -0,0 +1,264 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps";
type PodCleanerOptions = {
runtimeEnv: "local" | "kubernetes";
namespace?: string;
intervalInSeconds?: number;
};
export class PodCleaner {
private enabled = false;
private namespace = "default";
private intervalInSeconds = 300;
private logger = new SimpleLogger("[PodCleaner]");
private k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
};
constructor(private opts: PodCleanerOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
if (opts.intervalInSeconds) {
this.intervalInSeconds = opts.intervalInSeconds;
}
this.k8sClient = this.#createK8sClient();
}
#createK8sClient() {
const kubeConfig = new k8s.KubeConfig();
if (this.opts.runtimeEnv === "local") {
kubeConfig.loadFromDefault();
} else if (this.opts.runtimeEnv === "kubernetes") {
kubeConfig.loadFromCluster();
} else {
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
}
return {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
kubeConfig: kubeConfig,
};
}
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
if (typeof candidate !== "object" || candidate === null) {
return false;
} else {
return true;
}
}
#logK8sError(err: unknown, debugOnly = false) {
if (debugOnly) {
this.logger.debug("K8s API Error", err);
} else {
this.logger.error("K8s API Error", err);
}
}
#handleK8sError(err: unknown) {
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
this.#logK8sError(err);
return;
}
this.#logK8sError(err, true);
if (typeof err.body.message === "string") {
this.#logK8sError({ message: err.body.message });
return;
}
this.#logK8sError({ body: err.body });
}
async #deletePods(opts: {
namespace: string;
dryRun?: boolean;
fieldSelector?: string;
labelSelector?: string;
}) {
return await this.k8sClient.core
.deleteCollectionNamespacedPod(
opts.namespace,
undefined, // pretty
undefined, // continue
opts.dryRun ? "All" : undefined,
opts.fieldSelector,
undefined, // gracePeriodSeconds
opts.labelSelector
)
.catch(this.#handleK8sError.bind(this));
}
async #deleteCompletedRuns() {
this.logger.log("Deleting completed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Succeeded",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
}
async #deleteFailedRuns() {
this.logger.log("Deleting failed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Failed",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
}
async #deleteUnrecoverableRuns() {
await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=?",
labelSelector: "app=task-run",
});
}
async start() {
this.enabled = true;
this.logger.log("Starting");
const completedInterval = setInterval(async () => {
if (!this.enabled) {
clearInterval(completedInterval);
return;
}
try {
await this.#deleteCompletedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
}, this.intervalInSeconds * 1000);
const failedInterval = setInterval(
async () => {
if (!this.enabled) {
clearInterval(failedInterval);
return;
}
try {
await this.#deleteFailedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
},
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
2 * this.intervalInSeconds * 1000
);
// this.#launchTests();
}
async stop() {
if (!this.enabled) {
return;
}
this.enabled = false;
this.logger.log("Shutting down..");
}
async #launchTests() {
const createPod = async (
container: k8s.V1Container,
name: string,
labels?: Record<string, string>
) => {
this.logger.log("Creating pod:", name);
const pod = {
metadata: {
name,
labels,
},
spec: {
restartPolicy: "Never",
automountServiceAccountToken: false,
terminationGracePeriodSeconds: 1,
containers: [container],
},
} satisfies k8s.V1Pod;
await this.k8sClient.core
.createNamespacedPod(this.namespace, pod)
.catch(this.#handleK8sError.bind(this));
};
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "sleep infinity"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "true"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "false"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
await createIdlePod("test-idle-1", { app: "task-run" });
await createFailedPod("test-failed-1", { app: "task-run" });
await createCompletedPod("test-completed-1", { app: "task-run" });
}
}
+8 -2
View File
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
export class TaskMonitor {
#enabled = false;
#logger = new SimpleLogger("[TaskMonitor]");
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
#processedPods = new Map<string, number>();
#queue = new PQueue({ concurrency: 10 });
#k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
@@ -44,6 +46,10 @@ export class TaskMonitor {
private labelSelector = "app in (task-index, task-run)";
constructor(private opts: TaskMonitorOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
this.#k8sClient = this.#createK8sClient();
this.#taskInformer = this.#createTaskInformer();
@@ -408,7 +414,7 @@ export class TaskMonitor {
const createNonZeroExitPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "busybox",
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "exit 1"],
} satisfies k8s.V1Container;
@@ -419,7 +425,7 @@ export class TaskMonitor {
const createOoDiskPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "busybox",
image: "docker.io/library/busybox",
command: ["sh"],
args: [
"-c",
+2 -1
View File
@@ -17,4 +17,5 @@ build-storybook.log
.storybook-out
storybook-static
/prisma/seed.js
/prisma/seed.js
/prisma/populate.js
@@ -0,0 +1,53 @@
export function AISparkleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.9806 0.803884C14.8871 0.33646 14.4767 0 14 0C13.5233 0 13.1129 0.33646 13.0194 0.803884L12.7809 1.99644C12.7017 2.3923 12.3923 2.70174 11.9964 2.78091L10.8039 3.01942C10.3365 3.1129 10 3.52332 10 4C10 4.47668 10.3365 4.8871 10.8039 4.98058L11.9964 5.21909C12.3923 5.29826 12.7017 5.6077 12.7809 6.00356L13.0194 7.19612C13.1129 7.66354 13.5233 8 14 8C14.4767 8 14.8871 7.66354 14.9806 7.19612L15.2191 6.00356C15.2983 5.6077 15.6077 5.29826 16.0036 5.21909L17.1961 4.98058C17.6635 4.8871 18 4.47668 18 4C18 3.52332 17.6635 3.1129 17.1961 3.01942L16.0036 2.78091C15.6077 2.70174 15.2983 2.3923 15.2191 1.99644L14.9806 0.803884Z"
fill="url(#paint0_linear_11402_36656)"
/>
<path
d="M5.94868 4.68377C5.81257 4.27543 5.43043 4 5 4C4.56957 4 4.18743 4.27543 4.05132 4.68377L3.36754 6.73509C3.26801 7.03369 3.03369 7.26801 2.73509 7.36754L0.683772 8.05132C0.27543 8.18743 0 8.56957 0 9C0 9.43043 0.27543 9.81257 0.683772 9.94868L2.73509 10.6325C3.03369 10.732 3.26801 10.9663 3.36754 11.2649L4.05132 13.3162C4.18743 13.7246 4.56957 14 5 14C5.43043 14 5.81257 13.7246 5.94868 13.3162L6.63246 11.2649C6.73199 10.9663 6.96631 10.732 7.26491 10.6325L9.31623 9.94868C9.72457 9.81257 10 9.43043 10 9C10 8.56957 9.72457 8.18743 9.31623 8.05132L7.26491 7.36754C6.96631 7.26801 6.73199 7.03369 6.63246 6.73509L5.94868 4.68377Z"
fill="url(#paint1_linear_11402_36656)"
/>
<path
d="M12.9487 12.6838C12.8126 12.2754 12.4304 12 12 12C11.5696 12 11.1874 12.2754 11.0513 12.6838L10.8675 13.2351C10.768 13.5337 10.5337 13.768 10.2351 13.8675L9.68377 14.0513C9.27543 14.1874 9 14.5696 9 15C9 15.4304 9.27543 15.8126 9.68377 15.9487L10.2351 16.1325C10.5337 16.232 10.768 16.4663 10.8675 16.7649L11.0513 17.3162C11.1874 17.7246 11.5696 18 12 18C12.4304 18 12.8126 17.7246 12.9487 17.3162L13.1325 16.7649C13.232 16.4663 13.4663 16.232 13.7649 16.1325L14.3162 15.9487C14.7246 15.8126 15 15.4304 15 15C15 14.5696 14.7246 14.1874 14.3162 14.0513L13.7649 13.8675C13.4663 13.768 13.232 13.5337 13.1325 13.2351L12.9487 12.6838Z"
fill="url(#paint2_linear_11402_36656)"
/>
<defs>
<linearGradient
id="paint0_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
<linearGradient
id="paint1_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
<linearGradient
id="paint2_linear_11402_36656"
x1="9"
y1="0"
x2="9"
y2="18"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E543FF" />
<stop offset="1" stopColor="#286399" />
</linearGradient>
</defs>
</svg>
);
}
+19 -26
View File
@@ -1,10 +1,14 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { DiscordIcon, GitHubLightIcon } from "@trigger.dev/companyicons";
import { ActivityIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { cn } from "~/utils/cn";
import { docsPath } from "~/utils/pathBuilder";
import { Button, LinkButton } from "./primitives/Buttons";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
@@ -13,20 +17,9 @@ import { Header1, Header2 } from "./primitives/Headers";
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 { Select, SelectItem } from "./primitives/Select";
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
import { cn } from "~/utils/cn";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { ActivityIcon, HeartPulseIcon } from "lucide-react";
import { docsPath } from "~/utils/pathBuilder";
type FeedbackProps = {
button: ReactNode;
@@ -78,20 +71,20 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<Fieldset className="max-w-full gap-y-3">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
<SelectGroup>
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
<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>
<Select
{...conform.select(feedbackType)}
variant="tertiary/medium"
defaultValue={defaultValue}
placeholder="Select type"
text={(value) => feedbackTypeLabel[value]}
dropdownIcon
>
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
<SelectItem key={name} value={name}>
{title}
</SelectItem>
))}
</Select>
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
@@ -206,3 +206,39 @@ export function TriggerDevStepV3() {
</ClientTabs>
);
}
export function TriggerLoginStepV3() {
return (
<ClientTabs defaultValue="npm">
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"npm"}>
<ClipboardField
variant="primary/medium"
iconButton
className="mb-4"
value={`npx trigger.dev@${v3PackageTag} login`}
/>
</ClientTabsContent>
<ClientTabsContent value={"pnpm"}>
<ClipboardField
variant="primary/medium"
iconButton
className="mb-4"
value={`pnpm dlx trigger.dev@${v3PackageTag} login`}
/>
</ClientTabsContent>
<ClientTabsContent value={"yarn"}>
<ClipboardField
variant="primary/medium"
iconButton
className="mb-4"
value={`yarn dlx trigger.dev@${v3PackageTag} login`}
/>
</ClientTabsContent>
</ClientTabs>
);
}
@@ -0,0 +1,29 @@
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
const hasAdminAccess = useHasAdminAccess();
const isImpersonating = useIsImpersonating();
if (!hasAdminAccess && !isImpersonating) {
return null;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<ShieldCheckIcon className="h-5 w-5" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1">{children}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -290,6 +290,7 @@ export function TierPro({
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
fullWidth
value={concurrentBracketCode}
variant="primary"
onChange={(v) => setConcurrentBracketCode(v)}
/>
<div className="py-6">
@@ -1,5 +1,7 @@
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
import { sortEnvironments } from "~/utils/environmentSort";
import { SimpleTooltip } from "../primitives/Tooltip";
type Environment = Pick<RuntimeEnvironment, "type">;
const variants = {
@@ -33,6 +35,74 @@ export function EnvironmentLabel({
);
}
type EnvironmentWithUsername = Environment & { userName?: string };
export function EnvironmentLabels({
environments,
size = "small",
className,
}: {
environments: EnvironmentWithUsername[];
size?: keyof typeof variants;
className?: string;
}) {
const devEnvironments = sortEnvironments(
environments.filter((env) => env.type === "DEVELOPMENT")
);
const firstDevEnvironment = devEnvironments[0];
const otherDevEnvironments = devEnvironments.slice(1);
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
return (
<div className={cn("flex items-baseline gap-2", className)}>
{firstDevEnvironment && (
<EnvironmentLabel
environment={firstDevEnvironment}
userName={firstDevEnvironment.userName}
size={size}
/>
)}
{otherDevEnvironments.length > 0 ? (
<SimpleTooltip
disableHoverableContent
button={
<span
className={cn(
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName({ type: "DEVELOPMENT" }),
environmentTextClassName({ type: "DEVELOPMENT" }),
variants[size]
)}
>
+{otherDevEnvironments.length}
</span>
}
content={
<div className="flex gap-1 py-1">
{otherDevEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
}
/>
) : null}
{otherEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
);
}
export function environmentTitle(environment: Environment, username?: string) {
switch (environment.type) {
case "PRODUCTION":
@@ -9,7 +9,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
} from "../primitives/SimpleSelect";
import { EventListSearchSchema } from "./EventStatuses";
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
@@ -41,7 +41,7 @@ export function PageBody({
<div
className={cn(
scrollable
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
: "overflow-hidden",
className
)}
@@ -3,7 +3,9 @@ import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
BellAlertIcon,
ChartBarIcon,
ClockIcon,
CursorArrowRaysIcon,
IdentificationIcon,
KeyIcon,
@@ -44,9 +46,11 @@ import {
v3ApiKeysPath,
v3DeploymentsPath,
v3EnvironmentVariablesPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3RunsPath,
v3SchedulesPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
@@ -571,6 +575,13 @@ function V3ProjectSideMenu({
to={v3TestPath(organization, project)}
data-action="test"
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
iconColor="text-sun-500"
to={v3SchedulesPath(organization, project)}
data-action="schedules"
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
@@ -592,6 +603,13 @@ function V3ProjectSideMenu({
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
<SideMenuItem
name="Project settings"
icon="settings"
@@ -0,0 +1,56 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { ReactNode } from "react";
import { cn } from "~/utils/cn";
const variants = {
"tertiary/small": {
box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600",
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
},
"minimal/small": {
box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs",
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
},
};
type Variant = keyof typeof variants;
type AppliedFilterProps = {
label: ReactNode;
value: ReactNode;
removable?: boolean;
onRemove?: () => void;
variant?: Variant;
className?: string;
};
export function AppliedFilter({
label,
value,
removable = true,
onRemove,
variant = "tertiary/small",
className,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
<div className={cn("flex items-center transition", variantClassName.box, className)}>
<div className="flex items-center gap-0.5">
<div className="text-text-dimmed">
<span>{label}</span>:
</div>
<div className="text-text-bright">
<div>{value}</div>
</div>
</div>
{removable && (
<button
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
onClick={onRemove}
>
<XMarkIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -48,11 +48,11 @@ const theme = {
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
},
secondary: {
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
button:
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
shortcut:
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
},
tertiary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
@@ -286,8 +286,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> &
React.ComponentProps<typeof ButtonContent>;
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
@@ -295,6 +294,7 @@ export const LinkButton = ({
onMouseEnter,
onMouseLeave,
download,
disabled = false,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
@@ -309,6 +309,19 @@ export const LinkButton = ({
});
}
if (disabled) {
return (
<div
className={cn(
"group pointer-events-none cursor-default opacity-40 outline-none",
props.fullWidth ? "w-full" : ""
)}
>
<ButtonContent {...props} />
</div>
);
}
if (to.toString().startsWith("http") || to.toString().startsWith("/resources")) {
return (
<ExtLink
@@ -23,7 +23,7 @@ const variants = {
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -32,7 +32,7 @@ const variants = {
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -57,7 +57,7 @@ export type CheckboxProps = Omit<
name?: string;
value?: string;
variant?: keyof typeof variants;
label?: string;
label?: React.ReactNode;
description?: string;
badges?: string[];
className?: string;
@@ -137,7 +137,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
className={cn(
inputPositionClasses,
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-700 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
)}
id={id}
ref={ref}
@@ -1,3 +1,4 @@
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
@@ -5,11 +6,25 @@ import { useDateFieldState } from "@react-stately/datepicker";
import { Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { useLocales } from "./LocaleProvider";
import { Button } from "./Buttons";
const variants = {
small: {
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "minimal/small" as const,
},
medium: {
fieldStyles: "h-7 text-base rounded px-1",
nowButtonVariant: "tertiary/medium" as const,
clearButtonVariant: "minimal/medium" as const,
},
};
type Variant = keyof typeof variants;
type DateFieldProps = {
label?: string;
label: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
@@ -20,6 +35,7 @@ type DateFieldProps = {
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
variant?: Variant;
};
export function DateField({
@@ -34,6 +50,7 @@ export function DateField({
showGuide = false,
showNowButton = false,
showClearButton = false,
variant = "small",
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
@@ -90,48 +107,50 @@ export function DateField({
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-charcoal-300">
{label}
</span>
<div className="flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-1" aria-label={label}>
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border border-charcoal-800 bg-charcoal-750 p-0.5 px-1.5 transition-colors focus-within:border-charcoal-500 hover:border-charcoal-700 focus-within:hover:border-charcoal-500",
"flex rounded-sm border bg-charcoal-700 p-0.5 transition focus-within:border-charcoal-600 hover:border-charcoal-600",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={monthSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={daySegment} state={state} />
<DateSegment segment={literalSegment(", ")} state={state} />
<DateSegment segment={hourSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={minuteSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={secondSegment} state={state} />
<DateSegment segment={literalSegment(" ")} state={state} />
<DateSegment segment={dayPeriodSegment} state={state} />
<DateSegment segment={yearSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={monthSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
<DateSegment segment={daySegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(", ")} state={state} variant={variant} />
<DateSegment segment={hourSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={minuteSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
<DateSegment segment={secondSegment} state={state} variant={variant} />
<DateSegment segment={literalSegment(" ")} state={state} variant={variant} />
<DateSegment segment={dayPeriodSegment} state={state} variant={variant} />
</div>
{showNowButton && (
<Button
variant="tertiary/small"
type="button"
variant={variants[variant].nowButtonVariant}
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
onValueChange?.(now);
}}
>
Now
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
</Button>
)}
{showClearButton && (
<Button
variant="tertiary/small"
type="button"
variant={variants[variant].clearButtonVariant}
LeadingIcon={"close"}
leadingIconClassName="-mr-2"
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
@@ -142,7 +161,9 @@ export function DateField({
state.clearSegment("minute");
state.clearSegment("second");
}}
/>
>
Clear
</Button>
)}
</div>
{showGuide && (
@@ -172,11 +193,13 @@ function utcDateToCalendarDate(date?: Date) {
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
variant: Variant;
};
function DateSegment({ segment, state }: DateSegmentProps) {
function DateSegment({ segment, state, variant }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
const sizeVariant = variants[variant];
return (
<div
@@ -186,23 +209,27 @@ function DateSegment({ segment, state }: DateSegmentProps) {
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
className={cn(
"group box-content text-center tabular-nums outline-none focus:bg-charcoal-600 focus:text-text-bright",
sizeVariant.fieldStyles,
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
}`}
)}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="block text-center italic text-charcoal-500 group-focus:text-white"
className="flex h-full items-center justify-center text-center text-charcoal-500 group-focus:text-text-bright"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? "" : 0,
height: segment.isPlaceholder ? undefined : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
<span className="flex h-full items-center justify-center">
{segment.isPlaceholder ? "" : segment.text}
</span>
</div>
);
}
@@ -231,7 +258,7 @@ function DateSegmentGuide({ segment }: { segment: DateSegment }) {
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums text-rose-500 outline-none ${
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
}`}
>
@@ -10,7 +10,7 @@ type DateTimeProps = {
export const DateTime = ({
date,
timeZone = "UTC",
timeZone,
includeSeconds = true,
includeTime = true,
}: DateTimeProps) => {
@@ -20,7 +20,7 @@ export const DateTime = ({
const initialFormattedDateTime = formatDateTime(
realDate,
timeZone,
timeZone ?? "UTC",
locales,
includeSeconds,
includeTime
@@ -32,7 +32,13 @@ export const DateTime = ({
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
formatDateTime(
realDate,
timeZone ?? resolvedOptions.timeZone,
locales,
includeSeconds,
includeTime
)
);
}, [locales, includeSeconds, realDate]);
@@ -49,12 +49,7 @@ export function DetailCell({
const variation = variations[variant];
return (
<div
className={cn(
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
className
)}
>
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
<IconInBox
icon={leadingIcon}
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
@@ -62,20 +57,14 @@ export function DetailCell({
<div className="flex flex-1 flex-col">
<Paragraph
variant={variation.label.variant}
className={cn(
"flex-1 text-left transition group-hover:text-text-bright",
variation.label.className
)}
className={cn("flex-1 text-left", variation.label.className)}
>
{label}
</Paragraph>
{description && (
<Paragraph
variant={variation.description.variant}
className={cn(
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
variation.description.className
)}
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
>
{description}
</Paragraph>
@@ -1,5 +1,9 @@
import { Paragraph } from "./Paragraph";
export function Hint({ children }: { children: React.ReactNode }) {
return <Paragraph variant="extra-small">{children}</Paragraph>;
export function Hint({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<Paragraph variant="extra-small" className={className}>
{children}
</Paragraph>
);
}
@@ -1,29 +1,41 @@
import * as React from "react";
import { cn } from "~/utils/cn";
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
const labelVariants = {
const variants = {
small: {
text: "font-sans text-sm font-normal",
text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1",
},
medium: {
text: "font-sans text-sm leading-5 font-medium",
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",
},
large: {
text: "font-sans text-base leading-6 font-medium",
text: "font-sans text-base font-medium text-text-bright leading-tight flex items-center gap-1",
},
};
type LabelProps = React.AllHTMLAttributes<HTMLLabelElement> & {
className?: string;
children: React.ReactNode;
variant?: keyof typeof labelVariants;
variant?: keyof typeof variants;
required?: boolean;
tooltip?: React.ReactNode;
};
export function Label({ className, children, variant = "medium", ...props }: LabelProps) {
const variation = labelVariants[variant];
export function Label({
className,
children,
variant = "medium",
required = true,
tooltip,
...props
}: LabelProps) {
const variation = variants[variant];
return (
<label className={cn(variation.text, className)} {...props}>
{children}
{tooltip ? <InfoIconTooltip content={tooltip} /> : null}
{!required && <span className="text-text-dimmed"> (optional)</span>}
</label>
);
}
@@ -1,16 +1,18 @@
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { LinkDisabled } from "./LinkWithDisabled";
import { cn } from "~/utils/cn";
import { ButtonContent, LinkButton } from "./Buttons";
import { ButtonContent } from "./Buttons";
import { LinkDisabled } from "./LinkWithDisabled";
export function PaginationControls({
currentPage,
totalPages,
showPageNumbers = true,
}: {
currentPage: number;
totalPages: number;
showPageNumbers?: boolean;
}) {
const location = useLocation();
if (totalPages <= 1) {
@@ -30,9 +32,11 @@ export function PaginationControls({
</ButtonContent>
</LinkDisabled>
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
{showPageNumbers
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))
: null}
<LinkDisabled
to={pageUrl(location, currentPage + 1)}
@@ -22,7 +22,7 @@ const variants = {
},
"button/small": {
button:
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -30,7 +30,7 @@ const variants = {
},
button: {
button:
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -38,7 +38,7 @@ const variants = {
},
description: {
button:
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
@@ -46,7 +46,7 @@ const variants = {
},
icon: {
button:
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
return (
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
boxClassName
)}
>
@@ -129,7 +129,7 @@ export const RadioGroupItem = React.forwardRef<
>
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
const variants = {
primary: {
base: "bg-charcoal-700",
active: "text-text-bright hover:bg-charcoal-750/50",
},
secondary: {
base: "bg-charcoal-700/50",
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
},
};
type Variants = keyof typeof variants;
type Options = {
label: string;
value: string;
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
value?: string;
defaultValue?: string;
options: Options[];
variant?: Variants;
fullWidth?: boolean;
onChange?: (value: string) => void;
};
@@ -21,11 +35,18 @@ export default function SegmentedControl({
value,
defaultValue,
options,
variant = "secondary",
fullWidth,
onChange,
}: SegmentedControlProps) {
return (
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
<div
className={cn(
"flex h-10 rounded text-text-bright",
variants[variant].base,
fullWidth ? "w-full" : "w-fit"
)}
>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
@@ -46,11 +67,11 @@ export default function SegmentedControl({
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
: "",
checked
? "text-text-bright"
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
? variants[variant].active
: "text-text-dimmed transition hover:text-text-bright"
)
}
>
@@ -60,12 +81,12 @@ export default function SegmentedControl({
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
{checked && variant === "primary" && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
></motion.div>
/>
)}
</div>
</>
+601 -120
View File
@@ -1,143 +1,624 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as Ariakit from "@ariakit/react";
import { SelectProps as AriaSelectProps } from "@ariakit/react";
import { SelectValue } from "@ariakit/react-core/select/select-value";
import { Link } from "@remix-run/react";
import * as React from "react";
import { Fragment, useMemo, useState } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { ChevronDown } from "lucide-react";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
small: {
button: "h-6 rounded text-xs px-2 ",
},
medium: {
button: "h-8 rounded text-xs px-3 text-sm",
},
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
const style = {
tertiary: {
button:
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
},
minimal: {
button:
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
},
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
const variants = {
"tertiary/small": {
button: cn(sizes.small.button, style.tertiary.button),
},
"tertiary/medium": {
button: cn(sizes.medium.button, style.tertiary.button),
},
"minimal/small": {
button: cn(sizes.small.button, style.minimal.button),
},
"minimal/medium": {
button: cn(sizes.medium.button, style.minimal.button),
},
};
type Variant = keyof typeof variants;
type Section<TItem> = {
type: "section";
title?: string;
items: TItem[];
};
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
const firstItem = data[0];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
(firstItem as Section<TItem>).type === "section" &&
(firstItem as Section<TItem>).items !== undefined &&
Array.isArray((firstItem as Section<TItem>).items)
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
export interface SelectProps<TValue extends string | string[], TItem>
extends Omit<Ariakit.SelectProps, "children"> {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
value?: Ariakit.SelectProviderProps<TValue>["value"];
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
label?: string | Ariakit.SelectLabelProps["render"];
heading?: string;
showHeading?: boolean;
items?: TItem[] | Section<TItem>[];
empty?: React.ReactNode;
filter?: (item: ItemFromSection<TItem>, search: string, title?: string) => boolean;
children:
| React.ReactNode
| ((
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: {
title?: string;
startIndex: number;
count: number;
};
}
) => React.ReactNode);
variant?: Variant;
open?: boolean;
setOpen?: (open: boolean) => void;
shortcut?: ShortcutDefinition;
allowItemShortcuts?: boolean;
clearSearchOnSelection?: boolean;
dropdownIcon?: boolean | React.ReactNode;
}
export function Select<TValue extends string | string[], TItem>({
children,
icon,
text,
placeholder,
value,
setValue,
defaultValue,
label,
heading,
showHeading = false,
items,
filter,
empty = null,
variant = "tertiary/small",
open,
setOpen,
shortcut,
allowItemShortcuts = true,
disabled,
clearSearchOnSelection = true,
dropdownIcon,
...props
}: SelectProps<TValue, TItem>) {
const [searchValue, setSearchValue] = useState("");
const searchable = items !== undefined && filter !== undefined;
const matches = useMemo(() => {
if (!items) return [];
if (!searchValue || !filter) return items;
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: section.items.filter((item) =>
filter(item as ItemFromSection<TItem>, searchValue, section.title)
),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
}, [searchValue, items]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
const select = (
<SelectProvider
open={open}
setOpen={setOpen}
virtualFocus={searchable}
value={value}
setValue={(v) => {
if (clearSearchOnSelection) {
setSearchValue("");
}
if (setValue) {
setValue(v as any);
}
}}
defaultValue={defaultValue}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
<SelectTrigger
icon={icon}
variant={variant}
text={text}
placeholder={placeholder}
shortcut={shortcut}
tooltipTitle={heading}
disabled={disabled}
dropdownIcon={dropdownIcon}
{...props}
/>
<SelectPopover>
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
<SelectList>
{typeof children === "function" ? (
matches.length > 0 ? (
isSection(matches) ? (
<SelectGroupedRenderer
items={matches}
children={children}
enableItemShortcuts={enableItemShortcuts}
/>
) : (
children(matches as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
})
)
) : (
empty
)
) : (
children
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
if (searchable) {
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
React.startTransition(() => {
setSearchValue(value);
});
}}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
{select}
</ComboboxProvider>
);
}
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
return select;
}
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
export interface SelectTriggerProps<TValue = any> extends AriaSelectProps {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
variant?: Variant;
shortcut?: ShortcutDefinition;
tooltipTitle?: string;
dropdownIcon?: boolean | React.ReactNode;
}
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
export function SelectTrigger({
icon,
variant = "tertiary/small",
text,
shortcut,
tooltipTitle,
disabled,
placeholder,
dropdownIcon = false,
children,
className,
...props
}: SelectTriggerProps) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled,
});
const showTooltip = tooltipTitle || shortcut;
const variantClasses = variants[variant];
let content: React.ReactNode = "";
if (children) {
content = children;
} else if (text !== undefined) {
if (typeof text === "function") {
content = <SelectValue>{(value) => <>{text(value) ?? placeholder}</>}</SelectValue>;
} else {
content = text;
}
} else {
content = (
<SelectValue>
{(value) => (
<>
{typeof value === "string"
? value ?? placeholder
: value.length === 0
? placeholder
: value.join(", ")}
</>
)}
</SelectValue>
);
}
return (
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
className="button"
render={
<Ariakit.Select
className={cn(
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
variantClasses.button,
className
)}
ref={ref}
{...props}
/>
}
>
<div className="flex grow items-center gap-0.5">
{icon && <div className="-ml-1 flex-none">{icon}</div>}
<div className="truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
className={cn(
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
) : !dropdownIcon ? null : (
dropdownIcon
)}
</Ariakit.TooltipAnchor>
{showTooltip && (
<Ariakit.Tooltip
disabled={shortcut === undefined}
className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs"
>
<div className="flex items-center gap-2">
<span>{tooltipTitle ?? "Open menu"}</span>
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none")}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
);
}
export interface SelectProviderProps<TValue extends string | string[]>
extends Ariakit.SelectProviderProps<TValue> {}
export function SelectProvider<TValue extends string | string[]>(
props: SelectProviderProps<TValue>
) {
return <Ariakit.SelectProvider {...props} />;
}
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
export function ComboboxProvider(props: ComboboxProviderProps) {
return <Ariakit.ComboboxProvider {...props} />;
}
function SelectGroupedRenderer<TItem>({
items,
children,
enableItemShortcuts,
}: {
items: Section<TItem>[];
children: (
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: { title?: string; startIndex: number; count: number };
}
) => React.ReactNode;
enableItemShortcuts: boolean;
}) {
let count = 0;
return (
<>
{items.map((section, index) => {
const previousItem = items.at(index - 1);
count += previousItem ? previousItem.items.length : 0;
return (
<Fragment key={index}>
{children(section.items as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
section: {
title: section.title,
startIndex: count - 1,
count: section.items.length,
},
})}
</Fragment>
);
})}
</>
);
}
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
export function SelectList(props: SelectListProps) {
const combobox = Ariakit.useComboboxContext();
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
return (
<Component
{...props}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
props.className
)}
/>
);
}
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
shortcut,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
const ref = React.useRef<HTMLDivElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled: props.disabled,
enabledOnInputElements: true,
});
return (
<Ariakit.SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(
selectItemClasses,
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
props.className
)}
ref={ref}
>
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
</Ariakit.SelectItem>
);
}
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
to: string;
}
export function SelectLinkItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
to,
...props
}: SelectLinkItemProps) {
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
onClick: React.ComponentProps<"button">["onClick"];
}
export function SelectButtonItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
onClick,
...props
}: SelectButtonItemProps) {
const render = (
<button
onClick={onClick}
className={cn("block w-full text-left", selectItemClasses, props.className)}
/>
);
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export function shortcutFromIndex(
index: number,
meta: {
shortcutsEnabled?: boolean;
section?: { startIndex: number };
}
): ShortcutDefinition | undefined {
if (!meta.shortcutsEnabled) return;
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
if (adjustedIndex > 9) return;
if (adjustedIndex === 9) {
adjustedIndex = -1;
}
return { key: String(adjustedIndex + 1) };
}
export interface SelectSeparatorProps extends React.ComponentProps<"div"> {}
export function SelectSeparator(props: SelectSeparatorProps) {
return <div {...props} className={cn("h-px bg-charcoal-700", props.className)} />;
}
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
export function SelectGroup(props: SelectGroupProps) {
return <Ariakit.SelectGroup {...props} />;
}
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
export function SelectGroupLabel(props: SelectGroupLabelProps) {
return (
<Ariakit.SelectGroupLabel
{...props}
className={cn(
"flex h-[1.375rem] items-center border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright",
props.className
)}
/>
);
}
export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
export function SelectHeading({ render, ...props }: SelectHeadingProps) {
return (
<div className="flex h-[1.375rem] flex-none cursor-default items-center gap-2 border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright">
<Ariakit.SelectHeading render={<>{render}</>} />
</div>
);
}
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
export function SelectPopover({
gutter = 5,
shift = 0,
unmountOnHide = true,
className,
...props
}: SelectPopoverProps) {
return (
<Ariakit.SelectPopover
gutter={gutter}
shift={shift}
unmountOnHide={unmountOnHide}
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(480px,var(--popover-available-height))]",
"origin-[var(--popover-transform-origin)]",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
/>
);
}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
export interface SelectLabelProps extends Ariakit.SelectLabelProps {}
//currently unstyled
export function SelectLabel(props: SelectLabelProps) {
return <Ariakit.SelectLabel {...props} />;
}
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export interface ComboBoxProps extends Ariakit.ComboboxProps {
shortcut?: ShortcutDefinition;
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
export function ComboBox({
autoSelect = true,
placeholder = "Filter options",
shortcut,
...props
}: ComboBoxProps) {
return (
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
<Ariakit.Combobox
autoSelect={autoSelect}
render={<input placeholder={placeholder} />}
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
{...props}
/>
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
);
}
@@ -2,8 +2,14 @@ import { Fragment } from "react";
import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
} from "@heroicons/react/20/solid";
const variants = {
export const variants = {
small:
"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-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
medium:
@@ -23,7 +29,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
const isMac = platform === "mac";
let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers ?? [];
const character = keyString(relevantShortcut.key, isMac);
const character = keyString(relevantShortcut.key, isMac, variant);
return (
<span className={cn(variants[variant], className)}>
@@ -35,10 +41,22 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
);
}
function keyString(key: String, isMac: boolean) {
function keyString(key: String, isMac: boolean, size: "small" | "medium") {
key = key.toLowerCase();
const className = size === "small" ? "w-2.5 h-4" : "w-3 h-5";
switch (key) {
case "enter":
return isMac ? "↵" : key;
case "arrowdown":
return <ChevronDownIcon className={className} />;
case "arrowup":
return <ChevronUpIcon className={className} />;
case "arrowleft":
return <ChevronLeftIcon className={className} />;
case "arrowright":
return <ChevronRightIcon className={className} />;
default:
return key;
}
@@ -0,0 +1,143 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -49,7 +49,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
type TableBodyProps = {
className?: string;
children: ReactNode;
children?: ReactNode;
};
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
@@ -1,6 +1,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/utils/cn";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
const variantClasses = {
basic:
@@ -83,4 +84,22 @@ function SimpleTooltip({
);
}
export function InfoIconTooltip({
content,
buttonClassName,
}: {
content: React.ReactNode;
buttonClassName?: string;
}) {
return (
<SimpleTooltip
button={
<InformationCircleIcon className={cn("h-3.5 w-3.5 text-text-dimmed", buttonClassName)} />
}
content={content}
variant="dark"
/>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
@@ -0,0 +1,103 @@
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
import { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useEvent } from "react-use";
import useLazyRef from "~/hooks/useLazyRef";
// Recharts 3.x will have portal support, but until then we're using this:
//https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873
export interface PopperPortalProps {
active?: boolean;
children: ReactNode;
}
export default function TooltipPortal({ active = true, children }: PopperPortalProps) {
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
const virtualElementRef = useLazyRef(() => new VirtualElement());
const { styles, attributes, update } = usePopper(
virtualElementRef.current,
popperElement,
POPPER_OPTIONS
);
useEffect(() => {
const el = document.createElement("div");
document.body.appendChild(el);
setPortalElement(el);
return () => el.remove();
}, []);
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
virtualElementRef.current?.update(x, y);
if (!active) return;
update?.();
});
useEffect(() => {
if (!active) return;
update?.();
}, [active, update]);
if (!portalElement) return null;
return createPortal(
<div
ref={setPopperElement}
{...attributes.popper}
style={{
...styles.popper,
zIndex: 1000,
display: active ? "block" : "none",
}}
>
{children}
</div>,
portalElement
);
}
class VirtualElement implements IVirtualElement {
private rect = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON() {
return this;
},
};
update(x: number, y: number) {
this.rect.y = y;
this.rect.top = y;
this.rect.bottom = y;
this.rect.x = x;
this.rect.left = x;
this.rect.right = x;
}
getBoundingClientRect(): DOMRect {
return this.rect;
}
}
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
placement: "right-start",
modifiers: [
{
name: "offset",
options: {
offset: [8, 8],
},
},
],
};
@@ -1,10 +1,9 @@
import { VirtualItem, Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import { MutableRefObject, RefObject, useCallback, useEffect, useReducer, useRef } from "react";
import { UnmountClosed } from "react-collapse";
import { cn } from "~/utils/cn";
import { NodeState, NodesState, reducer } from "./reducer";
import { applyFilterToState, concreteStateFromInput, selectedIdFromState } from "./utils";
import { motion } from "framer-motion";
import { concreteStateFromInput, selectedIdFromState } from "./utils";
export type TreeViewProps<TData> = {
tree: FlatTree<TData>;
@@ -104,23 +103,22 @@ export function TreeView<TData>({
if (!node) return null;
const state = nodes[node.id];
if (!state) return null;
if (!state.visible) return null;
return (
<div
key={node.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="overflow-clip [&_.ReactCollapse--collapse]:transition-all"
className="overflow-clip"
{...getNodeProps(node.id)}
>
<UnmountClosed key={node.id} isOpened={state.visible}>
{renderNode({
node,
state,
index: virtualItem.index,
virtualizer: virtualizer,
virtualItem,
})}
</UnmountClosed>
{renderNode({
node,
state,
index: virtualItem.index,
virtualizer: virtualizer,
virtualItem,
})}
</div>
);
})}
@@ -130,19 +128,23 @@ export function TreeView<TData>({
);
}
type TreeStateHookProps<TData> = {
export type Filter<TData, TFilterValue> = {
value?: TFilterValue;
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
};
type TreeStateHookProps<TData, TFilterValue> = {
tree: FlatTree<TData>;
selectedId?: string;
collapsedIds?: string[];
onSelectedIdChanged?: (selectedId: string | undefined) => void;
onCollapsedIdsChanged?: (collapsedIds: string[]) => void;
estimatedRowHeight: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
}) => number;
parentRef: RefObject<any>;
filter?: (node: FlatTreeItem<TData>) => boolean;
filter?: Filter<TData, TFilterValue>;
};
//this is so Framer Motion can be used to render the components
@@ -165,6 +167,11 @@ export type UseTreeStateOutput = {
expandNode: (id: string, scrollToNode?: boolean) => void;
collapseNode: (id: string) => void;
toggleExpandNode: (id: string, scrollToNode?: boolean) => void;
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
expandLevel: (level: number) => void;
collapseLevel: (level: number) => void;
toggleExpandLevel: (level: number) => void;
selectFirstVisibleNode: (scrollToNode?: boolean) => void;
selectLastVisibleNode: (scrollToNode?: boolean) => void;
selectNextVisibleNode: (scrollToNode?: boolean) => void;
@@ -173,24 +180,24 @@ export type UseTreeStateOutput = {
scrollToNode: (id: string) => void;
};
export function useTree<TData>({
export function useTree<TData, TFilterValue>({
tree,
selectedId,
collapsedIds,
onSelectedIdChanged,
onCollapsedIdsChanged,
parentRef,
estimatedRowHeight,
filter,
}: TreeStateHookProps<TData>): UseTreeStateOutput {
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
const previousNodeCount = useRef(tree.length);
const previousSelectedId = useRef<string | undefined>(selectedId);
const [state, dispatch] = useReducer(
reducer,
concreteStateFromInput({ tree, selectedId, collapsedIds })
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
);
//fire onSelectedIdChanged()
useEffect(() => {
const selectedId = selectedIdFromState(state.nodes);
if (selectedId !== previousSelectedId.current) {
@@ -199,12 +206,7 @@ export function useTree<TData>({
}
}, [state.changes.selectedId]);
useEffect(() => {
if (state.changes.collapsedIds) {
onCollapsedIdsChanged?.(state.changes.collapsedIds);
}
}, [state.changes.collapsedIds]);
//update tree when the number of nodes changes
useEffect(() => {
if (tree.length !== previousNodeCount.current) {
previousNodeCount.current = tree.length;
@@ -212,9 +214,25 @@ export function useTree<TData>({
}
}, [previousNodeCount.current, tree.length]);
//update the filter, if it's changed
const previousFilter = useRef(filter);
useEffect(() => {
//check if the value (not reference) of the filter is the same
const previousValue = previousFilter.current
? JSON.stringify(previousFilter.current.value)
: undefined;
const newValue = filter ? JSON.stringify(filter.value) : undefined;
previousFilter.current = filter;
if (previousValue !== newValue) {
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
}
}, [filter?.value]);
const virtualizer = useVirtualizer({
count: tree.length,
getItemKey: (index) => tree[index].id,
count: state.visibleNodeIds.length,
getItemKey: (index) => state.visibleNodeIds[index],
getScrollElement: () => parentRef.current,
estimateSize: (index: number) => {
return estimatedRowHeight({
@@ -228,7 +246,7 @@ export function useTree<TData>({
const scrollToNodeFn = useCallback(
(id: string) => {
const itemIndex = tree.findIndex((node) => node.id === id);
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
@@ -264,21 +282,21 @@ export function useTree<TData>({
const expandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, tree, scrollToNode, scrollToNodeFn } });
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const collapseNode = useCallback(
(id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id, tree } });
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
},
[state]
);
const toggleExpandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, tree, scrollToNode, scrollToNodeFn } });
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
@@ -287,7 +305,7 @@ export function useTree<TData>({
(scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { tree, scrollToNode, scrollToNodeFn },
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
@@ -297,7 +315,7 @@ export function useTree<TData>({
(scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { tree, scrollToNode, scrollToNodeFn },
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
@@ -307,7 +325,7 @@ export function useTree<TData>({
(scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { tree, scrollToNode, scrollToNodeFn },
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
@@ -317,7 +335,7 @@ export function useTree<TData>({
(scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { tree, scrollToNode, scrollToNodeFn },
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
@@ -327,12 +345,47 @@ export function useTree<TData>({
(scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { tree, scrollToNode, scrollToNodeFn },
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const expandAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const collapseAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const expandLevel = useCallback(
(level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
},
[state]
);
const collapseLevel = useCallback(
(level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
},
[state]
);
const toggleExpandLevel = useCallback(
(level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
},
[state]
);
const getTreeProps = useCallback(() => {
return {
role: "tree",
@@ -368,25 +421,48 @@ export function useTree<TData>({
}
case "Left":
case "ArrowLeft": {
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (treeNode && treeNode.hasChildren && state.nodes[selected].expanded) {
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
collapseLevel(treeNode.level);
}
break;
}
const shouldCollapse =
treeNode && treeNode.hasChildren && state.nodes[selected].expanded;
if (shouldCollapse) {
collapseNode(selected);
} else {
selectParentNode(true);
}
}
e.preventDefault();
break;
}
case "Right":
case "ArrowRight": {
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
expandLevel(treeNode.level);
}
break;
}
expandNode(selected, true);
}
e.preventDefault();
break;
}
case "Escape": {
@@ -417,7 +493,7 @@ export function useTree<TData>({
return {
selected: selectedIdFromState(state.nodes),
nodes: filter ? applyFilterToState(tree, state.nodes, filter) : state.nodes,
nodes: state.nodes,
getTreeProps,
getNodeProps,
selectNode,
@@ -427,6 +503,11 @@ export function useTree<TData>({
expandNode,
collapseNode,
toggleExpandNode,
expandAllBelowDepth,
collapseAllBelowDepth,
expandLevel,
collapseLevel,
toggleExpandLevel,
selectFirstVisibleNode,
selectLastVisibleNode,
selectNextVisibleNode,
@@ -1,5 +1,7 @@
import { FlatTree } from "./TreeView";
import assertNever from "assert-never";
import { Filter, FlatTree } from "./TreeView";
import {
applyFilterToState,
applyVisibility,
collapsedIdsFromState,
concreteStateFromInput,
@@ -18,12 +20,15 @@ export type NodeState = {
export type Changes = {
selectedId: string | undefined;
collapsedIds: string[] | undefined;
};
export type TreeState = {
tree: FlatTree<any>;
nodes: NodesState;
filteredNodes: NodesState;
changes: Changes;
filter: Filter<any, any> | undefined;
visibleNodeIds: string[];
};
export type NodesState = Record<string, NodeState>;
@@ -71,7 +76,6 @@ type ExpandNodeAction = {
type: "EXPAND_NODE";
payload: {
id: string;
tree: FlatTree<any>;
} & WithScrollToNode;
};
@@ -79,7 +83,6 @@ type CollapseNodeAction = {
type: "COLLAPSE_NODE";
payload: {
id: string;
tree: FlatTree<any>;
};
};
@@ -87,43 +90,74 @@ type ToggleExpandNodeAction = {
type: "TOGGLE_EXPAND_NODE";
payload: {
id: string;
tree: FlatTree<any>;
} & WithScrollToNode;
};
type ExpandAllBelowDepthAction = {
type: "EXPAND_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type CollapseAllBelowDepthAction = {
type: "COLLAPSE_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type ExpandLevelAction = {
type: "EXPAND_LEVEL";
payload: {
level: number;
};
};
type CollapseLevelAction = {
type: "COLLAPSE_LEVEL";
payload: {
level: number;
};
};
type ToggleExpandLevelAction = {
type: "TOGGLE_EXPAND_LEVEL";
payload: {
level: number;
};
};
type SelectFirstVisibleNodeAction = {
type: "SELECT_FIRST_VISIBLE_NODE";
payload: {
tree: FlatTree<any>;
} & WithScrollToNode;
payload: {} & WithScrollToNode;
};
type SelectLastVisibleNodeAction = {
type: "SELECT_LAST_VISIBLE_NODE";
payload: {
tree: FlatTree<any>;
} & WithScrollToNode;
payload: {} & WithScrollToNode;
};
type SelectNextVisibleNodeAction = {
type: "SELECT_NEXT_VISIBLE_NODE";
payload: {
tree: FlatTree<any>;
} & WithScrollToNode;
payload: {} & WithScrollToNode;
};
type SelectPreviousVisibleNodeAction = {
type: "SELECT_PREVIOUS_VISIBLE_NODE";
payload: {
tree: FlatTree<any>;
} & WithScrollToNode;
payload: {} & WithScrollToNode;
};
type SelectParentNodeAction = {
type: "SELECT_PARENT_NODE";
payload: {} & WithScrollToNode;
};
type UpdateFilterAction = {
type: "UPDATE_FILTER";
payload: {
tree: FlatTree<any>;
} & WithScrollToNode;
filter: Filter<any, any> | undefined;
};
};
export type Action =
@@ -135,11 +169,17 @@ export type Action =
| ExpandNodeAction
| CollapseNodeAction
| ToggleExpandNodeAction
| ExpandAllBelowDepthAction
| CollapseAllBelowDepthAction
| ExpandLevelAction
| CollapseLevelAction
| ToggleExpandLevelAction
| SelectFirstVisibleNodeAction
| SelectLastVisibleNodeAction
| SelectNextVisibleNodeAction
| SelectPreviousVisibleNodeAction
| SelectParentNodeAction;
| SelectParentNodeAction
| UpdateFilterAction;
export function reducer(state: TreeState, action: Action): TreeState {
switch (action.type) {
@@ -159,7 +199,12 @@ export function reducer(state: TreeState, action: Action): TreeState {
action.payload.scrollToNodeFn(action.payload.id);
}
return { nodes: newNodes, changes: generateChanges(state.nodes, newNodes) };
return applyFilterToState({
...state,
tree: state.tree,
nodes: newNodes,
changes: generateChanges(state.nodes, newNodes),
});
}
case "DESELECT_NODE": {
const nodes = {
@@ -167,28 +212,36 @@ export function reducer(state: TreeState, action: Action): TreeState {
[action.payload.id]: { ...state.nodes[action.payload.id], selected: false },
};
return { nodes, changes: generateChanges(state.nodes, nodes) };
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "DESELECT_ALL_NODES": {
const nodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
);
return { nodes, changes: generateChanges(state.nodes, nodes) };
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "TOGGLE_NODE_SELECTION": {
const currentlySelected = state.nodes[action.payload.id]?.selected ?? false;
if (currentlySelected) {
return reducer(state, { type: "DESELECT_NODE", payload: { id: action.payload.id } });
} else {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_NODE": {
const newNodes = {
@@ -200,37 +253,161 @@ export function reducer(state: TreeState, action: Action): TreeState {
action.payload.scrollToNodeFn(action.payload.id);
}
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_NODE": {
const visibleNodes = applyVisibility(action.payload.tree, {
const visibleNodes = applyVisibility(state.tree, {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: false },
});
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_NODE": {
const currentlyExpanded = state.nodes[action.payload.id]?.expanded ?? true;
if (currentlyExpanded) {
return reducer(state, {
type: "COLLAPSE_NODE",
payload: { id: action.payload.id, tree: action.payload.tree },
payload: { id: action.payload.id },
});
} else {
}
return reducer(state, {
type: "EXPAND_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_ALL_BELOW_DEPTH": {
const nodesToExpand = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_ALL_BELOW_DEPTH": {
const nodesToCollapse = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "EXPAND_LEVEL": {
const nodesToExpand = state.tree.filter(
(n) => n.level <= action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_LEVEL": {
const nodesToCollapse = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_LEVEL": {
//first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level
//if it is collapsed, expand all nodes at that level
const nodesAtLevel = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const firstNode = nodesAtLevel[0];
if (!firstNode) {
return state;
}
const currentlyExpanded = state.nodes[firstNode.id]?.expanded ?? true;
const currentVisible = state.nodes[firstNode.id]?.visible ?? true;
if (currentlyExpanded && currentVisible) {
return reducer(state, {
type: "EXPAND_NODE",
type: "COLLAPSE_LEVEL",
payload: {
id: action.payload.id,
tree: action.payload.tree,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
level: action.payload.level,
},
});
}
return reducer(state, {
type: "EXPAND_LEVEL",
payload: {
level: action.payload.level,
},
});
}
case "SELECT_FIRST_VISIBLE_NODE": {
const node = firstVisibleNode(action.payload.tree, state.nodes);
const node = firstVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
@@ -241,9 +418,11 @@ export function reducer(state: TreeState, action: Action): TreeState {
},
});
}
return state;
}
case "SELECT_LAST_VISIBLE_NODE": {
const node = lastVisibleNode(action.payload.tree, state.nodes);
const node = lastVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
@@ -254,6 +433,8 @@ export function reducer(state: TreeState, action: Action): TreeState {
},
});
}
return state;
}
case "SELECT_NEXT_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
@@ -261,14 +442,13 @@ export function reducer(state: TreeState, action: Action): TreeState {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
tree: action.payload.tree,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(action.payload.tree, state.nodes);
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const nextNode = visible[selectedIndex + 1];
if (nextNode) {
@@ -281,6 +461,8 @@ export function reducer(state: TreeState, action: Action): TreeState {
},
});
}
return state;
}
case "SELECT_PREVIOUS_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
@@ -289,16 +471,15 @@ export function reducer(state: TreeState, action: Action): TreeState {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
tree: action.payload.tree,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(action.payload.tree, state.nodes);
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const previousNode = visible[selectedIndex - 1];
const previousNode = visible[Math.max(0, selectedIndex - 1)];
if (previousNode) {
return reducer(state, {
type: "SELECT_NODE",
@@ -319,19 +500,18 @@ export function reducer(state: TreeState, action: Action): TreeState {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
tree: action.payload.tree,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const selectedNode = action.payload.tree.find((node) => node.id === selected);
const selectedNode = state.tree.find((node) => node.id === selected);
if (!selectedNode) {
return state;
}
const parentNode = action.payload.tree.find((node) => node.id === selectedNode.parentId);
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
if (parentNode) {
return reducer(state, {
type: "SELECT_NODE",
@@ -350,12 +530,23 @@ export function reducer(state: TreeState, action: Action): TreeState {
const selectedId = selectedIdFromState(state.nodes);
const collapsedIds = collapsedIdsFromState(state.nodes);
const newState = concreteStateFromInput({
...state,
tree: action.payload.tree,
selectedId,
collapsedIds,
});
return newState;
}
case "UPDATE_FILTER": {
const newState = applyFilterToState({
...state,
filter: action.payload.filter,
});
return newState;
}
default: {
assertNever(action);
}
}
throw new Error(`Unhandled action type: ${(action as any).type}`);
@@ -1,4 +1,4 @@
import { FlatTree, FlatTreeItem } from "./TreeView";
import { Filter, FlatTree, FlatTreeItem } from "./TreeView";
import { Changes, NodeState, NodesState, TreeState } from "./reducer";
type PartialNodeState = Record<string, Partial<NodeState>>;
@@ -8,10 +8,12 @@ const defaultExpanded = true;
export function concreteStateFromInput({
tree,
filter,
selectedId,
collapsedIds,
}: {
tree: FlatTree<any>;
filter: Filter<any, any> | undefined;
selectedId: string | undefined;
collapsedIds: string[] | undefined;
}): TreeState {
@@ -35,10 +37,15 @@ export function concreteStateFromInput({
}
}
}
const nodes = concreteStateFromPartialState(tree, state);
return {
nodes: concreteStateFromPartialState(tree, state),
changes: { selectedId, collapsedIds: [] },
tree,
nodes,
changes: { selectedId },
filter,
filteredNodes: nodes,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
@@ -82,28 +89,48 @@ export function selectedIdFromState(state: NodesState): string | undefined {
return selected?.[0];
}
export function applyFilterToState<TData>(
tree: FlatTree<TData>,
inputNodes: NodesState,
filter: (node: FlatTreeItem<TData>) => boolean
): NodesState {
export function applyFilterToState<TData>({
tree,
nodes,
filter,
visibleNodeIds,
changes,
}: TreeState): TreeState {
if (!filter || !filter.value) {
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//we need to do two passes, first collect all the nodes that are results
const newFilteredOut = new Set<string>();
for (const node of tree) {
if (!filter(node)) {
if (!filter.fn(filter.value, node)) {
newFilteredOut.add(node.id);
}
}
//nothing is filtered out
if (newFilteredOut.size === 0) {
return inputNodes;
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//copy of nodes
const nodes = { ...inputNodes };
const filteredNodes = { ...nodes };
const selected = selectedIdFromState(nodes);
const selected = selectedIdFromState(filteredNodes);
const visible = new Set<string>();
const expanded = new Set<string>();
@@ -148,28 +175,35 @@ export function applyFilterToState<TData>(
//now set the visibility and expanded state
for (const id of hidden) {
nodes[id] = { ...nodes[id], visible: false };
filteredNodes[id] = { ...filteredNodes[id], visible: false };
}
for (const id of visible) {
nodes[id] = { ...nodes[id], visible: true };
filteredNodes[id] = { ...filteredNodes[id], visible: true };
}
for (const id of collapsed) {
nodes[id] = { ...nodes[id], expanded: false };
filteredNodes[id] = { ...filteredNodes[id], expanded: false };
}
for (const id of expanded) {
nodes[id] = { ...nodes[id], expanded: true };
filteredNodes[id] = { ...filteredNodes[id], expanded: true };
}
if (selected) {
if (visible.has(selected)) {
nodes[selected] = { ...nodes[selected], selected: true };
filteredNodes[selected] = { ...filteredNodes[selected], selected: true };
} else {
nodes[selected] = { ...nodes[selected], selected: false };
filteredNodes[selected] = { ...filteredNodes[selected], selected: false };
}
}
return nodes;
return {
tree,
nodes,
filteredNodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, filteredNodes).map((node) => node.id),
};
}
export function visibleNodes(tree: FlatTree<any>, nodes: NodesState) {
@@ -215,6 +249,5 @@ export function generateChanges(a: NodesState, b: NodesState): Changes {
return {
selectedId: selectedIdA !== selectedIdB ? selectedIdB : undefined,
collapsedIds: collapsedChanges.length > 0 ? collapsedChanges : undefined,
};
}
@@ -20,7 +20,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
} from "../primitives/SimpleSelect";
import { Spinner } from "../primitives/Spinner";
import {
FilterableEnvironment,
@@ -32,6 +32,7 @@ import {
import { TimeFrameFilter } from "./TimeFrameFilter";
import { Button } from "../primitives/Buttons";
import { useCallback } from "react";
import assertNever from "assert-never";
export function RunsFilters() {
const navigate = useNavigate();
@@ -182,8 +183,7 @@ export function FilterStatusIcon({
case "FAILED":
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -205,8 +205,7 @@ export function filterStatusTitle(status: FilterableStatus): string {
case "TIMEDOUT":
return "Timed out";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -228,8 +227,7 @@ export function filterStatusClassNameColor(status: FilterableStatus): string {
case "TIMEDOUT":
return "text-amber-300";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -11,6 +11,7 @@ import type { JobRunStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { Spinner } from "../primitives/Spinner";
import { z } from "zod";
import assertNever from "assert-never";
export function RunStatus({ status }: { status: JobRunStatus }) {
return (
@@ -51,8 +52,7 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla
case "CANCELED":
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -89,8 +89,7 @@ export function runStatusTitle(status: JobRunStatus): string {
case "INVALID_PAYLOAD":
return "Invalid payload";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -123,8 +122,7 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
case "CANCELED":
return "text-charcoal-500";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -7,6 +7,7 @@ import { DateField } from "../primitives/DateField";
import { formatDateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { Label } from "../primitives/Label";
type RunTimeFrameFilterProps = {
from?: number;
@@ -190,7 +191,7 @@ const timeFrameValues = [
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
function AbsoluteTimeFrame({
export function AbsoluteTimeFrame({
from,
to,
onValueChange,
@@ -202,7 +203,8 @@ function AbsoluteTimeFrame({
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col justify-start gap-2">
<div className="flex flex-row items-center gap-1">
<div className="space-y-1">
<Label>From (UTC)</Label>
<DateField
label="From (UTC)"
defaultValue={from}
@@ -214,7 +216,8 @@ function AbsoluteTimeFrame({
showClearButton
/>
</div>
<div className="flex flex-row items-center gap-1">
<div className="space-y-1">
<Label>To (UTC)</Label>
<DateField
label="To (UTC)"
defaultValue={to}
@@ -5,6 +5,7 @@ import {
XCircleIcon,
} from "@heroicons/react/20/solid";
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -54,8 +55,7 @@ export function DeploymentStatusIcon({
/>
);
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -74,8 +74,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
case "FAILED":
return "text-error";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -97,8 +96,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus): string {
case "FAILED":
return "Failed";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -0,0 +1,20 @@
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
export function EnabledStatus({ enabled }: { enabled: boolean }) {
switch (enabled) {
case true:
return (
<div className="flex items-center gap-1 text-xs text-success">
<CheckCircleIcon className="h-4 w-4" />
Enabled
</div>
);
case false:
return (
<div className="text-dimmed flex items-center gap-1 text-xs">
<BoltSlashIcon className="h-4 w-4" />
Disabled
</div>
);
}
}
@@ -1,18 +1,14 @@
import { formatDuration } from "@trigger.dev/core/v3";
import { useState, useEffect } from "react";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
import { useEffect, useState } from "react";
export function LiveTimer({
startTime,
endTime,
updateInterval = 250,
className,
}: {
startTime: Date;
endTime?: Date;
updateInterval?: number;
className?: string;
}) {
const [now, setNow] = useState<Date>();
@@ -30,13 +26,13 @@ export function LiveTimer({
}, [startTime]);
return (
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums", className)}>
<>
{formatDuration(startTime, now, {
style: "short",
maxDecimalPoints: 0,
units: ["d", "h", "m", "s"],
})}
</Paragraph>
</>
);
}
+642 -213
View File
@@ -1,9 +1,25 @@
import { TrashIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import type { TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
import { RuntimeEnvironment, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { useCallback } from "react";
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { ReactNode, startTransition, useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
ComboboxProvider,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
Tooltip,
TooltipContent,
@@ -11,243 +27,656 @@ import {
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/Select";
import { TimeFrameFilter } from "../TimeFrameFilter";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "./TaskRunStatus";
export const allTaskRunStatuses = [
"PENDING",
"WAITING_FOR_DEPLOY",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"INTERRUPTED",
"SYSTEM_FAILURE",
"CRASHED",
] as TaskRunStatusType[];
TaskRunStatusCombo,
allTaskRunStatuses,
descriptionForTaskRunStatus,
runStatusTitle,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus);
export const TaskRunListSearchFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
environments: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
tasks: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
versions: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
environments: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
tasks: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
versions: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
statuses: z.preprocess(
(value) => (typeof value === "string" ? value.split(",") : undefined),
(value) => (typeof value === "string" ? [value] : value),
TaskAttemptStatus.array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
const All = "ALL";
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type RunFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: string[];
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
hasFilters: boolean;
};
export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
const navigate = useNavigate();
export function RunsFilters(props: RunFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environments, tasks, versions, statuses, from, to } = TaskRunListSearchFilters.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleStatusChange = useCallback((value: TaskRunAttemptStatus | typeof All) => {
handleFilterChange("statuses", value === "ALL" ? undefined : value);
}, []);
const handleTaskChange = useCallback((value: string | typeof All) => {
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const clearFilters = useCallback(() => {
searchParams.delete("statuses");
searchParams.delete("environments");
searchParams.delete("tasks");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("tasks") ||
searchParams.has("period");
return (
<div className="flex flex-row justify-between">
<SelectGroup>
<Select
name="environment"
value={environments?.at(0) ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All environments
</Paragraph>
</SelectItem>
{possibleEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="status" value={statuses?.at(0) ?? "ALL"} onValueChange={handleStatusChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select status" className="ml-2 p-0">
{statuses?.at(0) ? (
<TaskRunStatusCombo
status={statuses[0]}
className="text-xs"
iconClassName="animate-none"
/>
) : (
"All statuses"
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="overflow-visible">
<SelectItem value={"ALL"} className="">
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All statuses
</Paragraph>
</SelectItem>
{allTaskRunStatuses.map((status) => (
<TooltipProvider key={status}>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<SelectItem value={status} className="">
<TaskRunStatusCombo
status={status}
className="text-xs"
iconClassName="animate-none"
/>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForTaskRunStatus(status)}
</Paragraph>
</TooltipContent>
</SelectItem>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All tasks
</Paragraph>
</SelectItem>
{possibleTasks
.sort((a, b) => a.localeCompare(b)) // 🔤
.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={TrashIcon} />
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<AppliedFilters {...props} />
{hasFilters && (
<Form>
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
Clear all
</Button>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
];
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: RunFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedPeriodFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & RunFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allTaskRunStatuses.map((status) => ({
title: runStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForTaskRunStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Status"
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
onRemove={() => del(["statuses", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedEnvironmentFilter({
possibleEnvironments,
}: Pick<RunFiltersProps, "possibleEnvironments">) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleTasks,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ tasks: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleTasks.filter((item) => {
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleTasks]);
return (
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.slug}
value={item.slug}
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
>
{item.slug}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
const { values, del } = useSearchParams();
if (values("tasks").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Task"
value={appliedSummary(
values("tasks").map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [
{
label: "All periods",
value: "all",
},
{
label: "5 mins ago",
value: "5m",
},
{
label: "15 mins ago",
value: "15m",
},
{
label: "30 mins ago",
value: "30m",
},
{
label: "1 hour ago",
value: "1h",
},
{
label: "3 hours ago",
value: "3h",
},
{
label: "6 hours ago",
value: "6h",
},
{
label: "1 day ago",
value: "1d",
},
{
label: "3 days ago",
value: "3d",
},
{
label: "7 days ago",
value: "7d",
},
{
label: "10 days ago",
value: "10d",
},
{
label: "14 days ago",
value: "14d",
},
{
label: "30 days ago",
value: "30d",
},
];
function CreatedDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { value, replace } = useSearchParams();
const handleChange = (newValue: string) => {
clearSearchValue();
if (newValue === "all") {
if (!value) return;
}
replace({ period: newValue, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedPeriodFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
}
return values.join(", ");
}
@@ -1,6 +1,5 @@
import {
ClockIcon,
FolderIcon,
HandRaisedIcon,
InformationCircleIcon,
Squares2X2Icon,
@@ -41,6 +40,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
switch (name) {
case "task":
return <TaskIcon className={cn(className, "text-blue-500")} />;
case "scheduled":
return <ClockIcon className={cn(className, "text-sun-500")} />;
case "attempt":
return <AttemptIcon className={cn(className, "text-text-dimmed")} />;
case "wait":
@@ -0,0 +1,168 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useThrottle } from "~/hooks/useThrottle";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/SimpleSelect";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
tasks: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
environments: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
search: z.string().optional(),
});
export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
const All = "ALL";
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type ScheduleFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: string[];
};
export function ScheduleFilters({ possibleEnvironments, possibleTasks }: ScheduleFiltersProps) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environments, tasks, page, search } = ScheduleListFilters.parse(
Object.fromEntries(searchParams.entries())
);
const hasFilters =
searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search");
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("page");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleTaskChange = useCallback((value: string | typeof All) => {
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleSearchChange = useThrottle((value: string) => {
handleFilterChange("search", value.length === 0 ? undefined : value);
}, 300);
const clearFilters = useCallback(() => {
searchParams.delete("page");
searchParams.delete("enabled");
searchParams.delete("tasks");
searchParams.delete("environments");
searchParams.delete("search");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex w-full flex-row">
<Input
name="search"
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
icon="search"
variant="tertiary"
className="grow"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<SelectGroup>
<Select
name="environment"
value={environments?.at(0) ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="minimal" width="full">
<SelectValue
placeholder={"Select environment"}
className="ml-2 whitespace-nowrap p-0"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All environments
</Paragraph>
</SelectItem>
{possibleEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All tasks
</Paragraph>
</SelectItem>
{possibleTasks.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
{hasFilters && (
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon}>
Clear all
</Button>
)}
</div>
);
}
@@ -73,14 +73,7 @@ export function SpanCodePathAccessory({
>
{accessory.items.map((item, index) => (
<Fragment key={index}>
<span
className={cn(
"truncate",
index === accessory.items.length - 1 ? "text-sun-100" : "text-text-dimmed"
)}
>
{item.text}
</span>
<span className={cn("truncate", "text-text-dimmed")}>{item.text}</span>
{index < accessory.items.length - 1 && (
<span className="text-text-dimmed">
<ChevronRightIcon className="h-4 w-4" />
@@ -27,7 +27,7 @@ type TaskFunctionNameProps = {
export function TaskFunctionName({ variant, functionName, className }: TaskFunctionNameProps) {
return (
<InlineCode variant={variant} className={cn("text-sun-100", className)}>
<InlineCode variant={variant} className={cn("text-text-dimmed", className)}>
{`${functionName}()`}
</InlineCode>
);
@@ -8,6 +8,7 @@ import {
} from "@heroicons/react/20/solid";
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
import { TaskRunAttemptStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { SnowflakeIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -72,8 +73,7 @@ export function TaskRunAttemptStatusIcon({
case "COMPLETED":
return <CheckCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -99,8 +99,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus
case "COMPLETED":
return "text-success";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -126,8 +125,7 @@ export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null):
case "COMPLETED":
return "Completed";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -10,10 +10,25 @@ import {
XCircleIcon,
} from "@heroicons/react/20/solid";
import { TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { SnowflakeIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allTaskRunStatuses = [
"WAITING_FOR_DEPLOY",
"PENDING",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"CRASHED",
"INTERRUPTED",
"SYSTEM_FAILURE",
] as TaskRunStatus[];
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
PENDING: "Task is waiting to be executed",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
@@ -29,6 +44,23 @@ const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
CRASHED: "Task has crashed and won't be retried",
};
export const QUEUED_STATUSES: TaskRunStatus[] = ["PENDING", "WAITING_FOR_DEPLOY"];
export const RUNNING_STATUSES: TaskRunStatus[] = [
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
];
export const FINISHED_STATUSES: TaskRunStatus[] = [
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"INTERRUPTED",
"SYSTEM_FAILURE",
"CRASHED",
];
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
return taskRunStatusDescriptions[status];
}
@@ -88,8 +120,7 @@ export function TaskRunStatusIcon({
return <FireIcon className={cn(runStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -120,8 +151,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
case "CRASHED":
return "text-error";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -153,8 +183,7 @@ export function runStatusTitle(status: TaskRunStatus): string {
case "CRASHED":
return "Crashed";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
assertNever(status);
}
}
}
@@ -1,10 +1,15 @@
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { StopIcon } from "@heroicons/react/24/outline";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { User } from "@trigger.dev/database";
import { useLocation } from "@remix-run/react";
import { formatDuration } from "@trigger.dev/core/v3";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { useEnvironments } from "~/hooks/useEnvironments";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server";
import { docsPath, v3RunPath, v3TestPath } from "~/utils/pathBuilder";
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
@@ -14,21 +19,15 @@ import {
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "../../primitives/Table";
import { formatDuration } from "@trigger.dev/core/v3";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { useEnvironments } from "~/hooks/useEnvironments";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { CancelRunDialog } from "./CancelRunDialog";
import { useLocation } from "@remix-run/react";
import { ReplayRunDialog } from "./ReplayRunDialog";
import { TaskRunStatusCombo } from "./TaskRunStatus";
import { LiveTimer } from "./LiveTimer";
type RunsTableProps = {
total: number;
@@ -37,7 +36,6 @@ type RunsTableProps = {
showJob?: boolean;
runs: RunListItem[];
isLoading?: boolean;
currentUser: User;
};
export function TaskRunsTable({
@@ -46,7 +44,6 @@ export function TaskRunsTable({
filters,
runs,
isLoading = false,
currentUser,
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
@@ -78,16 +75,17 @@ export function TaskRunsTable({
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run) => {
const path = v3RunPath(organization, project, run);
const usernameForEnv =
currentUser.id !== run.environment.userId ? run.environment.userName : undefined;
const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId });
return (
<TableRow key={run.id}>
<TableCell to={path}>#{run.number}</TableCell>
<TableCell to={path}>{run.taskIdentifier}</TableCell>
<TableCell to={path}>{run.version ?? ""}</TableCell>
<TableCell to={path}>
<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />
<EnvironmentLabel
environment={run.environment}
userName={run.environment.userName}
/>
</TableCell>
<TableCell to={path}>
<TaskRunStatusCombo status={run.status} />
@@ -96,9 +94,15 @@ export function TaskRunsTable({
{run.startedAt ? <DateTime date={run.startedAt} /> : ""}
</TableCell>
<TableCell to={path}>
{formatDuration(run.startedAt, run.completedAt, {
style: "short",
})}
{run.startedAt && run.finishedAt ? (
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
style: "short",
})
) : run.startedAt ? (
<LiveTimer startTime={new Date(run.startedAt)} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{run.isTest ? (
@@ -197,7 +201,12 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
{environment ? (
<>
{" "}
in <EnvironmentLabel environment={environment} size="large" />
in{" "}
<EnvironmentLabel
environment={environment}
userName={environment.userName}
size="large"
/>
</>
) : null}
</Paragraph>

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