Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe4b5409a | |||
| 223768a025 | |||
| 1fd26ff98b | |||
| 8d34c63c2d | |||
| ff04c986a1 | |||
| 7d6430dc46 | |||
| b289aa31da | |||
| 0508de6258 | |||
| 56a063e586 | |||
| d490bc5cbf | |||
| 6d9dfbc75d | |||
| 6406924b02 | |||
| 2156e15266 | |||
| 49b4d47726 | |||
| 2e4f5d893c | |||
| 203e002087 | |||
| 1670c4c41d | |||
| 9d6c424236 | |||
| cffc88aeac | |||
| ae13757703 | |||
| 0ee39e6101 | |||
| bce398b892 | |||
| e667028d4a | |||
| ae99807047 | |||
| bf4e505c70 | |||
| c8538c236c | |||
| ff6e2461d5 | |||
| 3ed016bc2b | |||
| 68dd3804a7 | |||
| 95f0c7b780 | |||
| 339c39d739 | |||
| bc0ca01319 | |||
| c37c822314 | |||
| 2dfe191c55 | |||
| e337b21650 | |||
| 9e5382951b | |||
| ed03f4bc15 | |||
| 9feb0f70b0 | |||
| 25de9e59af | |||
| 83dc871550 | |||
| 62700245a3 | |||
| 6ce820cb45 | |||
| 0f0a6884e8 | |||
| 7ff8f0ebab | |||
| 68455c796a | |||
| b0a2c42e0e | |||
| cac3c32f6a | |||
| ed8d24fd3d | |||
| d0ef36260a | |||
| 8eb68dd852 | |||
| a42037da03 | |||
| 43bc7ed94e | |||
| 4fdb7f8288 | |||
| 37b9b056c4 | |||
| 801c86bf73 | |||
| a1de11a001 | |||
| 29e9e372ee | |||
| affc128161 | |||
| 40ba8ad0ee | |||
| 96168eb383 | |||
| 2c68473cc8 | |||
| 469c8a2532 | |||
| ebeb790522 | |||
| e765240370 | |||
| 38770a91c7 | |||
| de188a22c5 | |||
| fae93ac747 | |||
| c3f6557eb6 | |||
| 9491a1649c | |||
| 0a5aa2dc15 | |||
| 4a1a5b2e4a | |||
| 503882762c | |||
| e3db257397 | |||
| de1cc868e3 | |||
| ff7fa9e19a | |||
| ba4f04db37 | |||
| d7911892ee | |||
| f888f85a09 | |||
| e9a63a4868 | |||
| 93ecbc5e05 | |||
| 60a8a5777b | |||
| ed1cd16753 | |||
| 6bb4dd7b6f | |||
| 0b5a0be807 | |||
| 74d1e61e42 | |||
| 12c83a56af | |||
| 75a54540a4 | |||
| b68012f81c | |||
| fb83d58703 | |||
| 52b2a8289c | |||
| 1e93ec4216 | |||
| cb27b7278a | |||
| e85fc501a6 | |||
| 0bfeb0816f | |||
| b207601732 | |||
| 3913e57ef4 | |||
| 26f310397a | |||
| 0a845767a0 | |||
| ed2a26c865 |
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3 CLI update command and package manager detection fix
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Hoist uncaughtException handler to the top of workers to better report error messages
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/shopify": patch
|
||||
---
|
||||
|
||||
improved error messages when a shopify webhook fails to register
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix error stack traces
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Implement task.onSuccess/onFailure and config.onSuccess/onFailure
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Strip out server-only package from worker builds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Remove the env var check during deploy (too many false negatives)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix issues with consecutive waits
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Adding some additional telemetry during deploy to help debug issues
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add the "log" level back in as an alias to "info"
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue that was treating v2 trigger directories as v3
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add configure function to be able to configure the SDK manually
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix dev CLI output when not printing update messages
|
||||
+30
-1
@@ -44,14 +44,25 @@
|
||||
"@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",
|
||||
@@ -60,22 +71,36 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -86,11 +111,15 @@
|
||||
"swift-dragons-peel",
|
||||
"tall-bees-wave",
|
||||
"tame-guests-know",
|
||||
"tender-moose-tell",
|
||||
"tender-oranges-rhyme",
|
||||
"thin-parents-heal",
|
||||
"tidy-balloons-suffer",
|
||||
"tidy-dryers-sleep",
|
||||
"tiny-doors-type",
|
||||
"tiny-elephants-scream",
|
||||
"tricky-bulldogs-heal"
|
||||
"tricky-bulldogs-heal",
|
||||
"tricky-ladybugs-unite",
|
||||
"two-pumas-wait"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add typescript as a dependency so the esbuild-decorator will work even when running in npx
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add git to prod worker image which fixes private package installs
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Lock SDK and CLI deps on exact core version
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve the display of non-object return types in the run trace viewer
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Use locked package versions when resolving dependencies in deployed workers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Ensure @trigger.dev/sdk and @trigger.dev/core are always in the list of deployed dependencies
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/cli": patch
|
||||
---
|
||||
|
||||
Add support for tasks located in subdirectories inside trigger dirs
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
We’re 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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -80,8 +80,8 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
],
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: "250m",
|
||||
memory: "0.5G",
|
||||
cpu: "1",
|
||||
memory: "1G",
|
||||
"ephemeral-storage": "2Gi",
|
||||
},
|
||||
},
|
||||
@@ -211,7 +211,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: [
|
||||
|
||||
@@ -408,7 +408,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 +419,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",
|
||||
|
||||
@@ -17,4 +17,5 @@ build-storybook.log
|
||||
.storybook-out
|
||||
storybook-static
|
||||
|
||||
/prisma/seed.js
|
||||
/prisma/seed.js
|
||||
/prisma/populate.js
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { XMarkIcon } 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={XMarkIcon} />
|
||||
<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,9 +1,11 @@
|
||||
import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
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";
|
||||
@@ -14,10 +16,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../primitives/Select";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
} from "../../primitives/SimpleSelect";
|
||||
|
||||
export const ScheduleListFilters = z.object({
|
||||
page: z.coerce.number().default(1),
|
||||
@@ -53,6 +52,9 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
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);
|
||||
@@ -146,7 +148,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
@@ -156,7 +158,11 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,20 @@ 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",
|
||||
@@ -30,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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,23 +70,21 @@ export { Prisma };
|
||||
|
||||
export const prisma = singleton("prisma", getClient);
|
||||
|
||||
export const $replica: Omit<PrismaClient, "$transaction"> = singleton(
|
||||
"replica",
|
||||
() => getReplicaClient() ?? prisma
|
||||
);
|
||||
|
||||
function getClient() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||
|
||||
const databaseUrl = new URL(DATABASE_URL);
|
||||
const databaseUrl = extendQueryParams(DATABASE_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
// We need to add the connection_limit and pool_timeout query params to the url, in a way that works if the DATABASE_URL already has query params
|
||||
const query = databaseUrl.searchParams;
|
||||
query.set("connection_limit", env.DATABASE_CONNECTION_LIMIT.toString());
|
||||
query.set("pool_timeout", env.DATABASE_POOL_TIMEOUT.toString());
|
||||
databaseUrl.search = query.toString();
|
||||
|
||||
// Remove the username:password in the url and print that to the console
|
||||
const urlWithoutCredentials = new URL(databaseUrl.href);
|
||||
urlWithoutCredentials.password = "";
|
||||
|
||||
console.log(`🔌 setting up prisma client to ${urlWithoutCredentials.toString()}`);
|
||||
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
|
||||
|
||||
const client = new PrismaClient({
|
||||
datasources: {
|
||||
@@ -134,6 +132,68 @@ function getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
function getReplicaClient() {
|
||||
if (!env.DATABASE_READ_REPLICA_URL) {
|
||||
console.log(`🔌 No database replica, using the regular client`);
|
||||
return;
|
||||
}
|
||||
|
||||
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
|
||||
|
||||
const replicaClient = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: replicaUrl.href,
|
||||
},
|
||||
},
|
||||
log: [
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "error",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "info",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "warn",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// connect eagerly
|
||||
replicaClient.$connect();
|
||||
|
||||
console.log(`🔌 read replica connected`);
|
||||
|
||||
return replicaClient;
|
||||
}
|
||||
|
||||
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
|
||||
const url = new URL(hrefOrUrl);
|
||||
const query = url.searchParams;
|
||||
|
||||
for (const [key, val] of Object.entries(queryParams)) {
|
||||
query.set(key, val);
|
||||
}
|
||||
|
||||
url.search = query.toString();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function redactUrlSecrets(hrefOrUrl: string | URL) {
|
||||
const url = new URL(hrefOrUrl);
|
||||
url.password = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export type { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
export const PrismaErrorSchema = z.object({
|
||||
|
||||
@@ -19,6 +19,7 @@ const EnvironmentSchema = z.object({
|
||||
isValidDatabaseUrl,
|
||||
"DIRECT_URL is invalid, for details please check the additional output above this message."
|
||||
),
|
||||
DATABASE_READ_REPLICA_URL: z.string().optional(),
|
||||
SESSION_SECRET: z.string(),
|
||||
MAGIC_LINK_SECRET: z.string(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
@@ -103,6 +104,7 @@ const EnvironmentSchema = z.object({
|
||||
COORDINATOR_SECRET: z.string().default("coordinator-secret"),
|
||||
DEPOT_TOKEN: z.string().optional(),
|
||||
DEPOT_PROJECT_ID: z.string().optional(),
|
||||
DEPOT_ORG_ID: z.string().optional(),
|
||||
CONTAINER_REGISTRY_ORIGIN: z.string().optional(),
|
||||
CONTAINER_REGISTRY_USERNAME: z.string().optional(),
|
||||
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
//a function that you call with a debounce delay, the function will only be called after the delay has passed
|
||||
/**
|
||||
* A function that you call with a debounce delay, the function will only be called after the delay has passed
|
||||
*
|
||||
* @param fn The function to debounce
|
||||
* @param delay In ms
|
||||
*/
|
||||
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
|
||||
@@ -10,20 +10,3 @@ export function useEnvironments(matches?: UIMatch[]) {
|
||||
|
||||
return project.environments;
|
||||
}
|
||||
|
||||
export function useDevEnvironment(matches?: UIMatch[]) {
|
||||
const user = useUser();
|
||||
const environments = useEnvironments(matches);
|
||||
if (!environments) return;
|
||||
|
||||
return environments.find(
|
||||
(environment) => environment.type === "DEVELOPMENT" && environment.userId === user.id
|
||||
);
|
||||
}
|
||||
|
||||
export function useProdEnvironment(matches?: UIMatch[]) {
|
||||
const environments = useEnvironments(matches);
|
||||
if (!environments) return;
|
||||
|
||||
return environments.find((environment) => environment.type === "PRODUCTION");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTextFilter } from "./useTextFilter";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
exportName: string;
|
||||
filePath: string;
|
||||
triggerSource: string;
|
||||
};
|
||||
|
||||
export function useFilterTasks<T extends Task>({ tasks }: { tasks: T[] }) {
|
||||
return useTextFilter<T>({
|
||||
items: tasks,
|
||||
filter: (task, text) => {
|
||||
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.id.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useRef, MutableRefObject } from "react";
|
||||
|
||||
const useLazyRef = <T>(initialValFunc: () => T) => {
|
||||
const ref: MutableRefObject<T | null> = useRef(null);
|
||||
if (ref.current === null) {
|
||||
ref.current = initialValFunc();
|
||||
}
|
||||
return ref;
|
||||
};
|
||||
|
||||
export default useLazyRef;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Reducer, useReducer } from "react";
|
||||
|
||||
export type ListState<T> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type AppendAction<T> = {
|
||||
type: "append";
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type UpdateAction<T> = {
|
||||
type: "update";
|
||||
index: number;
|
||||
item: T;
|
||||
};
|
||||
|
||||
type DeleteAction<T> = {
|
||||
type: "delete";
|
||||
index: number;
|
||||
};
|
||||
|
||||
type InsertAfter<T> = {
|
||||
type: "insertAfter";
|
||||
index: number;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type Action<T> = AppendAction<T> | UpdateAction<T> | DeleteAction<T> | InsertAfter<T>;
|
||||
|
||||
function reducer<T>(state: ListState<T>, action: Action<T>): ListState<T> {
|
||||
switch (action.type) {
|
||||
case "append":
|
||||
return { items: [...state.items, ...action.items] };
|
||||
case "update":
|
||||
return {
|
||||
items: state.items.map((v, i) => (i === action.index ? action.item : v)),
|
||||
};
|
||||
case "delete":
|
||||
return { items: state.items.filter((_, i) => i !== action.index) };
|
||||
case "insertAfter":
|
||||
return {
|
||||
items: [
|
||||
...state.items.slice(0, action.index + 1),
|
||||
...action.items,
|
||||
...state.items.slice(action.index + 1),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type HookReturn<T> = {
|
||||
items: T[];
|
||||
append: (items: T[]) => void;
|
||||
update: (index: number, item: T) => void;
|
||||
delete: (index: number) => void;
|
||||
insertAfter: (index: number, items: T[]) => void;
|
||||
};
|
||||
|
||||
export function useList<T>(initialItems: T[]): HookReturn<T> {
|
||||
const [state, dispatch] = useReducer<Reducer<ListState<T>, Action<T>>>(reducer, {
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
return {
|
||||
items: state.items,
|
||||
append: (items: T[]) => dispatch({ type: "append", items }),
|
||||
update: (index: number, item: T) => dispatch({ type: "update", index, item }),
|
||||
delete: (index: number) => dispatch({ type: "delete", index }),
|
||||
insertAfter: (index: number, items: T[]) => dispatch({ type: "insertAfter", index, items }),
|
||||
};
|
||||
}
|
||||
@@ -53,3 +53,11 @@ export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean {
|
||||
export const useOrganizationChanged = (action: (org: MatchedOrganization | undefined) => void) => {
|
||||
useChanged(useOptionalOrganization, action);
|
||||
};
|
||||
|
||||
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.isImpersonating === true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
import type { Location } from "@remix-run/react";
|
||||
|
||||
export function useReplaceLocation() {
|
||||
const optimisticLocation = useOptimisticLocation();
|
||||
const [location, setLocation] = useState(optimisticLocation);
|
||||
|
||||
const replaceLocation = useCallback((location: Location<any>) => {
|
||||
const fullPath = location.pathname + location.search + location.hash;
|
||||
//replace the URL in the browser
|
||||
history.replaceState(null, "", fullPath);
|
||||
//update the state (new object in case the same location ref was modified)
|
||||
const newLocation = { ...location };
|
||||
setLocation(newLocation);
|
||||
}, []);
|
||||
|
||||
const replaceSearchParam = useCallback(
|
||||
(key: string, value?: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (value) {
|
||||
searchParams.set(key, value);
|
||||
} else {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
replaceLocation({ ...optimisticLocation, search: "?" + searchParams.toString() });
|
||||
},
|
||||
[optimisticLocation, replaceLocation]
|
||||
);
|
||||
|
||||
return { location, replaceLocation, replaceSearchParam };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type Values = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function useSearchParams() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
|
||||
const set = useCallback(
|
||||
(values: Values) => {
|
||||
for (const [param, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
search.delete(param);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
search.set(param, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
search.delete(param);
|
||||
for (const v of value) {
|
||||
search.append(param, v);
|
||||
}
|
||||
}
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const replace = useCallback(
|
||||
(values: Values) => {
|
||||
set(values);
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
(keys: string | string[]) => {
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
for (const key of keys) {
|
||||
search.delete(key);
|
||||
}
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const value = useCallback(
|
||||
(param: string) => {
|
||||
return search.get(param) ?? undefined;
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const values = useCallback(
|
||||
(param: string) => {
|
||||
return search.getAll(param);
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
set,
|
||||
replace,
|
||||
del,
|
||||
};
|
||||
}
|
||||
@@ -17,16 +17,22 @@ export type ShortcutDefinition =
|
||||
| Shortcut;
|
||||
|
||||
type useShortcutKeysProps = {
|
||||
shortcut: ShortcutDefinition;
|
||||
shortcut: ShortcutDefinition | undefined;
|
||||
action: (event: KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
enabledOnInputElements?: boolean;
|
||||
};
|
||||
|
||||
export function useShortcutKeys({ shortcut, action, disabled = false }: useShortcutKeysProps) {
|
||||
export function useShortcutKeys({
|
||||
shortcut,
|
||||
action,
|
||||
disabled = false,
|
||||
enabledOnInputElements,
|
||||
}: useShortcutKeysProps) {
|
||||
const { platform } = useOperatingSystem();
|
||||
const isMac = platform === "mac";
|
||||
const relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
const relevantShortcut =
|
||||
shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
|
||||
const keys = createKeysFromShortcut(relevantShortcut);
|
||||
useHotkeys(
|
||||
@@ -36,13 +42,17 @@ export function useShortcutKeys({ shortcut, action, disabled = false }: useShort
|
||||
},
|
||||
{
|
||||
enabled: !disabled,
|
||||
enableOnFormTags: relevantShortcut.enabledOnInputElements,
|
||||
enableOnContentEditable: relevantShortcut.enabledOnInputElements,
|
||||
enableOnFormTags: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
|
||||
enableOnContentEditable: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createKeysFromShortcut(shortcut: Shortcut) {
|
||||
function createKeysFromShortcut(shortcut: Shortcut | undefined) {
|
||||
if (!shortcut) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modifiers = shortcut.modifiers;
|
||||
const character = shortcut.key;
|
||||
|
||||
|
||||
@@ -26,3 +26,9 @@ export function useUser(matches?: UIMatch[]): User {
|
||||
export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||
useChanged(useOptionalUser, callback);
|
||||
}
|
||||
|
||||
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||
const user = useOptionalUser(matches);
|
||||
|
||||
return Boolean(user?.admin);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,12 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export type ToastMessage = {
|
||||
|
||||
@@ -87,8 +87,8 @@ export async function createOrganization(
|
||||
}
|
||||
|
||||
export async function createEnvironment(
|
||||
organization: Organization,
|
||||
project: Project,
|
||||
organization: Pick<Organization, "id">,
|
||||
project: Pick<Project, "id">,
|
||||
type: RuntimeEnvironment["type"],
|
||||
member?: OrgMember,
|
||||
prismaClient: PrismaClientOrTransaction = prisma
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function createProject(
|
||||
// Create the dev and prod environments
|
||||
await createEnvironment(organization, project, "PRODUCTION");
|
||||
|
||||
if (project.version === "V2") {
|
||||
if (version === "v2") {
|
||||
await createEnvironment(organization, project, "STAGING");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { Prisma, RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
export type { RuntimeEnvironment };
|
||||
|
||||
@@ -118,3 +119,36 @@ export async function disconnectSession(environmentId: string) {
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
type: true;
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
displayName: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export function displayableEnvironments(
|
||||
environment: DisplayableInputEnvironment,
|
||||
userId: string | undefined
|
||||
) {
|
||||
return {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userName: environment.orgMember
|
||||
? environment.orgMember.user.id === userId
|
||||
? undefined
|
||||
: getUsername(environment.orgMember.user)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
|
||||
export class ProjectPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -49,7 +51,13 @@ export class ProjectPresenter {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKey: true,
|
||||
@@ -76,13 +84,12 @@ export class ProjectPresenter {
|
||||
hasInactiveExternalTriggers: project._count.sources > 0,
|
||||
jobCount: project._count.jobs,
|
||||
httpEndpointCount: project._count.httpEndpoints,
|
||||
environments: project.environments.map((environment) => ({
|
||||
id: environment.id,
|
||||
slug: environment.slug,
|
||||
type: environment.type,
|
||||
apiKey: environment.apiKey,
|
||||
userId: environment.orgMember?.userId,
|
||||
})),
|
||||
environments: sortEnvironments(
|
||||
project.environments.map((environment) => ({
|
||||
...displayableEnvironments(environment, userId),
|
||||
userId: environment.orgMember?.user.id,
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export class ApiKeysPresenter {
|
||||
environmentVariableCount: environment._count.environmentVariableValues,
|
||||
}))
|
||||
),
|
||||
hasStaging: environments.some((environment) => environment.type === "STAGING"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { AttemptStatus, RetrieveRunResponse, RunStatus, logger } from "@trigger.dev/core/v3";
|
||||
import { TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment,
|
||||
showSecretDetails: boolean
|
||||
): Promise<RetrieveRunResponse | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._prisma.taskRun.findUnique({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
lockedToVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
logger.debug("Task run not found", { friendlyId, envId: env.id });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskRun.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(taskRun.status),
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
idempotencyKey: taskRun.idempotencyKey ?? undefined,
|
||||
version: taskRun.lockedToVersion ? taskRun.lockedToVersion.version : undefined,
|
||||
createdAt: taskRun.createdAt ?? undefined,
|
||||
updatedAt: taskRun.updatedAt ?? undefined,
|
||||
attempts: !showSecretDetails
|
||||
? []
|
||||
: taskRun.attempts.map((a) => ({
|
||||
id: a.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
|
||||
createdAt: a.createdAt ?? undefined,
|
||||
updatedAt: a.updatedAt ?? undefined,
|
||||
startedAt: a.startedAt ?? undefined,
|
||||
completedAt: a.completedAt ?? undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static apiStatusFromRunStatus(status: TaskRunStatus): RunStatus {
|
||||
switch (status) {
|
||||
case "WAITING_FOR_DEPLOY":
|
||||
case "PENDING": {
|
||||
return "PENDING";
|
||||
}
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
case "EXECUTING": {
|
||||
return "EXECUTING";
|
||||
}
|
||||
case "WAITING_TO_RESUME":
|
||||
case "PAUSED": {
|
||||
return "PAUSED";
|
||||
}
|
||||
case "CANCELED": {
|
||||
return "CANCELED";
|
||||
}
|
||||
case "COMPLETED_SUCCESSFULLY": {
|
||||
return "COMPLETED";
|
||||
}
|
||||
case "SYSTEM_FAILURE":
|
||||
case "INTERRUPTED":
|
||||
case "CRASHED":
|
||||
case "COMPLETED_WITH_ERRORS": {
|
||||
return "FAILED";
|
||||
}
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static apiStatusFromAttemptStatus(status: TaskRunAttemptStatus): AttemptStatus {
|
||||
switch (status) {
|
||||
case "PENDING": {
|
||||
return "PENDING";
|
||||
}
|
||||
case "PAUSED": {
|
||||
return "PAUSED";
|
||||
}
|
||||
case "EXECUTING": {
|
||||
return "EXECUTING";
|
||||
}
|
||||
case "COMPLETED": {
|
||||
return "COMPLETED";
|
||||
}
|
||||
case "FAILED": {
|
||||
return "FAILED";
|
||||
}
|
||||
case "CANCELED": {
|
||||
return "CANCELED";
|
||||
}
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
TaskMetadataFailedToParseData,
|
||||
groupTaskMetadataIssuesByTask,
|
||||
} from "@trigger.dev/core/v3";
|
||||
@@ -39,6 +40,7 @@ export class DeploymentPresenter {
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
@@ -65,6 +67,9 @@ export class DeploymentPresenter {
|
||||
shortCode: true,
|
||||
version: true,
|
||||
errorData: true,
|
||||
imageReference: true,
|
||||
externalBuildData: true,
|
||||
projectId: true,
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -103,6 +108,7 @@ export class DeploymentPresenter {
|
||||
exportName: "asc",
|
||||
},
|
||||
},
|
||||
sdkVersion: true,
|
||||
},
|
||||
},
|
||||
triggeredBy: {
|
||||
@@ -116,6 +122,10 @@ export class DeploymentPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
@@ -135,6 +145,12 @@ export class DeploymentPresenter {
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
errorData: this.#prepareErrorData(deployment.errorData),
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
projectId: deployment.projectId,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type RunListOptions = {
|
||||
userId?: string;
|
||||
projectSlug: string;
|
||||
//filters
|
||||
tasks?: string[];
|
||||
@@ -12,6 +16,7 @@ type RunListOptions = {
|
||||
statuses?: TaskRunStatus[];
|
||||
environments?: string[];
|
||||
scheduleId?: string;
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
@@ -26,20 +31,16 @@ export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
|
||||
export type RunListItem = RunList["runs"][0];
|
||||
export type RunListAppliedFilters = RunList["filters"];
|
||||
|
||||
export class RunListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
export class RunListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
scheduleId,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -49,15 +50,16 @@ export class RunListPresenter {
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
|
||||
const hasFilters =
|
||||
tasks !== undefined ||
|
||||
versions !== undefined ||
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
environments !== undefined ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
@@ -85,15 +87,17 @@ export class RunListPresenter {
|
||||
});
|
||||
|
||||
//get all possible tasks
|
||||
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
|
||||
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
let runs = await this.#prismaClient.$queryRaw<
|
||||
let runs = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
number: BigInt;
|
||||
@@ -104,9 +108,9 @@ export class RunListPresenter {
|
||||
status: TaskRunStatus;
|
||||
createdAt: Date;
|
||||
lockedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
isTest: boolean;
|
||||
attempts: BigInt;
|
||||
spanId: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -119,19 +123,13 @@ export class RunListPresenter {
|
||||
tr.status AS status,
|
||||
tr."createdAt" AS "createdAt",
|
||||
tr."lockedAt" AS "lockedAt",
|
||||
tra."completedAt" AS "completedAt",
|
||||
tr."updatedAt" AS "updatedAt",
|
||||
tr."isTest" AS "isTest",
|
||||
COUNT(tra.id) AS attempts
|
||||
tr."spanId" AS "spanId"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
|
||||
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
|
||||
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
|
||||
WHERE
|
||||
-- project
|
||||
tr."projectId" = ${project.id}
|
||||
@@ -149,21 +147,22 @@ export class RunListPresenter {
|
||||
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${hasStatusFilters ? Prisma.sql`AND (` : Prisma.empty}
|
||||
${
|
||||
statuses && statuses.length > 0
|
||||
? Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
|
||||
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
|
||||
: Prisma.empty
|
||||
}
|
||||
${statuses && statuses.length > 0 && hasStatusFilters ? Prisma.sql` OR ` : Prisma.empty}
|
||||
${hasStatusFilters ? Prisma.sql`tr.status IS NULL` : Prisma.empty}
|
||||
${hasStatusFilters ? Prisma.sql`) ` : Prisma.empty}
|
||||
${
|
||||
environments && environments.length > 0
|
||||
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
@@ -174,8 +173,6 @@ export class RunListPresenter {
|
||||
? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
GROUP BY
|
||||
tr."friendlyId", tr."taskIdentifier", tr."runtimeEnvironmentId", tr.id, bw.version, tra.status, tr."createdAt", tra."startedAt", tra."completedAt"
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
@@ -214,33 +211,35 @@ export class RunListPresenter {
|
||||
throw new Error(`Environment not found for TaskRun ${run.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = FINISHED_STATUSES.includes(run.status);
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
friendlyId: run.runFriendlyId,
|
||||
number: Number(run.number),
|
||||
createdAt: run.createdAt,
|
||||
startedAt: run.lockedAt,
|
||||
completedAt: run.completedAt,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
startedAt: run.lockedAt ? run.lockedAt.toISOString() : undefined,
|
||||
hasFinished,
|
||||
finishedAt: hasFinished ? run.updatedAt.toISOString() : undefined,
|
||||
isTest: run.isTest,
|
||||
status: run.status,
|
||||
version: run.version,
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
attempts: Number(run.attempts),
|
||||
spanId: run.spanId,
|
||||
isReplayable: true,
|
||||
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
|
||||
environment: {
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userId: environment.orgMember?.user.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
environment: displayableEnvironments(environment, userId),
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
possibleTasks: possibleTasks.map((task) => task.slug),
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
|
||||
.sort((a, b) => {
|
||||
return a.slug.localeCompare(b.slug);
|
||||
}),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
|
||||
@@ -38,6 +38,7 @@ export class RunPresenter {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
organizationId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
@@ -66,9 +67,13 @@ export class RunPresenter {
|
||||
if (!traceSummary) {
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
@@ -118,9 +123,13 @@ export class RunPresenter {
|
||||
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
|
||||
@@ -42,7 +42,7 @@ export class SpanPresenter {
|
||||
const output =
|
||||
span.outputType === "application/store"
|
||||
? `/resources/packets/${span.environmentId}/${span.output}`
|
||||
: typeof span.output !== "undefined" && span.output !== null
|
||||
: typeof span.output !== "undefined"
|
||||
? await prettyPrintPacket(span.output, span.outputType ?? undefined)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { Prisma, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import {
|
||||
Prisma,
|
||||
RuntimeEnvironmentType,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type Task = Awaited<ReturnType<TaskListPresenter["call"]>>[0];
|
||||
export type Task = {
|
||||
slug: string;
|
||||
exportName: string;
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
environments: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class TaskListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
type Return = Awaited<ReturnType<TaskListPresenter["call"]>>;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
export type TaskActivity = Awaited<Return["activity"]>[string];
|
||||
|
||||
export class TaskListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
@@ -23,7 +41,7 @@ export class TaskListPresenter {
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
@@ -53,7 +71,7 @@ export class TaskListPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = await this.#prismaClient.$queryRaw<
|
||||
const tasks = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -64,73 +82,217 @@ export class TaskListPresenter {
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
>`
|
||||
SELECT DISTINCT ON(bwt.slug, bwt."runtimeEnvironmentId")
|
||||
bwt.slug,
|
||||
bwt.id,
|
||||
bwt."exportName",
|
||||
bwt."filePath",
|
||||
bwt."runtimeEnvironmentId",
|
||||
bwt."createdAt",
|
||||
bwt."triggerSource"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
WHERE bwt."projectId" = ${project.id}
|
||||
ORDER BY
|
||||
bwt.slug,
|
||||
bwt."runtimeEnvironmentId",
|
||||
bwt."createdAt" DESC;`;
|
||||
WITH workers AS (
|
||||
SELECT DISTINCT ON ("runtimeEnvironmentId") id, "runtimeEnvironmentId", version
|
||||
FROM ${sqlDatabaseSchema}."BackgroundWorker"
|
||||
WHERE "runtimeEnvironmentId" IN (${Prisma.join(project.environments.map((e) => e.id))})
|
||||
ORDER BY "runtimeEnvironmentId", "createdAt" DESC
|
||||
)
|
||||
SELECT tasks.id, slug, "filePath", "exportName", "triggerSource", tasks."runtimeEnvironmentId", tasks."createdAt"
|
||||
FROM workers
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" tasks ON tasks."workerId" = workers.id
|
||||
ORDER BY slug ASC;`;
|
||||
|
||||
let latestRuns = [] as {
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
lockedById: string;
|
||||
}[];
|
||||
|
||||
if (tasks.length > 0) {
|
||||
latestRuns = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
lockedById: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
"createdAt",
|
||||
"status",
|
||||
"lockedById",
|
||||
ROW_NUMBER() OVER (PARTITION BY "lockedById" ORDER BY "updatedAt" DESC) AS rn
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun"
|
||||
WHERE
|
||||
"lockedById" IN(${Prisma.join(tasks.map((t) => t.id))})
|
||||
) t
|
||||
WHERE rn = 1;`;
|
||||
}
|
||||
|
||||
return tasks.map((task) => {
|
||||
const latestRun = latestRuns.find((r) => r.lockedById === task.id);
|
||||
//group by the task identifier (task.slug). Add the latestRun and add all the environments.
|
||||
const outputTasks = tasks.reduce((acc, task) => {
|
||||
const environment = project.environments.find((env) => env.id === task.runtimeEnvironmentId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for TaskRun ${task.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userId: environment.orgMember?.user.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
latestRun: latestRun
|
||||
? {
|
||||
createdAt: latestRun.createdAt,
|
||||
status: latestRun.status,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
let existingTask = acc.find((t) => t.slug === task.slug);
|
||||
|
||||
if (!existingTask) {
|
||||
existingTask = {
|
||||
...task,
|
||||
environments: [],
|
||||
};
|
||||
acc.push(existingTask);
|
||||
}
|
||||
|
||||
existingTask.environments.push(displayableEnvironments(environment, userId));
|
||||
|
||||
//order the environments
|
||||
existingTask.environments = sortEnvironments(existingTask.environments);
|
||||
|
||||
return acc;
|
||||
}, [] as Task[]);
|
||||
|
||||
//then get the activity for each task
|
||||
const activity = this.#getActivity(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
);
|
||||
|
||||
const runningStats = this.#getRunningStats(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
);
|
||||
|
||||
const durations = this.#getAverageDurations(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
);
|
||||
|
||||
const userEnvironment = project.environments.find((e) => e.orgMember?.user.id === userId);
|
||||
const userHasTasks = userEnvironment
|
||||
? outputTasks.some((t) => t.environments.some((e) => e.id === userEnvironment.id))
|
||||
: false;
|
||||
|
||||
return { tasks: outputTasks, userHasTasks, activity, runningStats, durations };
|
||||
}
|
||||
|
||||
async #getActivity(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activity = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
status: TaskRunStatus;
|
||||
day: Date;
|
||||
count: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
DATE(tr."createdAt") as day,
|
||||
COUNT(*)
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."createdAt" >= (current_date - interval '6 days')
|
||||
GROUP BY
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
day
|
||||
ORDER BY
|
||||
tr."taskIdentifier" ASC,
|
||||
day ASC,
|
||||
tr."status" ASC;`;
|
||||
|
||||
//today with no time
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
return activity.reduce((acc, a) => {
|
||||
let existingTask = acc[a.taskIdentifier];
|
||||
|
||||
if (!existingTask) {
|
||||
existingTask = [];
|
||||
//populate the array with the past 7 days
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const day = new Date(today);
|
||||
day.setUTCDate(today.getDate() - i);
|
||||
day.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
existingTask.push({
|
||||
day: day.toISOString(),
|
||||
[TaskRunStatus.COMPLETED_SUCCESSFULLY]: 0,
|
||||
} as { day: string } & Record<TaskRunStatus, number>);
|
||||
}
|
||||
|
||||
acc[a.taskIdentifier] = existingTask;
|
||||
}
|
||||
|
||||
const dayString = a.day.toISOString();
|
||||
const day = existingTask.find((d) => d.day === dayString);
|
||||
|
||||
if (!day) {
|
||||
logger.warn(`Day not found for TaskRun`, {
|
||||
day: dayString,
|
||||
taskIdentifier: a.taskIdentifier,
|
||||
existingTask,
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
day[a.status] = Number(a.count);
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, ({ day: string } & Record<TaskRunStatus, number>)[]>);
|
||||
}
|
||||
|
||||
async #getRunningStats(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const statuses = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
status: TaskRunStatus;
|
||||
count: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
COUNT(*)
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."status" IN ('PENDING', 'WAITING_FOR_DEPLOY', 'EXECUTING', 'RETRYING_AFTER_FAILURE', 'WAITING_TO_RESUME')
|
||||
GROUP BY
|
||||
tr."taskIdentifier",
|
||||
tr."status"
|
||||
ORDER BY
|
||||
tr."taskIdentifier" ASC,
|
||||
tr."status" ASC;`;
|
||||
|
||||
return statuses.reduce((acc, a) => {
|
||||
let existingTask = acc[a.taskIdentifier];
|
||||
|
||||
if (!existingTask) {
|
||||
existingTask = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
};
|
||||
|
||||
acc[a.taskIdentifier] = existingTask;
|
||||
}
|
||||
|
||||
if (QUEUED_STATUSES.includes(a.status)) {
|
||||
existingTask.queued += Number(a.count);
|
||||
}
|
||||
if (RUNNING_STATUSES.includes(a.status)) {
|
||||
existingTask.running += Number(a.count);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, { queued: number; running: number }>);
|
||||
}
|
||||
|
||||
async #getAverageDurations(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const durations = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
duration: Number;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - tr."lockedAt"))) as duration
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."createdAt" >= (current_date - interval '6 days')
|
||||
AND tr."status" IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS')
|
||||
GROUP BY
|
||||
tr."taskIdentifier";`;
|
||||
|
||||
return Object.fromEntries(durations.map((s) => [s.taskIdentifier, Number(s.duration)]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,17 +63,18 @@ export class TestPresenter {
|
||||
const searchParams = createSearchParams(url, TestSearchParams);
|
||||
|
||||
//no environmentId
|
||||
if (!searchParams.success || !searchParams.params.get("environment")) {
|
||||
if (!searchParams.success) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
environments,
|
||||
};
|
||||
}
|
||||
|
||||
//default to dev environment
|
||||
const environment = searchParams.params.get("environment") ?? "dev";
|
||||
|
||||
//is the environmentId valid?
|
||||
const matchingEnvironment = project.environments.find(
|
||||
(env) => env.slug === searchParams.params.get("environment")
|
||||
);
|
||||
const matchingEnvironment = project.environments.find((env) => env.slug === environment);
|
||||
if (!matchingEnvironment) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
@@ -101,7 +102,7 @@ export class TestPresenter {
|
||||
WHERE "runtimeEnvironmentId" = ${matchingEnvironment.id}
|
||||
),
|
||||
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
|
||||
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId"
|
||||
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId", bwt."triggerSource"
|
||||
FROM latest_workers
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
|
||||
ORDER BY bwt."exportName" ASC;
|
||||
|
||||
@@ -171,17 +171,23 @@ export class TestTaskPresenter {
|
||||
return {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await getScheduleTaskRunPayload(r),
|
||||
};
|
||||
})
|
||||
),
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -189,6 +195,6 @@ export class TestTaskPresenter {
|
||||
|
||||
async function getScheduleTaskRunPayload(run: RawRun) {
|
||||
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
|
||||
const parsed = ScheduledTaskPayload.parse(payload);
|
||||
const parsed = ScheduledTaskPayload.safeParse(payload);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Span, SpanKind } from "@opentelemetry/api";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { $replica, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { attributesFromAuthenticatedEnv, tracer } from "../../v3/tracer.server";
|
||||
|
||||
export abstract class BasePresenter {
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {}
|
||||
constructor(
|
||||
protected readonly _prisma: PrismaClientOrTransaction = prisma,
|
||||
protected readonly _replica: PrismaClientOrTransaction = $replica
|
||||
) {}
|
||||
|
||||
protected async traceWithEnv<T>(
|
||||
trace: string,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
} from "~/components/primitives/SimpleSelect";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
|
||||
type FirstEndpointSheetProps = {
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ export default function Page() {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{job.hasIntegrationsRequiringAction && (
|
||||
<Callout variant="error" to={organizationIntegrationsPath(organization)} className="mb-2">
|
||||
{simplur`This Job has ${
|
||||
@@ -96,6 +96,6 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</Help>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
} from "~/components/primitives/SimpleSelect";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { runStatusClassNameColor, runStatusTitle } from "~/components/runs/RunStatuses";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ export default function Page() {
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
This change is irreversible, so please be certain. Type in the Project slug{" "}
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
|
||||
+3
-5
@@ -1,5 +1,4 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AstroLogo } from "~/assets/logos/AstroLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -11,18 +10,17 @@ import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
export default function SetUpAstro() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
invariant(devEnvironment, "Dev environment must be defined");
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
return (
|
||||
@@ -67,7 +65,7 @@ export default function SetUpAstro() {
|
||||
title="Run the CLI 'init' command in an existing Astro project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
<InitCommand appOrigin={appOrigin} apiKey={apiKey} />
|
||||
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very simple
|
||||
|
||||
+3
-7
@@ -1,5 +1,4 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ExpressLogo } from "~/assets/logos/ExpressLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -11,20 +10,17 @@ import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
invariant(devEnvironment, "Dev environment must be defined");
|
||||
const appOrigin = useAppOrigin();
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
@@ -73,7 +69,7 @@ export default function Page() {
|
||||
<ClipboardField
|
||||
secure
|
||||
className="w-fit"
|
||||
value={devEnvironment.apiKey}
|
||||
value={apiKey}
|
||||
variant={"secondary/medium"}
|
||||
icon={<Badge variant="outline">Server</Badge>}
|
||||
/>
|
||||
|
||||
+3
-6
@@ -1,5 +1,4 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { NestjsLogo } from "~/assets/logos/NestjsLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -11,12 +10,12 @@ import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { CodeBlock } from "../../components/code/CodeBlock";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
const AppModuleCode = `
|
||||
import { Module } from '@nestjs/common';
|
||||
@@ -114,11 +113,9 @@ export default function SetupNestJS() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
invariant(devEnvironment, "devEnvironment is required");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
<div className="mb-12 grid place-items-center">
|
||||
@@ -161,7 +158,7 @@ export default function SetupNestJS() {
|
||||
<CodeBlock
|
||||
fileName=".env"
|
||||
showChrome
|
||||
code={`TRIGGER_API_KEY=${devEnvironment.apiKey}\nTRIGGER_API_URL=${appOrigin}`}
|
||||
code={`TRIGGER_API_KEY=${apiKey}\nTRIGGER_API_URL=${appOrigin}`}
|
||||
/>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Add the TriggerDevModule" />
|
||||
|
||||
+4
-7
@@ -1,6 +1,5 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import { useState } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { NextjsLogo } from "~/assets/logos/NextjsLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -21,11 +20,11 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
type SelectionChoices = "use-existing-project" | "create-new-next-app";
|
||||
|
||||
@@ -33,12 +32,10 @@ export default function SetupNextjs() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
const appOrigin = useAppOrigin();
|
||||
const [selectedValue, setSelectedValue] = useState<SelectionChoices | null>(null);
|
||||
|
||||
invariant(devEnvironment, "devEnvironment is required");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
<div className="mb-12 grid place-items-center">
|
||||
@@ -152,7 +149,7 @@ export default function SetupNextjs() {
|
||||
title="Run the CLI 'init' command in your new Next.js project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
<InitCommand appOrigin={appOrigin} apiKey={apiKey} />
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very
|
||||
simple example Job in <InlineCode variant="extra-small">examples.ts</InlineCode>{" "}
|
||||
@@ -179,7 +176,7 @@ export default function SetupNextjs() {
|
||||
title="Run the CLI 'init' command in an existing Next.js project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
<InitCommand appOrigin={appOrigin} apiKey={apiKey} />
|
||||
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very
|
||||
|
||||
+3
-5
@@ -1,5 +1,4 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { RemixLogo } from "~/assets/logos/RemixLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommand, RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -11,18 +10,17 @@ import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
export default function SetUpRemix() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
invariant(devEnvironment, "Dev environment must be defined");
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
return (
|
||||
@@ -67,7 +65,7 @@ export default function SetUpRemix() {
|
||||
title="Run the CLI 'init' command in an existing Remix project"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<InitCommand appOrigin={appOrigin} apiKey={devEnvironment.apiKey} />
|
||||
<InitCommand appOrigin={appOrigin} apiKey={apiKey} />
|
||||
|
||||
<Paragraph spacing variant="small">
|
||||
You’ll notice a new folder in your project called 'jobs'. We’ve added a very simple
|
||||
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
import { ChatBubbleLeftRightIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SvelteKitLogo } from "~/assets/logos/SveltekitLogo";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { RunDevCommand, TriggerDevStep } from "~/components/SetupCommands";
|
||||
@@ -11,18 +10,18 @@ import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useProjectSetupComplete } from "~/hooks/useProjectSetupComplete";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { useV2OnboardingApiKey } from "../_app.orgs.$organizationSlug.projects.$projectParam.setup/route";
|
||||
|
||||
export default function SetUpSveltekit() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
useProjectSetupComplete();
|
||||
const devEnvironment = useDevEnvironment();
|
||||
invariant(devEnvironment, "Dev environment must be defined");
|
||||
const { apiKey } = useV2OnboardingApiKey();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
<div className="mb-12 grid place-items-center">
|
||||
@@ -70,7 +69,7 @@ export default function SetUpSveltekit() {
|
||||
<ClipboardField
|
||||
secure
|
||||
className="w-fit"
|
||||
value={devEnvironment.apiKey}
|
||||
value={apiKey}
|
||||
variant={"secondary/medium"}
|
||||
icon={<Badge variant="outline">Server</Badge>}
|
||||
/>
|
||||
|
||||
+44
@@ -1,4 +1,48 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
project: {
|
||||
slug: projectParam,
|
||||
},
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
apiKey: environment.apiKey,
|
||||
});
|
||||
};
|
||||
|
||||
export function useV2OnboardingApiKey() {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.setup",
|
||||
});
|
||||
if (!routeMatch) {
|
||||
throw new Error("Route match not found");
|
||||
}
|
||||
|
||||
return routeMatch;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user