Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc8c8edf04 | |||
| 41927edf12 | |||
| 1afba46cf0 | |||
| 32e4fc6310 | |||
| 0add95e224 | |||
| de7f133ef9 | |||
| 8b13681afc | |||
| 88baac2144 | |||
| df664ad472 | |||
| d8c53784f7 | |||
| 3c7b2de095 | |||
| ae9249405c | |||
| d63f4e2451 | |||
| f4275e5054 | |||
| 690b308fea | |||
| a74716a1b9 | |||
| 42185bc36b | |||
| 276181b91c | |||
| 578d2e5410 | |||
| 329944841f | |||
| 4a0f030ebf | |||
| 13345d0025 | |||
| d905059915 | |||
| 0d8530028f | |||
| c37e7f3030 | |||
| 6d400c3b57 | |||
| 2b8a44aed1 | |||
| 6d842b3abb | |||
| 9c6838e5c8 | |||
| 10e935b864 | |||
| 02e453c3b5 | |||
| 330ddb3311 | |||
| 14a2f4cc94 | |||
| 7e57f1f3dd | |||
| cf8f994604 | |||
| 2ae5178479 | |||
| 620b83832b | |||
| c7434125e4 | |||
| 67b5365d50 | |||
| ea25c37a10 | |||
| c1710ae72d | |||
| 9c5a7cf073 | |||
| 8eedf994af | |||
| 2c91c492df | |||
| b859abdb66 | |||
| 0876b87526 | |||
| 9c4be40adc | |||
| 2635f4bd10 | |||
| 401b7ee03d | |||
| 6881de84ab | |||
| 0ca8296b9a | |||
| 55946fcdb7 | |||
| c0f9f10b07 | |||
| e80aa2826d | |||
| 1be25d7639 | |||
| 3c3a5e1f28 | |||
| 8e741c7218 | |||
| 83455e3ad4 | |||
| f0f0420280 | |||
| c4533c36cb | |||
| f8e034c190 | |||
| 21f62e780c | |||
| c3564ca0d4 | |||
| 6e1b8a11d4 | |||
| 9900f19b70 | |||
| 237545754f | |||
| d7c1e98e5e | |||
| 71a0afeb8d | |||
| 36a390449a | |||
| 375e495423 | |||
| 866c20cd39 | |||
| 408785b8ef | |||
| f982e00997 | |||
| 7e100b8362 | |||
| ea052c44c7 | |||
| 76224568f4 | |||
| 72e67981b0 | |||
| cc44aff24b | |||
| 07d28ee4f5 | |||
| f16a2cc4e6 | |||
| b859837564 | |||
| 1343003efa | |||
| 0c4aea0338 | |||
| 627c767c64 | |||
| f3a0c3a5d1 | |||
| 7bca3d9c15 | |||
| 01fc1c6634 | |||
| 2d4f85e9f4 | |||
| 8718b28949 | |||
| 2caa2a5bbd | |||
| 43a10ee695 | |||
| 018005d478 | |||
| 4da187c4c5 | |||
| 4925424778 | |||
| 7ef61222ed | |||
| 044d38e390 | |||
| e7768881af | |||
| 5ea6a49d08 | |||
| dce5578327 | |||
| fde3f17a25 | |||
| a6c9cac26c | |||
| 960d99461a | |||
| e5307133b3 | |||
| 34bb3acc23 | |||
| 6d3b761cd7 | |||
| abc9737a4f | |||
| 84af64165c | |||
| 0adf41c7f0 | |||
| b7f4e40edb | |||
| cf33396dc0 | |||
| 51f2cd6b3f | |||
| 513d0f071d | |||
| 50e3192453 | |||
| 1524c95a7a | |||
| c8aaea8ad0 | |||
| cf7f25abcd | |||
| 99453e8581 | |||
| bc7206d981 | |||
| 64b8e150ea | |||
| b3b5852a88 | |||
| d1ecd6b99c | |||
| 0fb16a7324 | |||
| 4f317055fd | |||
| 3afd5f8cf3 | |||
| c922d24941 | |||
| 39ec34f84f | |||
| b9eba68078 | |||
| 9df93d0798 | |||
| 6769d6b439 | |||
| 70410d33af | |||
| 6371dd4f95 | |||
| ebe78017ef | |||
| b697a19954 | |||
| 29ca09e42d | |||
| 375b788ab0 | |||
| d6b44d13f7 | |||
| c6784ba7b8 | |||
| 6b8b1c7cae | |||
| 0c20756624 | |||
| 3d387d602d | |||
| 5d45dea0e1 | |||
| a25751e805 | |||
| 659be8cbc1 | |||
| 50e3d9e43a | |||
| 203a431350 | |||
| 39a332eff1 | |||
| 498f56de3e | |||
| b23696b255 | |||
| f3a7e2aa20 | |||
| b737f7af92 | |||
| f54a9ff5fc | |||
| 067c0db543 | |||
| 773765f82d | |||
| 624072c23b | |||
| d37576f814 | |||
| 476e2f035f | |||
| ad71964d30 | |||
| 975c5f1d3c | |||
| 916ed4e366 | |||
| df4667b7f5 | |||
| d663ba8985 | |||
| 8c8d1598e3 | |||
| 646bfd37bd | |||
| c0d5292b06 | |||
| 8c383bdc69 | |||
| 97e63d1222 | |||
| 87ee98aff1 | |||
| c070e78d06 | |||
| 1c4f9ff1ff | |||
| ca031a72e7 | |||
| cbc976188e | |||
| 59a94c710e | |||
| 4cd97c81ea | |||
| d0d5a698ec | |||
| ee033a1fd4 | |||
| 2394fcdf35 | |||
| 6edaffeb7e | |||
| 0558b2c595 | |||
| b12bc6042f | |||
| a7bc5df754 | |||
| 1c524c5acc | |||
| 65ad98c20d | |||
| 24d892e322 | |||
| be9b51131d | |||
| 63a65dabe4 | |||
| 1f3b423c22 | |||
| a69a5019f1 | |||
| 5910339bf4 | |||
| 4b641b25a6 | |||
| a847b492bc | |||
| 0df6410561 | |||
| c17faabe9f | |||
| 9a187f9eda | |||
| 2e9452ab7c | |||
| 914745f642 | |||
| 9189bdf50f | |||
| 3e5a97c4b5 | |||
| c0aa6633d2 | |||
| 4e35871861 |
@@ -13,6 +13,8 @@ APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
name: "🧪 E2E Tests"
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
e2e:
|
||||
name: "🧪 E2E Tests"
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
# Setup environment variables
|
||||
cp ./.env.example ./.env
|
||||
cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local
|
||||
|
||||
# Build packages
|
||||
pnpm run build --filter @references/nextjs-test^...
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Execute tests
|
||||
pnpm run docker
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run test:e2e
|
||||
|
||||
# Cleanup
|
||||
pnpm run docker:stop
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,31 @@
|
||||
name: 🤖 PR Checks
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
secrets: inherit
|
||||
|
||||
units:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
@@ -38,125 +38,19 @@ env:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: ʦ TypeScript
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
secrets: inherit
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck --filter webapp
|
||||
|
||||
unitTests:
|
||||
name: Unit Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
pnpm run test
|
||||
units:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: e2e Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
# Setup environment variables
|
||||
cp ./.env.example ./.env
|
||||
cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local
|
||||
|
||||
# Build packages
|
||||
pnpm run build --filter @references/nextjs-test^...
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Execute tests
|
||||
pnpm run docker
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run test:e2e
|
||||
|
||||
# Cleanup
|
||||
pnpm run docker:stop
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
needs: [typecheck, unitTests, e2e]
|
||||
needs: [typecheck, units, e2e]
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
@@ -4,16 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- "packages/**"
|
||||
- "!packages/**/*.md"
|
||||
- ".changeset/**"
|
||||
- "integrations/**"
|
||||
- "!integrations/**/*.md"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: "ʦ TypeScript"
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck --filter webapp
|
||||
@@ -0,0 +1,30 @@
|
||||
name: "🧪 Unit Tests"
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
unitTests:
|
||||
name: "🧪 Unit Tests"
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.18
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
pnpm run test
|
||||
@@ -52,3 +52,5 @@ apps/**/public/build
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.cosine
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"astro-build.astro-vscode",
|
||||
"denoland.vscode-deno"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"deno.enablePaths": ["references/deno-reference"],
|
||||
"debug.toolBarLocation": "commandCenter"
|
||||
}
|
||||
+23
-4
@@ -32,7 +32,26 @@ Please follow the best-practice of adding changesets in the same commit as the c
|
||||
|
||||
!MAKE SURE TO UPDATE THE TAG IN THE INSTRUCTIONS BELOW!
|
||||
|
||||
1. Add changesets as usual `pnpm run changeset:add`
|
||||
2. Create a snapshot version (replace "dev" with your tag) `pnpm exec changeset version --snapshot dev`
|
||||
3. Build the packages: `pnpm run build --filter "@trigger.dev/*"`
|
||||
4. Publish the snapshot (replace "dev" with your tag) `pnpm exec changeset publish --no-git-tag --snapshot --tag dev`
|
||||
1. Add changesets as usual
|
||||
|
||||
```sh
|
||||
pnpm run changeset:add
|
||||
```
|
||||
|
||||
2. Create a snapshot version (replace "prerelease" with your tag)
|
||||
|
||||
```sh
|
||||
pnpm exec changeset version --snapshot prerelease
|
||||
```
|
||||
|
||||
3. Build the packages:
|
||||
|
||||
```sh
|
||||
pnpm run build --filter "@trigger.dev/*"
|
||||
```
|
||||
|
||||
4. Publish the snapshot (replace "dev" with your tag)
|
||||
|
||||
```sh
|
||||
pnpm exec changeset publish --no-git-tag --snapshot --tag prerelease
|
||||
```
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct that could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
|
||||
@@ -52,9 +52,13 @@ branch are tagged into a release monthly.
|
||||
Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method.
|
||||
|
||||
6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
|
||||
|
||||
```
|
||||
pnpm run docker
|
||||
```
|
||||
|
||||
This will also start and run a local instance of [pgAdmin](https://www.pgadmin.org/) on [localhost:5480](http://localhost:5480), preconfigured with email `admin@example.com` and pwd `admin`. Then use `postgres` as the password to the Trigger.dev server.
|
||||
|
||||
7. Migrate the database
|
||||
```
|
||||
pnpm run db:migrate
|
||||
|
||||
@@ -62,8 +62,8 @@ Click the links to join the discussions about our upcoming features.
|
||||
| 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 | 🛠️ |
|
||||
| [React frameworks](https://github.com/triggerdotdev/trigger.dev/discussions/411) | Support for Remix, Astro, RedwoodJS & more | 🛠️ |
|
||||
| [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 | 🕝 |
|
||||
@@ -83,3 +83,9 @@ We provide an official trigger.dev docker image you can use to easily self-host
|
||||
## Development
|
||||
|
||||
To setup and develop locally or contribute to the open source project, follow our [development guide](./CONTRIBUTING.md).
|
||||
|
||||
## Meet the Amazing People Behind This Project 🚀
|
||||
|
||||
<a href="https://github.com/triggerdotdev/trigger.dev/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=triggerdotdev/trigger.dev" />
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "../app/tailwind.css";
|
||||
import { unstable_createRemixStub } from "@remix-run/testing";
|
||||
import { createRemixStub } from "@remix-run/testing";
|
||||
import React from "react";
|
||||
import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider";
|
||||
import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider";
|
||||
@@ -23,13 +23,16 @@ const preview: Preview = {
|
||||
},
|
||||
],
|
||||
},
|
||||
layout: "fullscreen",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const RemixStub = unstable_createRemixStub([
|
||||
const RemixStub = createRemixStub([
|
||||
{
|
||||
path: "/*",
|
||||
element: <Story />,
|
||||
action: () => ({ redirect: "/" }),
|
||||
loader: () => ({ redirect: "/" }),
|
||||
Component: Story,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export function EndpointIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.24336 5.11182C8.75159 4.60359 9.57559 4.60359 10.0838 5.11182L18.8882 13.9162C19.3964 14.4244 19.3964 15.2484 18.8882 15.7566L17.1469 17.4979C16.453 18.2196 15.6222 18.7956 14.7031 19.1922C13.7839 19.5889 12.7949 19.7982 11.7939 19.8081C10.7929 19.8179 9.79992 19.628 8.87318 19.2494C7.94643 18.8708 7.10449 18.3113 6.39662 17.6034C5.68875 16.8955 5.12916 16.0536 4.7506 15.1268C4.37205 14.2001 4.18212 13.2071 4.19194 12.2061C4.20176 11.2051 4.41112 10.2161 4.80779 9.29691C5.20445 8.37777 5.78044 7.54697 6.50206 6.85312L8.24336 5.11182Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.32398 20.676L7.11974 16.8803"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.2535 3.32398L11.4578 7.11974"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.676 8.74649L16.8803 12.5423"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
|
||||
export function IntegrationIcon() {
|
||||
return <LogoIcon className="h-3.5 w-3.5 flex-none pb-0.5" />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function RunsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.2964 3.66962C11.97 3.83662 11.6961 4.09048 11.5047 4.40323C11.3134 4.71598 11.2121 5.07547 11.212 5.44211L11.211 8.0162L9.22047 8.0172C8.95637 8.0172 8.70309 8.12211 8.51635 8.30886C8.3296 8.4956 8.22469 8.74888 8.22469 9.01298V14.9877L8.23166 15.1042C8.26019 15.3464 8.37664 15.5697 8.5589 15.7318C8.74116 15.8939 8.97657 15.9834 9.22047 15.9834L11.211 15.9825L11.212 18.5585C11.2121 18.9524 11.329 19.3373 11.5478 19.6648C11.7666 19.9922 12.0777 20.2474 12.4415 20.3981C12.8054 20.5488 13.2057 20.5882 13.592 20.5114C13.9783 20.4346 14.3331 20.245 14.6116 19.9666L21.1698 13.4084C21.5432 13.0349 21.7529 12.5284 21.7529 12.0003C21.7529 11.4722 21.5432 10.9658 21.1698 10.5923L14.6116 4.03408C14.3331 3.75539 13.9782 3.56558 13.5918 3.48865C13.2054 3.41172 12.8048 3.45114 12.4408 3.60191L12.2964 3.66962Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.24578 8.0172C3.48968 8.01723 3.72509 8.10677 3.90735 8.26885C4.08961 8.43092 4.20606 8.65425 4.23459 8.89647L4.24156 9.01298V14.9877C4.24128 15.2415 4.1441 15.4856 3.96986 15.6701C3.79563 15.8547 3.55751 15.9658 3.30414 15.9806C3.05077 15.9955 2.80128 15.9131 2.60666 15.7502C2.41203 15.5873 2.28695 15.3562 2.25697 15.1042L2.25 14.9877V9.01298C2.25 8.74888 2.35491 8.4956 2.54166 8.30885C2.7284 8.12211 2.98168 8.0172 3.24578 8.0172Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.23312 8.0172C6.47702 8.01723 6.71243 8.10677 6.89469 8.26885C7.07696 8.43092 7.1934 8.65425 7.22193 8.89647L7.2289 9.01298V14.9877C7.22862 15.2415 7.13144 15.4856 6.95721 15.6701C6.78298 15.8547 6.54485 15.9658 6.29148 15.9806C6.03811 15.9955 5.78863 15.9131 5.594 15.7502C5.39937 15.5873 5.27429 15.3562 5.24431 15.1042L5.23734 14.9877V9.01298C5.23734 8.74888 5.34226 8.4956 5.529 8.30885C5.71575 8.12211 5.96903 8.0172 6.23312 8.0172Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,8 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
|
||||
const [form, { path, feedbackType, message }] = useForm({
|
||||
id: "accept-invite",
|
||||
lastSubmission,
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { UserMinusIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function ImpersonationBanner({ impersonationId }: { impersonationId: string }) {
|
||||
export function ImpersonationBanner() {
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 px-4 text-center">
|
||||
<span></span>
|
||||
<Paragraph variant="small">
|
||||
You are impersonating <span className="font-semibold">{impersonationId}</span>
|
||||
</Paragraph>
|
||||
<Form
|
||||
action="/resources/impersonation"
|
||||
method="delete"
|
||||
reloadDocument
|
||||
className="justify-self-end"
|
||||
>
|
||||
<div className="w-full">
|
||||
<Form action="/resources/impersonation" method="delete" reloadDocument className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
className="!text-slate-700 transition hover:!text-slate-900"
|
||||
variant="secondary/small"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={UserMinusIcon}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Stop impersonating
|
||||
</Button>
|
||||
|
||||
@@ -44,28 +44,7 @@ export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey:
|
||||
);
|
||||
}
|
||||
|
||||
export function RunDevCommand() {
|
||||
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" className="mb-4" value={`npm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`pnpm run dev`} />
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField variant="primary/medium" className="mb-4" value={`yarn run dev`} />
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerDevCommand() {
|
||||
export function RunDevCommand({ extra }: { extra?: string }) {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
@@ -77,34 +56,67 @@ export function TriggerDevCommand() {
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npx @trigger.dev/cli@latest dev`}
|
||||
value={`npm run dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm dlx @trigger.dev/cli@latest dev`}
|
||||
value={`pnpm run dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn dlx @trigger.dev/cli@latest dev`}
|
||||
value={`yarn run dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerDevStep() {
|
||||
export function TriggerDevCommand({ extra }: { extra?: string }) {
|
||||
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"
|
||||
className="mb-4"
|
||||
value={`npx @trigger.dev/cli@latest dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm dlx @trigger.dev/cli@latest dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn dlx @trigger.dev/cli@latest dev${extra ? ` ${extra}` : ""}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerDevStep({ extra }: { extra?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Paragraph spacing>
|
||||
In a <span className="text-amber-400">separate terminal window or tab</span> run:
|
||||
</Paragraph>
|
||||
<TriggerDevCommand />
|
||||
<TriggerDevCommand extra={extra} />
|
||||
<Paragraph spacing variant="small">
|
||||
If you’re not running on the default you can specify the port by adding{" "}
|
||||
<InlineCode variant="extra-small">--port 3001</InlineCode> to the end.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
ClientTabsContent,
|
||||
} from "../primitives/ClientTabs";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
|
||||
type InstallPackagesProps = {
|
||||
packages: string[];
|
||||
};
|
||||
|
||||
export function InstallPackages({ packages }: InstallPackagesProps) {
|
||||
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"
|
||||
className="mb-4"
|
||||
value={`npm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn add ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { json as jsonLang } from "@codemirror/lang-json";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
@@ -14,6 +16,8 @@ export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
onBlur?: (code: string) => void;
|
||||
showCopyButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
}
|
||||
|
||||
const languages = {
|
||||
@@ -38,6 +42,8 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
onBlur,
|
||||
basicSetup,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = true,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
@@ -65,7 +71,8 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, state } = useCodeMirror(settings);
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
@@ -75,24 +82,71 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
|
||||
//if the defaultValue changes update the editor
|
||||
useEffect(() => {
|
||||
if (state !== undefined) {
|
||||
state.update({
|
||||
changes: { from: 0, to: state.doc.length, insert: defaultValue },
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, state]);
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
}, [view]);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700",
|
||||
opts.className
|
||||
)}
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
<div className={cn(opts.className, "relative")}>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
leadingIconClassName={copied ? "text-green-500 group-hover:text-green-500" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import {
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
} from "@codemirror/view";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { json as jsonLang } from "@codemirror/lang-json";
|
||||
import { closeBrackets } from "@codemirror/autocomplete";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
|
||||
export function getPreviewSetup(): Array<Extension> {
|
||||
return [
|
||||
jsonLang(),
|
||||
highlightSpecialChars(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
highlightSelectionMatches(),
|
||||
lineNumbers(),
|
||||
];
|
||||
}
|
||||
|
||||
export function getViewerSetup(): Array<Extension> {
|
||||
return [drawSelection(), dropCursor(), bracketMatching(), lineNumbers()];
|
||||
}
|
||||
import { jsonParseLinter } from "@codemirror/lang-json";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { lintGutter, lintKeymap, linter } from "@codemirror/lint";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { Prec, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
|
||||
export function getEditorSetup(showLineNumbers = true, showHighlights = true): Array<Extension> {
|
||||
const options = [
|
||||
@@ -36,7 +21,20 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
keymap.of([indentWithTab]),
|
||||
lintGutter(),
|
||||
linter(jsonParseLinter()),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
return true;
|
||||
},
|
||||
preventDefault: false,
|
||||
},
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...lintKeymap]),
|
||||
];
|
||||
|
||||
if (showLineNumbers) {
|
||||
|
||||
@@ -17,10 +17,15 @@ export function darkTheme(): Extension {
|
||||
violet = "#c678dd",
|
||||
darkBackground = "#21252b",
|
||||
highlightBackground = "rgba(71,85,105,0.2)",
|
||||
background = "#0f172a",
|
||||
background = "rgba(11, 16, 24 ,100)",
|
||||
tooltipBackground = "#353a42",
|
||||
selection = "rgb(71 85 105)",
|
||||
cursor = "#528bff";
|
||||
cursor = "#528bff",
|
||||
scrollbarTrack = "#0E1521",
|
||||
scrollbarTrackActive = "#131B2B",
|
||||
scrollbarThumb = "#293649",
|
||||
scrollbarThumbActive = "#3C4B62",
|
||||
scrollbarBg = "#0E1521";
|
||||
|
||||
const jsonHeroEditorTheme = EditorView.theme(
|
||||
{
|
||||
@@ -94,6 +99,45 @@ export function darkTheme(): Extension {
|
||||
color: ivory,
|
||||
},
|
||||
},
|
||||
".cm-scroller": {
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: `${scrollbarThumb} ${scrollbarTrack}`,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar": {
|
||||
display: "block",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track": {
|
||||
backgroundColor: scrollbarTrack,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:hover": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:active": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: scrollbarThumb,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:active": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner": {
|
||||
backgroundColor: scrollbarBg,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:hover": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:active": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
@@ -155,157 +199,3 @@ export function darkTheme(): Extension {
|
||||
|
||||
return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)];
|
||||
}
|
||||
|
||||
export function lightTheme(): Extension[] {
|
||||
const stringColor = "text-[#53a053]",
|
||||
numberColor = "text-[#447bef]",
|
||||
variableColor = "text-[#a42ea2]",
|
||||
booleanColor = "text-[#e2574e]",
|
||||
coral = "text-[#e06c75]",
|
||||
invalid = "text-[#ffffff]",
|
||||
ivory = "text-[#abb2bf]",
|
||||
stone = "text-[#7d8799]",
|
||||
malibu = "text-[#61afef]",
|
||||
whiskey = "text-[#d19a66]",
|
||||
violet = "text-[#c678dd]",
|
||||
darkBackground = "text-[#21252b]",
|
||||
highlightBackground = "text-[#D0D0D0]",
|
||||
background = "text-[#ffffff]",
|
||||
tooltipBackground = "text-[#353a42]",
|
||||
selection = "text-[#D0D0D0]",
|
||||
cursor = "text-[#528bff]";
|
||||
|
||||
const jsonHeroEditorTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: ivory,
|
||||
backgroundColor: background,
|
||||
},
|
||||
|
||||
".cm-content": {
|
||||
caretColor: cursor,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" },
|
||||
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#72a1ff59",
|
||||
outline: "1px solid #457dff",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "#6199ff2f",
|
||||
},
|
||||
|
||||
".cm-activeLine": { backgroundColor: highlightBackground },
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#bad0f847",
|
||||
outline: "1px solid #515a6b",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
backgroundColor: background,
|
||||
color: stone,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: highlightBackground,
|
||||
},
|
||||
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#ddd",
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
border: "none",
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: tooltipBackground,
|
||||
borderBottomColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: highlightBackground,
|
||||
color: ivory,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ dark: false }
|
||||
);
|
||||
|
||||
/// The highlighting style for code in the JSON Hero theme.
|
||||
const jsonHeroHighlightStyle = tagHighlighter([
|
||||
{ tag: tags.keyword, class: violet },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
class: variableColor,
|
||||
},
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.labelName],
|
||||
class: malibu,
|
||||
},
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
class: whiskey,
|
||||
},
|
||||
{ tag: [tags.definition(tags.name), tags.separator], class: ivory },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
class: numberColor,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
class: stringColor,
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], class: stone },
|
||||
|
||||
{ tag: tags.link, class: stone },
|
||||
{ tag: tags.heading, class: coral },
|
||||
{
|
||||
tag: [tags.atom, tags.bool, tags.special(tags.variableName)],
|
||||
class: booleanColor,
|
||||
},
|
||||
{
|
||||
tag: [tags.processingInstruction, tags.string, tags.inserted],
|
||||
class: stringColor,
|
||||
},
|
||||
{ tag: tags.invalid, class: invalid },
|
||||
]);
|
||||
|
||||
return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CheckCircleIcon, ClockIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EndpointIndexStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
export function EndpointIndexStatusIcon({ status }: { status: EndpointIndexStatus }) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return <ClockIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
|
||||
case "STARTED":
|
||||
return <Spinner className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
|
||||
case "SUCCESS":
|
||||
return (
|
||||
<CheckCircleIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />
|
||||
);
|
||||
case "FAILURE":
|
||||
return <XCircleIcon className={cn("h-4 w-4", endpointIndexStatusClassNameColor(status))} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function EndpointIndexStatusLabel({ status }: { status: EndpointIndexStatus }) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return (
|
||||
<span className={endpointIndexStatusClassNameColor(status)}>
|
||||
{endpointIndexStatusTitle(status)}
|
||||
</span>
|
||||
);
|
||||
case "STARTED":
|
||||
return (
|
||||
<span className={endpointIndexStatusClassNameColor(status)}>
|
||||
{endpointIndexStatusTitle(status)}
|
||||
</span>
|
||||
);
|
||||
case "SUCCESS":
|
||||
return (
|
||||
<span className={endpointIndexStatusClassNameColor(status)}>
|
||||
{endpointIndexStatusTitle(status)}
|
||||
</span>
|
||||
);
|
||||
case "FAILURE":
|
||||
return (
|
||||
<span className={endpointIndexStatusClassNameColor(status)}>
|
||||
{endpointIndexStatusTitle(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointIndexStatusTitle(status: EndpointIndexStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "Pending";
|
||||
case "STARTED":
|
||||
return "Started";
|
||||
case "SUCCESS":
|
||||
return "Success";
|
||||
case "FAILURE":
|
||||
return "Failure";
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointIndexStatusClassNameColor(status: EndpointIndexStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-dimmed";
|
||||
case "STARTED":
|
||||
return "text-blue-500";
|
||||
case "SUCCESS":
|
||||
return "text-green-500";
|
||||
case "FAILURE":
|
||||
return "text-rose-500";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { generateTwoRandomWords } from "~/utils/randomWords";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
randomWord: string;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
leadingIconClassName="text-dimmed"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
|
||||
// form submission completed
|
||||
if (fetcher.state === "loading") {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-5 pb-4">
|
||||
<div className="mt-3 flex gap-x-3 rounded-md border border-ui-border py-4 pl-3 pr-5">
|
||||
<ExclamationTriangleIcon className="relative top-1 h-6 w-6 min-w-[2rem] text-amber-500" />
|
||||
<Paragraph>
|
||||
Regenerating the keys for this environment will temporarily break any live Jobs in the
|
||||
<span className="text-bright"> {title} Environment</span> until the new API keys are set
|
||||
in the relevant environment variables.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${id}/regenerate-api-key`}
|
||||
className="mt-2 flex w-full flex-col items-center gap-2"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-ui-border bg-slate-900 px-2 py-1 font-mono text-bright"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
className="rounded-r-none"
|
||||
variant="large"
|
||||
/>
|
||||
<Button
|
||||
variant="primary/large"
|
||||
disabled={confirmationText !== randomWord}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isSubmitting ? <Spinner color="white" /> : <>Regenerate</>}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,11 +3,10 @@ import { GitHubDarkIcon } from "@trigger.dev/companyicons";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { projectSetupPath } from "~/utils/pathBuilder";
|
||||
import { PageGradient } from "../PageGradient";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
export type FrameworkComingSoonProps = {
|
||||
frameworkName: string;
|
||||
githubIssueUrl: string;
|
||||
@@ -25,43 +24,41 @@ export function FrameworkComingSoon({
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageGradient>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 grid place-items-center pb-6">{children}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>{frameworkName} is coming soon!</Header1>
|
||||
<LinkButton
|
||||
to={projectSetupPath(organization, project)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={Squares2X2Icon}
|
||||
>
|
||||
Choose a different framework
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph spacing className="border-b border-slate-800 pb-4">
|
||||
We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with
|
||||
the GitHub issue or contribute and help us bring it to Trigger.dev faster.
|
||||
</Paragraph>
|
||||
<a
|
||||
href={githubIssueUrl}
|
||||
target="_blank"
|
||||
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
<div className="mb-6 grid place-items-center pb-6">{children}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>{frameworkName} is coming soon!</Header1>
|
||||
<LinkButton
|
||||
to={projectSetupPath(organization, project)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={Squares2X2Icon}
|
||||
>
|
||||
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
|
||||
<h2 className="text-2xl font-semibold text-black">
|
||||
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
support for {frameworkName}
|
||||
</h2>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
<GitHubDarkIcon className="h-4 w-4" />
|
||||
<Paragraph variant="small">View on GitHub</Paragraph>
|
||||
<NamedIcon
|
||||
name="arrow-right"
|
||||
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
Choose a different framework
|
||||
</LinkButton>
|
||||
</div>
|
||||
</PageGradient>
|
||||
<Paragraph spacing className="border-b border-slate-800 pb-4">
|
||||
We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with
|
||||
the GitHub issue or contribute and help us bring it to Trigger.dev faster.
|
||||
</Paragraph>
|
||||
<a
|
||||
href={githubIssueUrl}
|
||||
target="_blank"
|
||||
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
|
||||
>
|
||||
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
|
||||
<h2 className="text-2xl font-semibold text-black">
|
||||
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
support for {frameworkName}
|
||||
</h2>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
<GitHubDarkIcon className="h-4 w-4" />
|
||||
<Paragraph variant="small">View on GitHub</Paragraph>
|
||||
<NamedIcon
|
||||
name="arrow-right"
|
||||
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
projectSetupSvelteKitPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { PageGradient } from "../PageGradient";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
|
||||
@@ -33,51 +32,49 @@ export function FrameworkSelector() {
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageGradient>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>Choose a framework to get started…</Header1>
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
Request a framework
|
||||
</Button>
|
||||
}
|
||||
defaultValue="feature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 place-items-center gap-2">
|
||||
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
|
||||
<NextjsLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
|
||||
<ExpressLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
|
||||
<RemixLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
|
||||
<AstroLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
|
||||
<RedwoodLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
|
||||
<NuxtLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)}>
|
||||
<SvelteKitLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
|
||||
<FastifyLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNestjsPath(organization, project)}>
|
||||
<NestjsLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl pt-28">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>Choose a framework to get started…</Header1>
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
Request a framework
|
||||
</Button>
|
||||
}
|
||||
defaultValue="feature"
|
||||
/>
|
||||
</div>
|
||||
</PageGradient>
|
||||
|
||||
<div className="grid grid-cols-3 place-items-center gap-2">
|
||||
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
|
||||
<NextjsLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
|
||||
<ExpressLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
|
||||
<RemixLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
|
||||
<AstroLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)} supported>
|
||||
<SvelteKitLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNestjsPath(organization, project)} supported>
|
||||
<NestjsLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
|
||||
<FastifyLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
|
||||
<NuxtLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
|
||||
<RedwoodLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,8 +90,7 @@ function FrameworkLink({ children, to, supported = false }: FrameworkLinkProps)
|
||||
to={to}
|
||||
className={cn(
|
||||
"flex h-28 w-full items-center justify-center rounded-md border border-slate-750 px-8 py-4 transition hover:bg-slate-850",
|
||||
!supported &&
|
||||
"border-dashed opacity-50 grayscale transition hover:opacity-100 hover:grayscale-0"
|
||||
!supported && "border opacity-30 grayscale transition hover:opacity-100 hover:grayscale-0"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { IntegrationIcon } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route";
|
||||
import { jobTestPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, jobTestPath } from "~/utils/pathBuilder";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { InlineCode } from "../code/InlineCode";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
@@ -21,6 +20,7 @@ import selectEnvironment from "./select-environment.png";
|
||||
import selectExample from "./select-example.png";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { TriggerDevCommand } from "../SetupCommands";
|
||||
import { IntegrationIcon } from "~/assets/icons/IntegrationIcon";
|
||||
|
||||
export function HowToRunYourJob() {
|
||||
const organization = useOrganization();
|
||||
@@ -79,37 +79,6 @@ export function HowToRunYourJob() {
|
||||
);
|
||||
}
|
||||
|
||||
export function HowToRunATest() {
|
||||
return (
|
||||
<>
|
||||
<StepNumber
|
||||
stepNumber="1"
|
||||
title="Select an environment
|
||||
"
|
||||
/>
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>Select the environment you’d like the test to run against.</Paragraph>
|
||||
<img src={selectEnvironment} className="mt-2 w-52" />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Write your test payload" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Write your own payload specific to your Job. Some Triggers also provide example payloads
|
||||
that you can select from. This will populate the code editor below.
|
||||
</Paragraph>
|
||||
<img src={selectExample} className="mt-2 h-40" />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run your test" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>When you’re happy with the payload, click Run test.</Paragraph>
|
||||
</StepContentContainer>
|
||||
<Callout variant="docs" to="https://trigger.dev/docs/documentation/guides/testing-jobs">
|
||||
Learn more about running tests.
|
||||
</Callout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowToConnectAnIntegration() {
|
||||
return (
|
||||
<>
|
||||
@@ -325,3 +294,140 @@ export function HowToUseApiKeysAndEndpoints() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WhatAreHttpEndpoints() {
|
||||
return (
|
||||
<>
|
||||
<Paragraph spacing>
|
||||
HTTP endpoints allow you to trigger your Jobs from any webhooks. They require a bit more
|
||||
work than using <TextLink to={docsPath("integrations/introduction")}>Integrations</TextLink>{" "}
|
||||
but allow you to connect to any API.
|
||||
</Paragraph>
|
||||
<Header2 spacing>Getting started</Header2>
|
||||
<Paragraph spacing>
|
||||
You need to define the HTTP endpoint in your code. To do this you use{" "}
|
||||
<InlineCode>client.defineHttpEndpoint()</InlineCode>. This will create an HTTP endpoint.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
Then you can create a Trigger from this by calling <InlineCode>.onRequest()</InlineCode> on
|
||||
the created HTTP endpoint.
|
||||
</Paragraph>
|
||||
<Callout variant="docs" to={docsPath("documentation/concepts/http-endpoints")}>
|
||||
Read the HTTP endpoints guide to learn more.
|
||||
</Callout>
|
||||
<Header2 spacing className="mt-4">
|
||||
An example: cal.com
|
||||
</Header2>
|
||||
<CodeBlock
|
||||
code={`//create an HTTP endpoint
|
||||
const caldotcom = client.defineHttpEndpoint({
|
||||
id: "cal.com",
|
||||
source: "cal.com",
|
||||
icon: "caldotcom",
|
||||
verify: async (request) => {
|
||||
//this helper function makes verifying most webhooks easy
|
||||
return await verifyRequestSignature({
|
||||
request,
|
||||
headerName: "X-Cal-Signature-256",
|
||||
secret: process.env.CALDOTCOM_SECRET!,
|
||||
algorithm: "sha256",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "http-caldotcom",
|
||||
name: "HTTP Cal.com",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
//create a Trigger from the HTTP endpoint above. The filter is optional.
|
||||
trigger: caldotcom.onRequest({ filter: { body: { triggerEvent: ["BOOKING_CANCELLED"] } } }),
|
||||
run: async (request, io, ctx) => {
|
||||
//note that when using HTTP endpoints, the first parameter is the request
|
||||
//you need to get the body, usually it will be json so you do:
|
||||
const body = await request.json();
|
||||
await io.logger.info("Body", body);
|
||||
},
|
||||
});`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowToConnectHttpEndpoint() {
|
||||
return (
|
||||
<>
|
||||
<Header2 spacing>Setting up your webhook</Header2>
|
||||
<Paragraph spacing>
|
||||
To start receiving data you need to enter the Endpoint URL and secret into the API service
|
||||
you want to receive webhooks from.
|
||||
</Paragraph>
|
||||
|
||||
<StepNumber stepNumber="1" title={<>Go to the relevant API dashboard</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
For example, if you want to receive webhooks from Cal.com then you should login to your
|
||||
Cal.com account and go to their Settings/Developer/Webhooks page.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="2" title={<>Copy the Webhook URL and Secret</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
A unique Webhook URL is created for each environment (Dev, Staging, and Prod). Jobs will
|
||||
only be triggered from the relevant environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
Copy the relevant Endpoint URL and secret from the table opposite and paste it into the
|
||||
correct place in the API dashboard you located in the previous step.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="3" title={<>Add the Secret to your Environment variables</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
You should also add the Secret to the Environment variables in your code and where you're
|
||||
deploying. Usually in Node this means adding it to the .env file.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
Use the secret in the <InlineCode>verify()</InlineCode> function of HTTP Endpoint. This
|
||||
ensures that someone can't just send a request to your Endpoint and trigger a Job.
|
||||
Different APIs do this verification in different ways – a common way is to have a header
|
||||
that has a hash of the payload and secret. Refer to the API's documentation for more
|
||||
information.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<Header2 spacing>Triggering runs</Header2>
|
||||
<StepNumber stepNumber="1" title="Ensure you're using the HTTP Endpoint in your code" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
In your code, you should use the <InlineCode>.onRequest()</InlineCode> function in a Job
|
||||
Trigger. You can filter so only data that matches your criteria triggers the Job.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Make sure your code is deployed (for Staging and Prod)" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
If you're using the Staging or Prod environment, you need to make sure your code is
|
||||
deployed. Deploy like you normally would –{" "}
|
||||
<TextLink to={docsPath("documentation/guides/deployment")}>
|
||||
read our deployment guide
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Perform an action that sends a webhook" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Now you need to actually perform an action on that third-party service that triggers the
|
||||
webhook you've subscribed to. For example, add a new meeting using Cal.com.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<Callout variant="docs" to={docsPath("documentation/concepts/http-endpoints")}>
|
||||
Read the HTTP endpoints guide to learn more.
|
||||
</Callout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ export function ConnectToOAuthForm({
|
||||
|
||||
const [form, { title, slug, scopes, hasCustomClient, customClientId, customClientSecret }] =
|
||||
useForm({
|
||||
lastSubmission: fetcher.data,
|
||||
// TODO: type this
|
||||
lastSubmission: fetcher.data as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
|
||||
@@ -45,7 +45,8 @@ export function UpdateOAuthForm({
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
const [form, { title, scopes, hasCustomClient, customClientId, customClientSecret }] = useForm({
|
||||
lastSubmission: fetcher.data,
|
||||
// TODO: type this
|
||||
lastSubmission: fetcher.data as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema,
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DeleteJobDialogContent({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
|
||||
<Header1>{title}</Header1>
|
||||
<Paragraph variant="small">ID: {slug}</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ProjectJob } from "~/hooks/useJobs";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { JobRunStatus } from "~/models/job.server";
|
||||
import { ProjectJob } from "~/presenters/JobListPresenter.server";
|
||||
import { jobPath, jobTestPath } from "~/utils/pathBuilder";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
@@ -27,10 +27,9 @@ import { JobStatusBadge } from "./JobStatusBadge";
|
||||
|
||||
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table containerClassName="mb-4">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Job</TableHeaderCell>
|
||||
@@ -45,7 +44,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableBody>
|
||||
{jobs.length > 0 ? (
|
||||
jobs.map((job) => {
|
||||
const path = jobPath(organization, project, job);
|
||||
const path = jobPath(organization, { slug: job.projectSlug }, job);
|
||||
return (
|
||||
<TableRow key={job.id} className="group">
|
||||
<TableCell to={path}>
|
||||
@@ -153,26 +152,25 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableCellMenu isSticky>
|
||||
<PopoverMenuItem to={path} title="View Job" icon="eye" />
|
||||
<PopoverMenuItem
|
||||
to={jobTestPath(organization, project, job)}
|
||||
to={jobTestPath(organization, { slug: job.projectSlug }, job)}
|
||||
title="Test Job"
|
||||
icon="beaker"
|
||||
/>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="menu-item" LeadingIcon="trash-can">
|
||||
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs">
|
||||
Delete Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogHeader>Delete Job</DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCellMenu>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function AppContainer({
|
||||
}) {
|
||||
return (
|
||||
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
|
||||
<div className={cn("grid h-full w-full grid-rows-[2.75rem_auto]")}>{children}</div>
|
||||
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
|
||||
</BackgroundGradient>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export function PageContainer({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("grid h-full grid-rows-[auto_1fr]", className)}>{children}</div>;
|
||||
return (
|
||||
<div className={cn("grid h-full grid-rows-[auto_1fr] overflow-hidden", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBody({
|
||||
@@ -58,7 +62,15 @@ export function PageBody({
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(scrollable ? "overflow-y-auto p-4 " : "overflow-hidden")}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
: "overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +80,8 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function MainCenteredContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mx-auto mt-[30vh] max-w-xs">{children}</div>
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mx-auto mt-[25vh] max-w-xs overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { RouteMatch, useMatches } from "@remix-run/react";
|
||||
import { UIMatch, useMatches } from "@remix-run/react";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
import { BreadcrumbIcon } from "../primitives/BreadcrumbIcon";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
|
||||
export type BreadcrumbItem = (match: RouteMatch, allMatches: RouteMatch[]) => ReactNode;
|
||||
export type BreadcrumbItem = (match: UIMatch, allMatches: UIMatch[]) => ReactNode;
|
||||
|
||||
export function Breadcrumb() {
|
||||
const matches = useMatches();
|
||||
const matches = useMatches() as UIMatch<unknown, Handle>[];
|
||||
|
||||
return (
|
||||
<div className="hidden items-center md:flex">
|
||||
{matches.map((match) => {
|
||||
if (!match.handle || !match.handle.breadcrumb) return null;
|
||||
<div className="flex items-center px-1">
|
||||
{matches
|
||||
.filter((b) => b.handle && b.handle.breadcrumb)
|
||||
.map((match, index) => {
|
||||
const breadcrumb = match.handle.breadcrumb as BreadcrumbItem;
|
||||
|
||||
const breadcrumb = match.handle.breadcrumb as BreadcrumbItem;
|
||||
|
||||
return (
|
||||
<Fragment key={match.id}>
|
||||
<BreadcrumbIcon />
|
||||
{breadcrumb(match, matches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Fragment key={match.id}>
|
||||
{index !== 0 && <BreadcrumbIcon />} {breadcrumb(match, matches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
return (
|
||||
<LinkButton to={to} variant="tertiary/small">
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Link, RouteMatch } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useJobs } from "~/hooks/useJobs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { jobPath } from "~/utils/pathBuilder";
|
||||
import { LabelValueStack } from "../primitives/LabelValueStack";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
|
||||
export function JobsMenu({ matches }: { matches: RouteMatch[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const organization = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
const projectJobs = useJobs(matches);
|
||||
const currentJob = useJob(matches);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverArrowTrigger isOpen={isOpen}>
|
||||
{currentJob?.title ?? "Select a job"}
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
collisionPadding={20}
|
||||
>
|
||||
<PopoverSectionHeader title="Jobs" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{projectJobs.map((job) => {
|
||||
const isSelected = job.id === currentJob?.id;
|
||||
return (
|
||||
<Link
|
||||
key={job.id}
|
||||
to={jobPath(organization, project, job)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md p-2 transition hover:bg-slate-800",
|
||||
isSelected && "bg-slate-750 group-hover:bg-slate-750"
|
||||
)}
|
||||
>
|
||||
<NamedIcon name={job.event.icon} className="h-8 w-8" />
|
||||
<LabelValueStack
|
||||
label={job.title}
|
||||
value={job.slug}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
/>
|
||||
{isSelected && <NamedIcon name="check" className="mr-1 h-6 w-6" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { BookOpenIcon, ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
import { docsRoot } from "~/utils/pathBuilder";
|
||||
|
||||
export function NavBar() {
|
||||
return (
|
||||
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-uiBorder py-1 pl-1 pr-2.5">
|
||||
<div className="flex gap-0.5">
|
||||
<Link to="/" className="p-2">
|
||||
<LogoIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<Breadcrumb />
|
||||
<MobileDropdownMenu />
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<LinkButton to={docsRoot()} variant="secondary/small" LeadingIcon={BookOpenIcon}>
|
||||
Documentation
|
||||
</LinkButton>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ChatBubbleLeftRightIcon}
|
||||
shortcut={{ key: "f" }}
|
||||
>
|
||||
Help & feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
return (
|
||||
<LinkButton to={to} variant="tertiary/small">
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDropdownMenu() {
|
||||
return (
|
||||
<Popover className="block sm:hidden">
|
||||
<Popover.Button
|
||||
className="bg-slate-70 relative z-10 flex h-8 w-8 items-center justify-center rounded border-none bg-opacity-50 focus-visible:border-none focus-visible:outline-none"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
{({ open }) => <MobileNavIcon open={open} />}
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="absolute left-0 top-0 h-full w-full origin-top bg-midnight-900/80" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-100 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="absolute inset-x-6 top-0 mt-20 flex origin-top flex-col gap-4 rounded-md bg-slate-800 p-4 text-lg tracking-tight text-slate-900 shadow-xl ring-1 ring-slate-700"
|
||||
>
|
||||
{/* <PrimaryA
|
||||
href={docsRoot()}
|
||||
target="_blank"
|
||||
className="max-w-full"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="-ml-1 h-4 w-4" />
|
||||
Documentation
|
||||
</PrimaryA>
|
||||
<PrimaryButton
|
||||
data-attr="posthog-feedback-button"
|
||||
className="max-w-full"
|
||||
>
|
||||
<ChatBubbleLeftRightIcon className="-ml-1 h-4 w-4" />
|
||||
Send us feedback
|
||||
</PrimaryButton>
|
||||
<SecondaryLink to="/logout" className="max-w-full">
|
||||
Logout
|
||||
</SecondaryLink> */}
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 overflow-visible stroke-slate-300"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path
|
||||
d="M0 1H14M0 7H14M0 13H14"
|
||||
className={cn("origin-center transition", open && "scale-90 opacity-0")}
|
||||
/>
|
||||
<path
|
||||
d="M2 2L12 12M12 2L2 12"
|
||||
className={cn("origin-center transition", !open && "scale-90 opacity-0")}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
export function PageNavigationIndicator() {
|
||||
const navigation = useNavigation();
|
||||
if (navigation.state === "loading") {
|
||||
return <Spinner color="muted" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useOptionalJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
accountPath,
|
||||
organizationBillingPath,
|
||||
organizationTeamPath,
|
||||
projectEnvironmentsPath,
|
||||
projectIntegrationsPath,
|
||||
projectPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { NavLinkButton } from "../primitives/Buttons";
|
||||
import { NamedIcon, type IconNames } from "../primitives/NamedIcon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex h-full w-full justify-stretch overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
const expandedWith = "14rem";
|
||||
const collapsedWith = "2.81rem";
|
||||
|
||||
const menuVariants = {
|
||||
expanded: {
|
||||
minWidth: expandedWith,
|
||||
width: expandedWith,
|
||||
},
|
||||
collapsed: {
|
||||
minWidth: collapsedWith,
|
||||
width: collapsedWith,
|
||||
},
|
||||
};
|
||||
|
||||
export function ProjectSideMenu() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const matches = useMatches();
|
||||
|
||||
//the deepest route `handle` determines if the menu is expanded
|
||||
const deepestMatch = matches.at(-1);
|
||||
const handle = deepestMatch?.handle as Handle;
|
||||
const isCollapsed = handle?.expandSidebar ? !handle.expandSidebar : true;
|
||||
|
||||
const job = useOptionalJob();
|
||||
const jobsActive =
|
||||
job !== undefined ||
|
||||
deepestMatch?.id === "routes/_app.orgs.$organizationSlug.projects.$projectParam._index";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
variants={menuVariants}
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
transition={{ type: "spring", duration: 0.5 }}
|
||||
className={cn(
|
||||
"flex h-full flex-col justify-between overflow-hidden border-r border-uiBorder p-1 transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
to={projectPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
forceActive={jobsActive}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={projectIntegrationsPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
hasWarning={project.hasUnconfiguredIntegrations}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
data-action="triggers"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon="team"
|
||||
to={organizationTeamPath(organization)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Usage & Billing"
|
||||
icon="billing"
|
||||
to={organizationBillingPath(organization)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Onboarding"
|
||||
icon="clipboard-checked"
|
||||
to={projectSetupPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="onboarding"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="list"
|
||||
to="https://trigger.dev/changelog"
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Account"
|
||||
icon={UserProfilePhoto}
|
||||
to={accountPath()}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="account"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
},
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
name,
|
||||
to,
|
||||
isCollapsed,
|
||||
forceActive,
|
||||
hasWarning = false,
|
||||
target,
|
||||
}: {
|
||||
icon: IconNames | React.ComponentType<any>;
|
||||
name: string;
|
||||
to: string;
|
||||
isCollapsed: boolean;
|
||||
hasWarning?: boolean;
|
||||
forceActive?: boolean;
|
||||
target?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<NavLinkButton
|
||||
variant="menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
to={to}
|
||||
target={target}
|
||||
className={({ isActive, isPending }) => {
|
||||
if (forceActive !== undefined) {
|
||||
isActive = forceActive;
|
||||
}
|
||||
return cn(
|
||||
"relative",
|
||||
isActive || isPending
|
||||
? "bg-slate-800 text-bright group-hover:bg-slate-800"
|
||||
: "text-dimmed group-hover:bg-slate-850 group-hover:text-bright"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
className="shrink-0 pl-1"
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
variants={itemVariants}
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
>
|
||||
{name}
|
||||
</motion.span>
|
||||
{hasWarning && <NamedIcon name="error" className="absolute left-1 top-1 h-4 w-4" />}
|
||||
</NavLinkButton>
|
||||
}
|
||||
content={name}
|
||||
side="right"
|
||||
hidden={!isCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { useIsNewOrganizationPage, useOrganizations } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
import { newOrganizationPath, newProjectPath, projectPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
|
||||
export function ProjectsMenu({ matches }: { matches: RouteMatch[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const organizations = useOrganizations(matches);
|
||||
const isNewOrgPage = useIsNewOrganizationPage(matches);
|
||||
const currentProject = useOptionalProject(matches);
|
||||
|
||||
if (isNewOrgPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverArrowTrigger isOpen={isOpen}>
|
||||
{currentProject?.name ?? "Select a project"}
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
collisionPadding={20}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1 ">
|
||||
{organization.projects.map((project) => {
|
||||
const isSelected = project.id === currentProject?.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={project.id}
|
||||
to={projectPath(organization, project)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between pl-1 text-bright">
|
||||
<span className="grow truncate text-left">{project.name}</span>
|
||||
<Badge className="mr-0.5">{simplur`${project._count.jobs} job[|s]`}</Badge>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New Project"
|
||||
isSelected={false}
|
||||
icon="plus"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="border-t border-slate-700 p-1">
|
||||
<PopoverMenuItem
|
||||
to={newOrganizationPath()}
|
||||
title="New Organization"
|
||||
isSelected={false}
|
||||
icon="plus"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChartBarIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { MatchedProject } from "~/hooks/useProject";
|
||||
import { User } from "~/models/user.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
inviteTeamMemberPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
organizationBillingPath,
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationTeamPath,
|
||||
projectEnvironmentsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { type IconNames } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverCustomTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "hasInactiveExternalTriggers" | "jobCount" | "httpEndpointCount"
|
||||
>;
|
||||
|
||||
type SideMenuProps = {
|
||||
user: SideMenuUser;
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
};
|
||||
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (borderRef.current) {
|
||||
const shouldShowHeaderDivider = borderRef.current.scrollTop > 1;
|
||||
if (showHeaderDivider !== shouldShowHeaderDivider) {
|
||||
setShowHeaderDivider(shouldShowHeaderDivider);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
borderRef.current?.addEventListener("scroll", handleScroll);
|
||||
return () => borderRef.current?.removeEventListener("scroll", handleScroll);
|
||||
}, [showHeaderDivider]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-background px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
icon="plus"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
count={project.jobCount}
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
iconColor="text-amber-500"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
data-action="triggers"
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
iconColor="text-green-500"
|
||||
count={project.httpEndpointCount}
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
iconColor="text-rose-500"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={organization.title}>
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
title="Invite team member"
|
||||
icon={UserPlusIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon="folder"
|
||||
to={organizationPath(organization)}
|
||||
data-action="projects"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
to={organizationTeamPath(organization)}
|
||||
iconColor="text-sky-500"
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name={isManagedCloud ? "Usage & Billing" : "Usage"}
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-pink-500"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organization,
|
||||
organizations,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
}) {
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setOrgMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isOrgMenuOpen}
|
||||
overflowHidden
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{organization.title ?? "Select an organization"}</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="border-t border-slate-800 p-1">
|
||||
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon="plus" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
|
||||
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1">
|
||||
<UserProfilePhoto
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-600",
|
||||
user.isImpersonating && "rounded-full border border-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<PopoverSectionHeader title={user.email} variant="extra-small" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{user.isImpersonating && <ImpersonationBanner />}
|
||||
{user.admin && (
|
||||
<PopoverMenuItem
|
||||
to={"/admin"}
|
||||
title="Admin"
|
||||
icon={AcademicCapIcon}
|
||||
leadingIconClassName="text-yellow-500"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={accountPath()}
|
||||
title="View profile"
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Log out"
|
||||
icon={ArrowRightOnRectangleIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between overflow-hidden">
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
@@ -7,99 +7,129 @@ import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
const variant = {
|
||||
"primary/small": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60 justify-self-center",
|
||||
},
|
||||
"secondary/small": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
"tertiary/small": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-transparent group-hover:bg-slate-850 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
"danger/small": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut: "ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"primary/medium": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"secondary/medium": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut:
|
||||
"ml-1.5 -mr-0.5 border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright",
|
||||
},
|
||||
"tertiary/medium": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80",
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-transparent group-hover:bg-slate-850 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut:
|
||||
"ml-1.5 -mr-0.5 border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright",
|
||||
},
|
||||
"danger/medium": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"primary/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
|
||||
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"secondary/large": {
|
||||
textColor: "text-dimmed",
|
||||
textColor: "text-dimmed px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"danger/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50 px-1",
|
||||
button:
|
||||
"h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"menu-item": {
|
||||
textColor: "text-bright",
|
||||
textColor: "text-bright px-1",
|
||||
button:
|
||||
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-slate-800 transition",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"small-menu-item": {
|
||||
textColor: "text-bright",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-1.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"small-menu-sub-item": {
|
||||
textColor: "text-dimmed",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
|
||||
icon: undefined,
|
||||
iconSpacing: undefined,
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
@@ -110,7 +140,7 @@ const allVariants = {
|
||||
variant: variant,
|
||||
};
|
||||
|
||||
type ButtonContentPropsType = {
|
||||
export type ButtonContentPropsType = {
|
||||
children?: React.ReactNode;
|
||||
LeadingIcon?: React.ComponentType<any> | IconNamesOrString;
|
||||
TrailingIcon?: React.ComponentType<any> | IconNamesOrString;
|
||||
@@ -140,15 +170,17 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
// Based on the size prop, we'll use the corresponding variant classnames
|
||||
const btnClassName = cn(allVariants.$all, variation.button);
|
||||
const iconClassName = variation.icon;
|
||||
const iconSpacingClassName = variation.iconSpacing;
|
||||
const shortcutClassName = variation.shortcut;
|
||||
const textColorClassName = variation.textColor;
|
||||
|
||||
return (
|
||||
<div className={cn(fullWidth ? "flex" : "inline-flex text-xxs", btnClassName, className)}>
|
||||
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
|
||||
<div
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center gap-x-0.5"
|
||||
"flex w-full items-center",
|
||||
iconSpacingClassName
|
||||
)}
|
||||
>
|
||||
{LeadingIcon &&
|
||||
@@ -170,7 +202,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
|
||||
{text &&
|
||||
(typeof text === "string" ? (
|
||||
<span className={cn("mx-auto grow self-center truncate px-1", textColorClassName)}>
|
||||
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
export const variantClasses = {
|
||||
info: {
|
||||
@@ -51,8 +52,16 @@ export const variantClasses = {
|
||||
textColor: "text-blue-200",
|
||||
linkClassName: "transition hover:bg-blue-400/40",
|
||||
},
|
||||
pending: {
|
||||
className: "border-blue-400/20 bg-blue-800/30",
|
||||
icon: <Spinner className="h-5 w-5 shrink-0 " />,
|
||||
textColor: "text-blue-300",
|
||||
linkClassName: "transition hover:bg-blue-400/40",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CalloutVariant = keyof typeof variantClasses;
|
||||
|
||||
export function Callout({
|
||||
children,
|
||||
className,
|
||||
@@ -63,7 +72,7 @@ export function Callout({
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
variant: keyof typeof variantClasses;
|
||||
variant: CalloutVariant;
|
||||
to?: string;
|
||||
}) {
|
||||
const variantDefinition = variantClasses[variant];
|
||||
|
||||
@@ -63,16 +63,17 @@ export const DateTimeAccurate = ({ date, timeZone = "UTC" }: DateTimeProps) => {
|
||||
};
|
||||
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
|
||||
const milliseconds = `00${date.getMilliseconds()}`.slice(-3);
|
||||
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore this works in 92.5% of browsers https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_datetimeformat_options_parameter_options_fractionalseconddigits_parameter
|
||||
fractionalSecondDigits: 3,
|
||||
}).format(date);
|
||||
|
||||
return `${formatDateTime}.${milliseconds}`;
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Icon, IconInBox, RenderIcon } from "./Icon";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const variations = {
|
||||
small: {
|
||||
label: {
|
||||
variant: "small" as const,
|
||||
className: "m-0 leading-[1.1rem]",
|
||||
},
|
||||
description: {
|
||||
variant: "extra-small" as const,
|
||||
className: "m-0",
|
||||
},
|
||||
},
|
||||
base: {
|
||||
label: {
|
||||
variant: "base" as const,
|
||||
className: "m-0 leading-[1.1rem] ",
|
||||
},
|
||||
description: {
|
||||
variant: "small" as const,
|
||||
className: "m-0",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type DetailCellProps = {
|
||||
leadingIcon?: RenderIcon;
|
||||
leadingIconClassName?: string;
|
||||
trailingIcon?: RenderIcon;
|
||||
trailingIconClassName?: string;
|
||||
label: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
className?: string;
|
||||
variant?: keyof typeof variations;
|
||||
};
|
||||
|
||||
export function DetailCell({
|
||||
leadingIcon,
|
||||
leadingIconClassName,
|
||||
trailingIcon,
|
||||
trailingIconClassName,
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
variant = "small",
|
||||
}: DetailCellProps) {
|
||||
const variation = variations[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-slate-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<IconInBox
|
||||
icon={leadingIcon}
|
||||
className={cn("flex-none transition group-hover:border-slate-750", leadingIconClassName)}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Paragraph
|
||||
variant={variation.label.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left transition group-hover:text-bright",
|
||||
variation.label.className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Paragraph>
|
||||
{description && (
|
||||
<Paragraph
|
||||
variant={variation.description.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left text-dimmed transition group-hover:text-bright",
|
||||
variation.description.className
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1">
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn(
|
||||
"h-6 w-6 flex-none transition group-hover:border-slate-750",
|
||||
trailingIconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,12 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 p-6 pt-11 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<hr className="-ml-6 w-[calc(100%_+_3rem)]" />
|
||||
<hr className="absolute left-0 top-12 w-full" />
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
|
||||
<div className="flex gap-x-2">
|
||||
@@ -73,7 +73,7 @@ const DialogContent = React.forwardRef<
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
<div className={cn("flex flex-col text-left font-medium text-dimmed", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
|
||||
@@ -2,8 +2,17 @@ import type { z } from "zod";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { NamedIcon } from "./NamedIcon";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function FormError({ children, id }: { children: React.ReactNode; id?: string }) {
|
||||
export function FormError({
|
||||
children,
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children && (
|
||||
@@ -11,7 +20,7 @@ export function FormError({ children, id }: { children: React.ReactNode; id?: st
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-start gap-0.5"
|
||||
className={cn("flex items-start gap-0.5", className)}
|
||||
>
|
||||
<NamedIcon name="error" className="h-4 w-4 shrink-0 justify-start" />
|
||||
<Paragraph id={id} variant="extra-small" className="text-rose-500">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function FormTitle({
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex flex-col gap-3 pb-4",
|
||||
divide ? "border-b border-uiBorder" : "",
|
||||
divide ? "border-b border-ui-border" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -88,12 +88,7 @@ export function HelpContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grow rounded border border-slate-850 bg-midnight-850 bg-contain bg-left-top bg-no-repeat p-6"
|
||||
style={{
|
||||
backgroundImage: `url(${gradientPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="grow rounded border border-slate-850 bg-midnight-850/80 bg-contain bg-left-top bg-no-repeat p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export type RenderIcon = IconNamesOrString | React.ComponentType<any>;
|
||||
|
||||
type IconProps = {
|
||||
icon?: RenderIcon;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Use this icon to either render a passed in React component, or a NamedIcon/CompanyIcon */
|
||||
export function Icon(props: IconProps) {
|
||||
if (typeof props.icon === "string") {
|
||||
return <NamedIcon name={props.icon} className={props.className ?? ""} fallback={<></>} />;
|
||||
}
|
||||
|
||||
const Icon = props.icon;
|
||||
|
||||
if (!Icon) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <Icon className={props.className} />;
|
||||
}
|
||||
|
||||
export function IconInBox({ boxClassName, ...props }: IconProps & { boxClassName?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-9 w-9 place-content-center rounded-sm border border-slate-750 bg-slate-850",
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
<Icon icon={props.icon} className={cn("h-6 w-6", props.className)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
const variations = {
|
||||
primary: {
|
||||
@@ -45,18 +46,7 @@ export function LabelValueStack({
|
||||
<Paragraph variant={variation.label}>{label}</Paragraph>
|
||||
<>
|
||||
{href ? (
|
||||
<SimpleTooltip
|
||||
side="bottom"
|
||||
button={
|
||||
<Paragraph variant={variation.value}>
|
||||
<a href={href} className="underline underline-offset-2" target="_blank">
|
||||
{value}
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
|
||||
</a>
|
||||
</Paragraph>
|
||||
}
|
||||
content={href}
|
||||
/>
|
||||
<ValueButton value={value} href={href} variant={variant} />
|
||||
) : (
|
||||
<Paragraph variant={variation.value}>{value}</Paragraph>
|
||||
)}
|
||||
@@ -64,3 +54,40 @@ export function LabelValueStack({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ValueButtonStackProps = {
|
||||
value: React.ReactNode;
|
||||
href: string;
|
||||
variant?: keyof typeof variations;
|
||||
};
|
||||
|
||||
function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackProps) {
|
||||
const variation = variations[variant];
|
||||
|
||||
const isExternalUrl = href.startsWith("http");
|
||||
|
||||
if (!isExternalUrl) {
|
||||
return (
|
||||
<Paragraph variant={variation.value}>
|
||||
<Link to={href} reloadDocument className="underline underline-offset-2">
|
||||
{value}
|
||||
</Link>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
side="bottom"
|
||||
button={
|
||||
<Paragraph variant={variation.value}>
|
||||
<a href={href} className="underline underline-offset-2" target="_blank">
|
||||
{value}
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
|
||||
</a>
|
||||
</Paragraph>
|
||||
}
|
||||
content={href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,15 +59,19 @@ import {
|
||||
import { CompanyIcon, hasIcon } from "@trigger.dev/companyicons";
|
||||
import { ActivityIcon, HourglassIcon } from "lucide-react";
|
||||
import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon";
|
||||
import { EndpointIcon } from "~/assets/icons/EndpointIcon";
|
||||
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
|
||||
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
|
||||
import { ScheduleIcon } from "~/assets/icons/ScheduleIcon";
|
||||
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
|
||||
import { WebhookIcon } from "~/assets/icons/WebhookIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
|
||||
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
|
||||
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
|
||||
import tablerSpritePath from "./tabler-sprite.svg";
|
||||
|
||||
const icons = {
|
||||
account: (className: string) => <UserCircleIcon className={cn("text-slate-400", className)} />,
|
||||
@@ -125,7 +129,9 @@ const icons = {
|
||||
"invite-member": (className: string) => (
|
||||
<UserPlusIcon className={cn("text-indigo-500", className)} />
|
||||
),
|
||||
job: (className: string) => <WrenchScrewdriverIcon className={cn("text-teal-500", className)} />,
|
||||
job: (className: string) => (
|
||||
<WrenchScrewdriverIcon className={cn("text-indigo-500", className)} />
|
||||
),
|
||||
key: (className: string) => <KeyIcon className={cn("text-amber-400", className)} />,
|
||||
lightbulb: (className: string) => <LightBulbIcon className={cn("text-amber-400", className)} />,
|
||||
"clipboard-checked": (className: string) => (
|
||||
@@ -181,6 +187,11 @@ const icons = {
|
||||
<ScheduleIcon className={cn("text-sky-500", className)} />
|
||||
),
|
||||
webhook: (className: string) => <WebhookIcon className={cn("text-pink-500", className)} />,
|
||||
endpoint: (className: string) => <EndpointIcon className={cn("text-blue-500", className)} />,
|
||||
"http-endpoint": (className: string) => (
|
||||
<GlobeAltIcon className={cn("text-blue-500", className)} />
|
||||
),
|
||||
runs: (className: string) => <RunsIcon className={cn("text-lime-500", className)} />,
|
||||
};
|
||||
|
||||
export type IconNames = keyof typeof icons;
|
||||
@@ -215,7 +226,15 @@ export function NamedIcon({
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Icon ${name} not found`);
|
||||
if (tablerIcons.has("tabler-" + name)) {
|
||||
return <TablerIcon name={"tabler-" + name} className={className} />;
|
||||
} else if (name.startsWith("tabler-") && tablerIcons.has(name)) {
|
||||
return <TablerIcon name={name} className={className} />;
|
||||
}
|
||||
|
||||
if (name === "supabase-management") {
|
||||
return <NamedIcon name="supabase" className={className} />;
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
@@ -247,3 +266,11 @@ export function NamedIconInBox({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TablerIcon({ name, className }: { name: string; className?: string }) {
|
||||
return (
|
||||
<svg className={cn("stroke-[1.5]", className)}>
|
||||
<use xlinkHref={`${tablerSpritePath}#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Paragraph } from "./Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { NamedIcon } from "./NamedIcon";
|
||||
import { Tabs, TabsProps } from "./Tabs";
|
||||
import { Icon, RenderIcon } from "./Icon";
|
||||
import { Button, LinkButton } from "./Buttons";
|
||||
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
type WithChildren = {
|
||||
children: React.ReactNode;
|
||||
@@ -13,7 +16,7 @@ type WithChildren = {
|
||||
|
||||
export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) {
|
||||
return (
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-uiBorder pb-4")}>
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-ui-border pb-4")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -24,6 +27,7 @@ export function PageTitleRow({ children }: WithChildren) {
|
||||
}
|
||||
|
||||
type PageTitleProps = {
|
||||
icon?: RenderIcon;
|
||||
title: string;
|
||||
backButton?: {
|
||||
to: string;
|
||||
@@ -31,7 +35,7 @@ type PageTitleProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export function PageTitle({ title, backButton }: PageTitleProps) {
|
||||
export function PageTitle({ icon, title, backButton }: PageTitleProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{backButton && (
|
||||
@@ -48,7 +52,10 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
|
||||
<BreadcrumbIcon className="h-6" />
|
||||
</div>
|
||||
)}
|
||||
<Header1>{title}</Header1>
|
||||
<Header1 className="flex items-center gap-1">
|
||||
{icon && <Icon icon={icon} className="h-5 w-5" />}
|
||||
{title}
|
||||
</Header1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +96,28 @@ export function PageInfoProperty({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
to,
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
value: React.ReactNode;
|
||||
to?: string;
|
||||
}) {
|
||||
if (to === undefined) {
|
||||
return <PageInfoPropertyContent icon={icon} label={label} value={value} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkButton variant="tertiary/small" to={to} TrailingIcon={ArrowUpRightIcon}>
|
||||
<PageInfoPropertyContent icon={icon} label={label} value={value} />
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PageInfoPropertyContent({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
|
||||
@@ -51,6 +51,7 @@ const paragraphVariants = {
|
||||
text: "font-sans text-xxs font-normal text-bright",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
|
||||
"extra-extra-small/caps": {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed",
|
||||
spacing: "mb-1",
|
||||
@@ -59,6 +60,10 @@ const paragraphVariants = {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-bright",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
"extra-extra-small/dimmed/caps": {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
};
|
||||
|
||||
export type ParagraphVariant = keyof typeof paragraphVariants;
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { Paragraph, ParagraphVariant } from "./Paragraph";
|
||||
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { LinkButton } from "./Buttons";
|
||||
import { ButtonContentPropsType, LinkButton } from "./Buttons";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
@@ -22,19 +21,27 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
|
||||
style={{
|
||||
maxHeight: "var(--radix-popover-content-available-height)",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
function PopoverSectionHeader({ title }: { title: string }) {
|
||||
function PopoverSectionHeader({
|
||||
title,
|
||||
variant = "extra-extra-small/dimmed/caps",
|
||||
}: {
|
||||
title: string;
|
||||
variant?: ParagraphVariant;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="extra-extra-small/bright/caps" className="bg-slate-900 px-2.5 py-2">
|
||||
<Paragraph variant={variant} className="bg-slate-900 px-2.5 py-1.5">
|
||||
{title}
|
||||
</Paragraph>
|
||||
);
|
||||
@@ -45,28 +52,33 @@ function PopoverMenuItem({
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
}: {
|
||||
to: string;
|
||||
icon: string;
|
||||
icon: string | React.ComponentType<any>;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={to}
|
||||
variant="menu-item"
|
||||
variant={variant.variant}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={leadingIconClassName}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
TrailingIcon={isSelected ? "check" : undefined}
|
||||
className={isSelected ? "bg-slate-750 group-hover:bg-slate-750" : undefined}
|
||||
className={isSelected ? "bg-slate-750 group-hover:bg-slate-800" : undefined}
|
||||
>
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
function PopoverCustomTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
className,
|
||||
@@ -76,15 +88,50 @@ function PopoverArrowTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
"group flex items-center justify-end gap-1 rounded text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Paragraph variant="extra-small" className="transition group-hover:text-bright">
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
fullWidth = false,
|
||||
overflowHidden = false,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
fullWidth?: boolean;
|
||||
overflowHidden?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
fullWidth && "w-full justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className={cn(
|
||||
"flex transition group-hover:text-bright",
|
||||
overflowHidden && "overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Paragraph>
|
||||
<ChevronDownIcon
|
||||
className={cn("h-3 w-3 transition group-hover:text-bright", isOpen && "-rotate-180")}
|
||||
className={cn(
|
||||
"h-3 w-3 min-w-[0.75rem] text-slate-600 transition group-hover:text-bright",
|
||||
isOpen && "-rotate-180"
|
||||
)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
);
|
||||
@@ -113,7 +160,8 @@ export {
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverSectionHeader,
|
||||
PopoverCustomTrigger,
|
||||
PopoverArrowTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
PopoverMenuItem,
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-uiBorder",
|
||||
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-ui-border",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
@@ -154,7 +154,7 @@ const SheetContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-uiBorder p-2">
|
||||
<div className="flex items-center gap-2 border-b border-ui-border p-2">
|
||||
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-slate-800 disabled:pointer-events-none">
|
||||
<NamedIcon name="close" className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivE
|
||||
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-uiBorder py-3.5",
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-ui-border py-3.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -194,7 +194,7 @@ export const SheetFooter = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("shrink-0", className)} {...props}>
|
||||
<div className="mx-4 border-t border-uiBorder py-3">{children}</div>
|
||||
<div className="mx-4 border-t border-ui-border py-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,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 = relevantShortcut.key;
|
||||
const character = keyString(relevantShortcut.key, isMac);
|
||||
|
||||
return (
|
||||
<span className={cn(variants[variant], className)}>
|
||||
@@ -35,6 +35,15 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
);
|
||||
}
|
||||
|
||||
function keyString(key: String, isMac: boolean) {
|
||||
switch (key) {
|
||||
case "enter":
|
||||
return isMac ? "↵" : key;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function modifierString(modifier: Modifier, isMac: boolean) {
|
||||
switch (modifier) {
|
||||
case "alt":
|
||||
@@ -42,8 +51,10 @@ function modifierString(modifier: Modifier, isMac: boolean) {
|
||||
case "ctrl":
|
||||
return isMac ? "⌃" : "Ctrl+";
|
||||
case "meta":
|
||||
return isMac ? "⌘" : "⊞";
|
||||
return isMac ? "⌘" : "⊞+";
|
||||
case "shift":
|
||||
return isMac ? "⇧" : "Shift+";
|
||||
case "mod":
|
||||
return isMac ? "⌘" : "Ctrl+";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Spinner({
|
||||
color = "blue",
|
||||
}: {
|
||||
className?: string;
|
||||
color?: "blue" | "white";
|
||||
color?: "blue" | "white" | "muted";
|
||||
}) {
|
||||
const colors = {
|
||||
blue: {
|
||||
@@ -16,6 +16,10 @@ export function Spinner({
|
||||
light: "rgba(255, 255, 255, 0.4)",
|
||||
dark: "rgba(255, 255, 255)",
|
||||
},
|
||||
muted: {
|
||||
light: "#1C2433",
|
||||
dark: "#3C4B62",
|
||||
},
|
||||
};
|
||||
|
||||
const currentColor = colors[color];
|
||||
|
||||
@@ -15,7 +15,7 @@ const variations = {
|
||||
container: "flex items-center gap-x-1.5 rounded hover:bg-slate-850 pr-1 py-[0.1rem] pl-1.5",
|
||||
root: "h-3 w-6",
|
||||
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-xs text-slate-400 group-hover:text-slate-200 mt-0.5",
|
||||
text: "text-xs text-slate-400 group-hover:text-slate-200 hover:cursor-pointer",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-uiBorder scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-ui-border scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
return (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-uiBorder bg-slate-850", className)}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-ui-border bg-slate-850", className)}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
@@ -55,7 +55,7 @@ type TableBodyProps = {
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className, children }, ref) => {
|
||||
return (
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-uiBorder", className)}>
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-ui-border", className)}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle text-xs font-normal uppercase tracking-wider text-dimmed",
|
||||
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-dimmed",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type TabsProps = {
|
||||
|
||||
export function Tabs({ tabs, className }: TabsProps) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-uiBorder`, className)}>
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import toast, { Toaster, resolveValue, useToasterStore } from "react-hot-toast";
|
||||
import { Toaster, toast } from "sonner";
|
||||
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useEffect } from "react";
|
||||
@@ -11,79 +11,55 @@ const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
|
||||
export function Toast() {
|
||||
const { toastMessage } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastMessage) {
|
||||
return;
|
||||
}
|
||||
const { message, type, options } = toastMessage;
|
||||
|
||||
switch (type) {
|
||||
case "success":
|
||||
toast.success(message, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
toast.error(message, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`${type} is not handled`);
|
||||
}
|
||||
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
}, [toastMessage]);
|
||||
|
||||
return <Toaster />;
|
||||
}
|
||||
|
||||
export function ToastUI({
|
||||
variant,
|
||||
message,
|
||||
t,
|
||||
toastWidth = 356, // Default width, matches what sonner provides by default
|
||||
}: {
|
||||
variant: "error" | "success";
|
||||
message: string;
|
||||
t: string;
|
||||
toastWidth?: string | number;
|
||||
}) {
|
||||
return (
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
success: {
|
||||
icon: <CheckCircleIcon className="h-6 w-6 text-green-600" />,
|
||||
},
|
||||
error: {
|
||||
icon: <ExclamationCircleIcon className="h-6 w-6 text-rose-600" />,
|
||||
},
|
||||
<div
|
||||
className={`self-end rounded-lg border border-slate-750 bg-midnight-900 shadow-md`}
|
||||
style={{
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
{(t) => (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="flex gap-2 rounded-lg border border-slate-750 bg-no-repeat p-4 text-bright shadow-md"
|
||||
style={{
|
||||
opacity: t.visible ? 1 : 0,
|
||||
background:
|
||||
"radial-gradient(at top, hsla(271, 91%, 65%, 0.18), hsla(221, 83%, 53%, 0.18)) hsla(221, 83%, 53%, 0.18)",
|
||||
}}
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={t.visible ? "visible" : "hidden"}
|
||||
variants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t.icon}
|
||||
{resolveValue(t.message, t)}
|
||||
<button className="p-1" onClick={() => toast.dismiss(t.id)}>
|
||||
<XMarkIcon className="h-4 w-4 text-bright" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</Toaster>
|
||||
<div
|
||||
className="flex w-full gap-2 rounded-lg bg-no-repeat p-4 text-bright"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(at top, hsla(271, 91%, 65%, 0.18), hsla(221, 83%, 53%, 0.18)) hsla(221, 83%, 53%, 0.18)",
|
||||
}}
|
||||
>
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-600" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="h-6 w-6 text-rose-600" />
|
||||
)}
|
||||
{message}
|
||||
<button className="ms-auto p-1" onClick={() => toast.dismiss(t)}>
|
||||
<XMarkIcon className="h-4 w-4 text-bright" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.0 MiB |
@@ -36,7 +36,7 @@ export function RunPanel({
|
||||
? "border-slate-850"
|
||||
: "border-slate-900",
|
||||
onClick && "cursor-pointer",
|
||||
onClick && !selected && "hover:border-green-500/30",
|
||||
onClick && !selected && "hover:border-slate-500/30",
|
||||
className
|
||||
)}
|
||||
onClick={() => onClick && onClick()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BoltIcon, ForwardIcon } from "@heroicons/react/24/solid";
|
||||
import { PlayIcon } from "@heroicons/react/20/solid";
|
||||
import { BoltIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
Form,
|
||||
Outlet,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { useMemo } from "react";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { Run } from "~/presenters/RunPresenter.server";
|
||||
import { ViewRun } from "~/presenters/RunPresenter.server";
|
||||
import { cancelSchema } from "~/routes/resources.runs.$runId.cancel";
|
||||
import { schema } from "~/routes/resources.runs.$runId.rerun";
|
||||
import { formatDuration } from "~/utils";
|
||||
@@ -59,7 +60,7 @@ import { TaskCard } from "./TaskCard";
|
||||
import { TaskCardSkeleton } from "./TaskCardSkeleton";
|
||||
|
||||
type RunOverviewProps = {
|
||||
run: Run;
|
||||
run: ViewRun;
|
||||
trigger: {
|
||||
icon: string;
|
||||
title: string;
|
||||
@@ -159,6 +160,17 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
<div className="grid h-full grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-6 overflow-y-auto py-4 pl-4 pr-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div>
|
||||
{run.status === "SUCCESS" &&
|
||||
(run.tasks.length === 0 || run.tasks.every((t) => t.noop)) && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
to="https://trigger.dev/docs/documentation/concepts/tasks"
|
||||
className="mb-4"
|
||||
>
|
||||
This Run completed but it did not use any Tasks – this can cause unpredictable
|
||||
results. Read the docs to view the solution.
|
||||
</Callout>
|
||||
)}
|
||||
<Header2 className="mb-2">Trigger</Header2>
|
||||
<RunPanel
|
||||
selected={selectedId === "trigger"}
|
||||
@@ -251,7 +263,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
<CodeBlock language="json" code={run.output} maxLines={10} />
|
||||
) : (
|
||||
run.output === null && (
|
||||
<Paragraph variant="small">This Run returned nothing.</Paragraph>
|
||||
<Paragraph variant="small" className="mt-4">
|
||||
This Run returned nothing.
|
||||
</Paragraph>
|
||||
)
|
||||
)}
|
||||
</RunPanelBody>
|
||||
@@ -262,7 +276,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
|
||||
{/* Detail view */}
|
||||
<div className="overflow-y-auto py-4 pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<Header2 className="mb-2">Detail</Header2>
|
||||
<Header2 className="mb-2">Details</Header2>
|
||||
{selectedId ? <Outlet /> : <Callout variant="info">Select a task or trigger</Callout>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +327,8 @@ function RerunPopover({
|
||||
|
||||
const [form, { successRedirect }] = useForm({
|
||||
id: "rerun",
|
||||
lastSubmission,
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
@@ -326,50 +341,52 @@ function RerunPopover({
|
||||
Rerun Job
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
|
||||
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
|
||||
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
|
||||
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
|
||||
{environmentType === "PRODUCTION" && (
|
||||
<Callout variant="warning" className="mb-2">
|
||||
This will rerun this Job in your Production environment.
|
||||
</Callout>
|
||||
<div className="px-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
This will rerun this Job in your Production environment.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-4 divide-y divide-slate-600">
|
||||
<div>
|
||||
<div className="flex flex-col items-start divide-y divide-slate-800">
|
||||
<div className="p-4">
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Start a brand new Job run with the same Trigger data as this one. This will re-do
|
||||
every Task.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
variant="secondary/medium"
|
||||
type="submit"
|
||||
name={conform.INTENT}
|
||||
value="start"
|
||||
fullWidth
|
||||
LeadingIcon={BoltIcon}
|
||||
className="text-bright"
|
||||
>
|
||||
<BoltIcon className="mr-1 h-3.5 w-3.5 text-bright" />
|
||||
Run again
|
||||
</Button>
|
||||
|
||||
<Paragraph variant="extra-small" className="mt-2">
|
||||
Start a brand new job run with the same Trigger data as this one. This will re-do
|
||||
every task.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{status === "FAILED" && (
|
||||
<div className="pt-4">
|
||||
<div className="p-4">
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Continue running this Job run from where it left off. This will skip any Task that
|
||||
has already been completed.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
variant="secondary/medium"
|
||||
type="submit"
|
||||
name={conform.INTENT}
|
||||
value="continue"
|
||||
fullWidth
|
||||
LeadingIcon={ForwardIcon}
|
||||
className="text-bright"
|
||||
>
|
||||
Retry job run
|
||||
<PlayIcon className="mr-1 h-3.5 w-3.5 text-bright" />
|
||||
Retry Job run
|
||||
</Button>
|
||||
|
||||
<Paragraph variant="extra-small" className="mt-2">
|
||||
Continue running this job run from where it left off. This will skip any task that
|
||||
has already been completed.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,7 +403,8 @@ export function CancelRun({ runId }: { runId: string }) {
|
||||
|
||||
const [form, { redirectUrl }] = useForm({
|
||||
id: "cancel-run",
|
||||
lastSubmission,
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: cancelSchema });
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Fragment, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Task } from "~/presenters/RunPresenter.server";
|
||||
import { ViewTask } from "~/presenters/RunPresenter.server";
|
||||
import { formatDuration } from "~/utils";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "./RunCard";
|
||||
import { TaskStatusIcon } from "./TaskStatus";
|
||||
|
||||
type TaskCardProps = Task & {
|
||||
type TaskCardProps = ViewTask & {
|
||||
selectedId?: string;
|
||||
selectedTask: (id: string) => void;
|
||||
isLast: boolean;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "../primitives/Table";
|
||||
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
|
||||
import { TaskStatusIcon } from "./TaskStatus";
|
||||
import { ClientOnly } from "remix-utils";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TriggerDetail({
|
||||
};
|
||||
properties: DisplayProperty[];
|
||||
}) {
|
||||
const { id, name, payload, timestamp, deliveredAt } = trigger;
|
||||
const { id, name, payload, context, timestamp, deliveredAt } = trigger;
|
||||
|
||||
return (
|
||||
<RunPanel selected={false}>
|
||||
@@ -45,6 +45,7 @@ export function TriggerDetail({
|
||||
/>
|
||||
)}
|
||||
<RunPanelIconProperty icon="id" label="Event name" value={name} />
|
||||
<RunPanelIconProperty icon="account" label="Event ID" value={id} />
|
||||
{trigger.externalAccount && (
|
||||
<RunPanelIconProperty
|
||||
icon="account"
|
||||
@@ -62,7 +63,9 @@ export function TriggerDetail({
|
||||
</div>
|
||||
)}
|
||||
<Header3>Payload</Header3>
|
||||
<CodeBlock code={JSON.stringify(payload, null, 2)} />
|
||||
<CodeBlock code={payload} />
|
||||
<Header3>Context</Header3>
|
||||
<CodeBlock code={context} />
|
||||
</div>
|
||||
</RunPanelBody>
|
||||
</RunPanel>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { RunList } from "~/presenters/RunListPresenter.server";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { formatDuration } from "~/utils";
|
||||
import { JobForPath, OrgForPath, ProjectForPath, jobRunDashboardPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -21,7 +17,6 @@ import {
|
||||
TableRow,
|
||||
} from "../primitives/Table";
|
||||
import { RunStatus } from "./RunStatuses";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
|
||||
type RunTableItem = {
|
||||
id: string;
|
||||
@@ -129,9 +124,7 @@ export function RunsTable({
|
||||
function NoRuns({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Callout variant="warning" className="w-auto">
|
||||
{title}
|
||||
</Callout>
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DetailCell } from "../primitives/DetailCell";
|
||||
import { ClockIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
|
||||
import { DateTime, DateTimeAccurate } from "../primitives/DateTime";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/DetailCells",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Examples>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => <Examples />,
|
||||
};
|
||||
|
||||
function Examples() {
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col items-start gap-y-8 p-8">
|
||||
<DetailCell
|
||||
leadingIcon="integration"
|
||||
leadingIconClassName="text-dimmed"
|
||||
label="Learn how to create your own API Integrations"
|
||||
variant="base"
|
||||
trailingIcon="external-link"
|
||||
trailingIconClassName="text-slate-700 group-hover:text-bright"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
label="Issue comment created"
|
||||
trailingIcon="check"
|
||||
trailingIconClassName="text-green-500 group-hover:text-green-400"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={ClockIcon}
|
||||
leadingIconClassName="text-slate-400"
|
||||
label={<DateTime date={new Date()} />}
|
||||
description="Run #42 complete"
|
||||
trailingIcon="plus"
|
||||
trailingIconClassName="text-slate-500 group-hover:text-bright"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { withDesign } from "storybook-addon-designs";
|
||||
import { NamedIcon, iconNames } from "../primitives/NamedIcon";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
|
||||
const meta: Meta<typeof NamedIcons> = {
|
||||
title: "Icons",
|
||||
@@ -25,17 +27,35 @@ export const Basic: Story = {
|
||||
|
||||
function NamedIcons() {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{iconNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6"} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Header1 spacing>Internal</Header1>
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{iconNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6"} />
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Header1 spacing>Tabler</Header1>
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{Array.from(tablerIcons).map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6 text-indigo-500"} />
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const shortcuts: ShortcutDefinition[] = [
|
||||
{ key: "f", modifiers: ["meta"] },
|
||||
{ key: "k", modifiers: ["meta"] },
|
||||
{ key: "del", modifiers: ["ctrl", "alt"] },
|
||||
{ key: "enter", modifiers: ["meta"] },
|
||||
{ key: "enter", modifiers: ["mod"] },
|
||||
];
|
||||
|
||||
function Collection() {
|
||||
@@ -67,6 +69,9 @@ function Set({ platform }: { platform: "mac" | "windows" }) {
|
||||
<Button variant="danger/medium" shortcut={shortcut}>
|
||||
Danger medium
|
||||
</Button>
|
||||
<Button variant="danger/medium" shortcut={shortcut}>
|
||||
Danger medium
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</OperatingSystemContextProvider>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { ToastUI } from "../primitives/Toast";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/Toast",
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Collection>;
|
||||
|
||||
export const Toasts: Story = {
|
||||
render: () => <Collection />,
|
||||
};
|
||||
|
||||
function Collection() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-4 p-4">
|
||||
<ToastUI variant="success" message="Success UI" t="-" />
|
||||
<ToastUI variant="error" message="Error UI" t="-" />
|
||||
<br />
|
||||
<Button
|
||||
variant="primary/large"
|
||||
onClick={() =>
|
||||
toast.custom((t) => <ToastUI variant="success" message="Success" t={t as string} />, {
|
||||
duration: Infinity, // Prevents auto-dismissal for demo purposes
|
||||
})
|
||||
}
|
||||
>
|
||||
Success
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary/large"
|
||||
onClick={() =>
|
||||
toast.custom((t) => <ToastUI variant="error" message="Error" t={t as string} />, {
|
||||
duration: Infinity,
|
||||
})
|
||||
}
|
||||
>
|
||||
Error
|
||||
</Button>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,6 @@ export const MAX_CONCURRENT_RUNS_LIMIT = 20;
|
||||
export const PREPROCESS_RETRY_LIMIT = 2;
|
||||
export const EXECUTE_JOB_RETRY_LIMIT = 10;
|
||||
export const MAX_RUN_YIELDED_EXECUTIONS = 100;
|
||||
export const RUN_CHUNK_EXECUTION_BUFFER = 350;
|
||||
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
|
||||
export const RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
|
||||
|
||||
@@ -3,6 +3,7 @@ import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { env } from "./env.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
|
||||
export type PrismaTransactionClient = Omit<
|
||||
PrismaClient,
|
||||
@@ -66,24 +67,7 @@ export async function $transaction<R>(
|
||||
|
||||
export { Prisma };
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
declare global {
|
||||
var __db__: PrismaClient;
|
||||
}
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
// the server with every change, but we want to make sure we don't
|
||||
// create a new connection to the DB with every change either.
|
||||
// in production we'll have a single connection to the DB.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
prisma = getClient();
|
||||
} else {
|
||||
if (!global.__db__) {
|
||||
global.__db__ = getClient();
|
||||
}
|
||||
prisma = global.__db__;
|
||||
}
|
||||
export const prisma = singleton("prisma", getClient);
|
||||
|
||||
function getClient() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
@@ -143,7 +127,6 @@ function getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
export { prisma };
|
||||
export type { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
export const PrismaErrorSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { H } from "@highlight-run/node";
|
||||
import type { DataFunctionArgs, EntryContext, Headers } from "@remix-run/node"; // or cloudflare/deno
|
||||
import { Response } from "@remix-run/node"; // or cloudflare/deno
|
||||
import {
|
||||
createReadableStreamFromReadable,
|
||||
type DataFunctionArgs,
|
||||
type EntryContext,
|
||||
} from "@remix-run/node"; // or cloudflare/deno
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { parseAcceptLanguage } from "intl-parse-accept-language";
|
||||
import isbot from "isbot";
|
||||
@@ -36,7 +39,7 @@ export default function handleRequest(
|
||||
// response to render before sending it to the client. This
|
||||
// ensures that bots can see the full page content.
|
||||
if (isbot(request.headers.get("user-agent"))) {
|
||||
return serveTheBots(
|
||||
return handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
@@ -46,7 +49,7 @@ export default function handleRequest(
|
||||
);
|
||||
}
|
||||
|
||||
return serveBrowsers(
|
||||
return handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
@@ -56,7 +59,7 @@ export default function handleRequest(
|
||||
);
|
||||
}
|
||||
|
||||
function serveTheBots(
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
@@ -65,76 +68,97 @@ function serveTheBots(
|
||||
platform: OperatingSystemPlatform
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<OperatingSystemContextProvider platform={platform}>
|
||||
<LocaleContextProvider locales={locales}>
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
</LocaleContextProvider>
|
||||
</OperatingSystemContextProvider>,
|
||||
{
|
||||
// Use onAllReady to wait for the entire document to be ready
|
||||
onAllReady() {
|
||||
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
||||
let body = new PassThrough();
|
||||
pipe(body);
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status: responseStatusCode,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
);
|
||||
},
|
||||
onShellError(err: unknown) {
|
||||
reject(err);
|
||||
},
|
||||
}
|
||||
);
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
function serveBrowsers(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
locales: string[],
|
||||
platform: OperatingSystemPlatform
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let didError = false;
|
||||
let shellReady = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<OperatingSystemContextProvider platform={platform}>
|
||||
<LocaleContextProvider locales={locales}>
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
|
||||
</LocaleContextProvider>
|
||||
</OperatingSystemContextProvider>,
|
||||
{
|
||||
// use onShellReady to wait until a suspense boundary is triggered
|
||||
onShellReady() {
|
||||
shellReady = true;
|
||||
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
||||
let body = new PassThrough();
|
||||
pipe(body);
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status: didError ? 500 : responseStatusCode,
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(err: unknown) {
|
||||
reject(err);
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
didError = true;
|
||||
if (shellReady) {
|
||||
logError(error, request);
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
locales: string[],
|
||||
platform: OperatingSystemPlatform
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<OperatingSystemContextProvider platform={platform}>
|
||||
<LocaleContextProvider locales={locales}>
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />
|
||||
</LocaleContextProvider>
|
||||
</OperatingSystemContextProvider>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
|
||||
import { isValidRegex } from "./utils/regex";
|
||||
|
||||
const EnvironmentSchema = z.object({
|
||||
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
|
||||
@@ -10,6 +11,7 @@ const EnvironmentSchema = z.object({
|
||||
SESSION_SECRET: z.string(),
|
||||
MAGIC_LINK_SECRET: z.string(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
WHITELISTED_EMAILS: z.string().refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.").optional(),
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
@@ -40,6 +42,7 @@ const EnvironmentSchema = z.object({
|
||||
EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
WORKER_ENABLED: z.string().default("true"),
|
||||
EXECUTION_WORKER_ENABLED: z.string().default("true"),
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { MatchedProject, useOptionalProject } from "./useProject";
|
||||
import { useUser } from "./useUser";
|
||||
|
||||
export type ProjectJobEnvironment = MatchedProject["environments"][number];
|
||||
|
||||
export function useEnvironments(matches?: RouteMatch[]) {
|
||||
export function useEnvironments(matches?: UIMatch[]) {
|
||||
const project = useOptionalProject(matches);
|
||||
if (!project) return;
|
||||
|
||||
return project.environments;
|
||||
}
|
||||
|
||||
export function useDevEnvironment(matches?: RouteMatch[]) {
|
||||
export function useDevEnvironment(matches?: UIMatch[]) {
|
||||
const user = useUser();
|
||||
const environments = useEnvironments(matches);
|
||||
if (!environments) return;
|
||||
|
||||
return environments.find((environment) => environment.type === "DEVELOPMENT");
|
||||
return environments.find(
|
||||
(environment) => environment.type === "DEVELOPMENT" && environment.userId === user.id
|
||||
);
|
||||
}
|
||||
|
||||
export function useProdEnvironment(matches?: RouteMatch[]) {
|
||||
export function useProdEnvironment(matches?: UIMatch[]) {
|
||||
const environments = useEnvironments(matches);
|
||||
if (!environments) return;
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { ProjectJob } from "./useJobs";
|
||||
import { ProjectJob } from "~/presenters/JobListPresenter.server";
|
||||
import { useTextFilter } from "./useTextFilter";
|
||||
import { useToggleFilter } from "./useToggleFilter";
|
||||
|
||||
export function useFilterJobs(jobs: ProjectJob[]) {
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<ProjectJob>({
|
||||
export function useFilterJobs(jobs: ProjectJob[], onlyActiveJobs = false) {
|
||||
const toggleFilterRes = useToggleFilter<ProjectJob>({
|
||||
items: jobs,
|
||||
filter: (job, onlyActiveJobs) => {
|
||||
if (onlyActiveJobs && job.status !== "ACTIVE") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
defaultValue: onlyActiveJobs,
|
||||
});
|
||||
|
||||
const textFilterRes = useTextFilter<ProjectJob>({
|
||||
items: toggleFilterRes.filteredItems,
|
||||
filter: (job, text) => {
|
||||
if (job.slug.toLowerCase().includes(text.toLowerCase())) return true;
|
||||
if (job.title.toLowerCase().includes(text.toLowerCase())) return true;
|
||||
@@ -24,5 +36,11 @@ export function useFilterJobs(jobs: ProjectJob[]) {
|
||||
},
|
||||
});
|
||||
|
||||
return { filterText, setFilterText, filteredItems };
|
||||
return {
|
||||
filteredItems: textFilterRes.filteredItems,
|
||||
filterText: textFilterRes.filterText,
|
||||
setFilterText: textFilterRes.setFilterText,
|
||||
onlyActiveJobs: toggleFilterRes.isToggleActive,
|
||||
setOnlyActiveJobs: toggleFilterRes.setToggleActive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam/route";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedClient = UseDataFunctionReturn<typeof loader>["client"];
|
||||
|
||||
export function useOptionalIntegrationClient(matches?: RouteMatch[]) {
|
||||
export function useOptionalIntegrationClient(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam",
|
||||
id: "routes/_app.orgs.$organizationSlug.integrations_.$clientParam",
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.client;
|
||||
}
|
||||
|
||||
export function useIntegrationClient(matches?: RouteMatch[]) {
|
||||
export function useIntegrationClient(matches?: UIMatch[]) {
|
||||
const integration = useOptionalIntegrationClient(matches);
|
||||
invariant(integration, "Integration must be defined");
|
||||
return integration;
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import { RouteMatch, useMatches } from "@remix-run/react";
|
||||
import { UIMatch, useMatches } from "@remix-run/react";
|
||||
|
||||
export function useIsProjectChildPage(matches?: RouteMatch[]) {
|
||||
export function useIsOrgChildPage(matches?: UIMatch[]) {
|
||||
if (!matches) {
|
||||
matches = useMatches();
|
||||
}
|
||||
|
||||
return matches.some((matchData) => {
|
||||
return matchData.id.startsWith("routes/_app.orgs.$organizationSlug.projects.$projectParam");
|
||||
return matchData.id.startsWith("routes/_app.orgs.$organizationSlug");
|
||||
});
|
||||
}
|
||||
@@ -2,27 +2,28 @@ import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
|
||||
|
||||
export const jobMatchId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
|
||||
export function useOptionalJob(matches?: RouteMatch[]) {
|
||||
|
||||
export function useOptionalJob(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: jobMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
if (!routeMatch || !routeMatch.job) {
|
||||
if (!routeMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return routeMatch.projectJobs.find((j) => j.id === routeMatch.job.id);
|
||||
return routeMatch.job;
|
||||
}
|
||||
|
||||
export function useJob(matches?: RouteMatch[]) {
|
||||
export function useJob(matches?: UIMatch[]) {
|
||||
const job = useOptionalJob(matches);
|
||||
invariant(job, "Job must be defined");
|
||||
return job;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route";
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type ProjectJob = UseDataFunctionReturn<typeof loader>["projectJobs"][number];
|
||||
|
||||
export const jobsMatchId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
|
||||
|
||||
// This is only used in the JobsMenu component, which is the breadcrumb job list dropdown.
|
||||
// This dropdown is only shown once you have selected a job, so we can assume that
|
||||
// the route above has loaded and we can use the data from it.
|
||||
export function useOptionalJobs(matches?: RouteMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: jobsMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.projectJobs;
|
||||
}
|
||||
|
||||
export function useJobs(matches?: RouteMatch[]) {
|
||||
const jobs = useOptionalJobs(matches);
|
||||
invariant(jobs, "Jobs must be defined");
|
||||
return jobs;
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import type { loader as appLoader } from "~/routes/_app/route";
|
||||
import { hydrateObject, useMatchesData } from "~/utils";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof appLoader>["organizations"][number];
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
|
||||
export const organizationMatchId = "routes/_app.orgs.$organizationSlug";
|
||||
|
||||
export function useOptionalOrganizations(matches?: RouteMatch[]) {
|
||||
const data = useTypedMatchesData<typeof appLoader>({
|
||||
id: "routes/_app",
|
||||
export function useOptionalOrganizations(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.organizations;
|
||||
}
|
||||
|
||||
export function useOrganizations(matches?: RouteMatch[]) {
|
||||
export function useOrganizations(matches?: UIMatch[]) {
|
||||
const orgs = useOptionalOrganizations(matches);
|
||||
invariant(orgs, "No organizations found in loader.");
|
||||
return orgs;
|
||||
}
|
||||
|
||||
export function useOptionalOrganization(matches?: RouteMatch[]) {
|
||||
export function useOptionalOrganization(matches?: UIMatch[]) {
|
||||
const orgs = useOptionalOrganizations(matches);
|
||||
const org = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
@@ -37,13 +37,13 @@ export function useOptionalOrganization(matches?: RouteMatch[]) {
|
||||
return orgs.find((o) => o.id === org.organization.id);
|
||||
}
|
||||
|
||||
export function useOrganization(matches?: RouteMatch[]) {
|
||||
export function useOrganization(matches?: UIMatch[]) {
|
||||
const org = useOptionalOrganization(matches);
|
||||
invariant(org, "No organization found in loader.");
|
||||
return org;
|
||||
}
|
||||
|
||||
export function useIsNewOrganizationPage(matches?: RouteMatch[]): boolean {
|
||||
export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean {
|
||||
const data = useTypedMatchesData<any>({
|
||||
id: "routes/_app.orgs.new",
|
||||
matches,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
|
||||
@@ -9,7 +9,7 @@ export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
|
||||
|
||||
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
|
||||
|
||||
export function useOptionalProject(matches?: RouteMatch[]) {
|
||||
export function useOptionalProject(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: projectMatchId,
|
||||
matches,
|
||||
@@ -18,7 +18,7 @@ export function useOptionalProject(matches?: RouteMatch[]) {
|
||||
return routeMatch?.project;
|
||||
}
|
||||
|
||||
export function useProject(matches?: RouteMatch[]) {
|
||||
export function useProject(matches?: UIMatch[]) {
|
||||
const project = useOptionalProject(matches);
|
||||
invariant(project, "Project must be defined");
|
||||
return project;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEventSource } from "remix-utils";
|
||||
import { useEventSource } from "remix-utils/sse/react";
|
||||
import { projectPath, projectStreamingPath } from "~/utils/pathBuilder";
|
||||
import { useProject } from "./useProject";
|
||||
import { useOrganization } from "./useOrganizations";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route";
|
||||
@@ -7,7 +7,7 @@ import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedRun = UseDataFunctionReturn<typeof runLoader>["run"];
|
||||
|
||||
export function useOptionalRun(matches?: RouteMatch[]) {
|
||||
export function useOptionalRun(matches?: UIMatch[]) {
|
||||
const project = useOptionalProject(matches);
|
||||
const routeMatch = useTypedMatchesData<typeof runLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam",
|
||||
@@ -21,7 +21,7 @@ export function useOptionalRun(matches?: RouteMatch[]) {
|
||||
return routeMatch.run;
|
||||
}
|
||||
|
||||
export function useRun(matches?: RouteMatch[]) {
|
||||
export function useRun(matches?: UIMatch[]) {
|
||||
const run = useOptionalRun(matches);
|
||||
invariant(run, "Run must be present");
|
||||
return run;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider";
|
||||
|
||||
export type Modifier = "alt" | "ctrl" | "meta" | "shift";
|
||||
export type Modifier = "alt" | "ctrl" | "meta" | "shift" | "mod";
|
||||
|
||||
export type Shortcut = {
|
||||
key: string;
|
||||
modifiers?: Modifier[];
|
||||
enabledOnInputElements?: boolean;
|
||||
};
|
||||
|
||||
export type ShortcutDefinition =
|
||||
@@ -20,19 +20,31 @@ type useShortcutKeysProps = {
|
||||
shortcut: ShortcutDefinition;
|
||||
action: (event: KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
enabledOnInputElements?: boolean;
|
||||
};
|
||||
|
||||
export function useShortcutKeys({ shortcut, action, disabled = false }: useShortcutKeysProps) {
|
||||
const keys = createKeysFromShortcut(shortcut);
|
||||
useHotkeys(keys, action, { enabled: !disabled });
|
||||
}
|
||||
|
||||
function createKeysFromShortcut(shortcut: ShortcutDefinition) {
|
||||
const { platform } = useOperatingSystem();
|
||||
const isMac = platform === "mac";
|
||||
let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
const modifiers = relevantShortcut.modifiers;
|
||||
const character = relevantShortcut.key;
|
||||
const relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
|
||||
return modifiers ? modifiers.map((k) => k).join("+") + "+" : "" + character;
|
||||
const keys = createKeysFromShortcut(relevantShortcut);
|
||||
useHotkeys(
|
||||
keys,
|
||||
(event, hotkeysEvent) => {
|
||||
action(event);
|
||||
},
|
||||
{
|
||||
enabled: !disabled,
|
||||
enableOnFormTags: relevantShortcut.enabledOnInputElements,
|
||||
enableOnContentEditable: relevantShortcut.enabledOnInputElements,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createKeysFromShortcut(shortcut: Shortcut) {
|
||||
const modifiers = shortcut.modifiers;
|
||||
const character = shortcut.key;
|
||||
|
||||
return modifiers ? modifiers.map((k) => k).join("+") + "+" + character : character;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type ToggleFilterProps<T> = {
|
||||
items: T[];
|
||||
filter: (item: T, isToggleActive: boolean) => boolean;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export function useToggleFilter<T>({ items, filter, defaultValue = false }: ToggleFilterProps<T>) {
|
||||
const [isToggleActive, setToggleActive] = useState(defaultValue);
|
||||
|
||||
const filteredItems = useMemo<T[]>(() => {
|
||||
return items.filter((item) => filter(item, isToggleActive));
|
||||
}, [items, isToggleActive]);
|
||||
|
||||
return {
|
||||
isToggleActive,
|
||||
setToggleActive,
|
||||
filteredItems,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteMatch, useMatches } from "@remix-run/react";
|
||||
import { UIMatch, useMatches } from "@remix-run/react";
|
||||
import { RemixSerializedType, UseDataFunctionReturn, deserializeRemix } from "remix-typedjson";
|
||||
|
||||
type AppData = any;
|
||||
@@ -8,7 +8,7 @@ function useTypedDataFromMatches<T = AppData>({
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches: RouteMatch[];
|
||||
matches: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
const match = matches.find((m) => m.id === id);
|
||||
return useTypedMatchData<T>(match);
|
||||
@@ -19,7 +19,7 @@ export function useTypedMatchesData<T = AppData>({
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches?: RouteMatch[];
|
||||
matches?: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
if (!matches) {
|
||||
matches = useMatches();
|
||||
@@ -29,7 +29,7 @@ export function useTypedMatchesData<T = AppData>({
|
||||
}
|
||||
|
||||
export function useTypedMatchData<T = AppData>(
|
||||
match: RouteMatch | undefined
|
||||
match: UIMatch | undefined
|
||||
): UseDataFunctionReturn<T> | undefined {
|
||||
if (!match) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { User } from "~/models/user.server";
|
||||
import { useMatchesData } from "~/utils";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { loader } from "~/root";
|
||||
|
||||
export function useOptionalUser(matches?: RouteMatch[]): User | undefined {
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
matches,
|
||||
@@ -14,7 +14,7 @@ export function useOptionalUser(matches?: RouteMatch[]): User | undefined {
|
||||
return routeMatch?.user ?? undefined;
|
||||
}
|
||||
|
||||
export function useUser(matches?: RouteMatch[]): User {
|
||||
export function useUser(matches?: UIMatch[]): User {
|
||||
const maybeUser = useOptionalUser(matches);
|
||||
if (!maybeUser) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { RuntimeEnvironmentType, type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const apiKeyId = customAlphabet(
|
||||
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
12
|
||||
);
|
||||
|
||||
type RegenerateAPIKeyInput = {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
// check if the user is part of the org
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
id: environment.organization.id,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
|
||||
// check if it is the user's dev environment
|
||||
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
|
||||
if (!environment.orgMemberId) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
|
||||
const orgMember = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
userId: userId,
|
||||
id: environment.orgMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgMember) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
}
|
||||
|
||||
// generate and store new keys
|
||||
const newApiKey = createApiKeyForEnv(environment.type);
|
||||
const newPkApiKey = createPkApiKeyForEnv(environment.type);
|
||||
|
||||
const updatedEnviroment = await prisma.runtimeEnvironment.update({
|
||||
data: {
|
||||
apiKey: newApiKey,
|
||||
pkApiKey: newPkApiKey,
|
||||
},
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedEnviroment;
|
||||
}
|
||||
|
||||
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
export function envSlug(environmentType: RuntimeEnvironment["type"]) {
|
||||
switch (environmentType) {
|
||||
case "DEVELOPMENT": {
|
||||
return "dev";
|
||||
}
|
||||
case "PRODUCTION": {
|
||||
return "prod";
|
||||
}
|
||||
case "STAGING": {
|
||||
return "stg";
|
||||
}
|
||||
case "PREVIEW": {
|
||||
return "prev";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { prisma } from "~/db.server";
|
||||
import { Prettify } from "~/lib.es5";
|
||||
|
||||
@@ -18,3 +19,14 @@ export async function findEndpoint(id: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function detectResponseIsTimeout(response?: Response) {
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
|
||||
response.headers.get("x-vercel-error") === "FUNCTION_INVOCATION_TIMEOUT"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const IndexEndpointStatsSchema = z.object({
|
||||
jobs: z.number(),
|
||||
sources: z.number(),
|
||||
dynamicTriggers: z.number(),
|
||||
dynamicSchedules: z.number(),
|
||||
});
|
||||
|
||||
export type IndexEndpointStats = z.infer<typeof IndexEndpointStatsSchema>;
|
||||
|
||||
export function parseEndpointIndexStats(stats: unknown): IndexEndpointStats {
|
||||
return IndexEndpointStatsSchema.parse(stats);
|
||||
}
|
||||
@@ -1,62 +1,47 @@
|
||||
import { JobRun, JobRunExecution } from "@trigger.dev/database";
|
||||
import { JobRun } from "@trigger.dev/database";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { executionWorker } from "~/services/worker.server";
|
||||
|
||||
export async function enqueueRunExecutionV1(
|
||||
execution: JobRunExecution,
|
||||
queueId: string,
|
||||
concurrency: number,
|
||||
tx: PrismaClientOrTransaction,
|
||||
runAt?: Date
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: execution.id,
|
||||
},
|
||||
{
|
||||
queueName: `job:queue:${queueId}`,
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `execution:${execution.runId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export type EnqueueRunExecutionV2Options = {
|
||||
runAt?: Date;
|
||||
resumeTaskId?: string;
|
||||
isRetry?: boolean;
|
||||
skipRetrying?: boolean;
|
||||
executionCount?: number;
|
||||
};
|
||||
|
||||
export async function enqueueRunExecutionV2(
|
||||
run: JobRun,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options: EnqueueRunExecutionV2Options = {}
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecutionV2",
|
||||
{
|
||||
id: run.id,
|
||||
reason: run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB",
|
||||
resumeTaskId: options.resumeTaskId,
|
||||
isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
jobKey: `job_run:${run.id}:${options.executionCount ?? 0}${
|
||||
options.resumeTaskId ? `:task:${options.resumeTaskId}` : ""
|
||||
}`,
|
||||
maxAttempts: options.skipRetrying ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
return await executionWorker.dequeue(`job_run:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
|
||||
export type EnqueueRunExecutionV3Options = {
|
||||
runAt?: Date;
|
||||
skipRetrying?: boolean;
|
||||
};
|
||||
|
||||
export async function enqueueRunExecutionV3(
|
||||
run: JobRun,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options: EnqueueRunExecutionV3Options = {}
|
||||
) {
|
||||
const reason = run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB";
|
||||
|
||||
return await executionWorker.enqueue(
|
||||
"performRunExecutionV3",
|
||||
{
|
||||
id: run.id,
|
||||
reason: reason,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
queueName: `job_run:${run.id}`,
|
||||
jobKey: `job_run:${reason}:${run.id}`,
|
||||
maxAttempts: options.skipRetrying ? 1 : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function dequeueRunExecutionV3(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
|
||||
await executionWorker.dequeue(`job_run:PREPROCESS:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,60 +9,12 @@ import { customAlphabet } from "nanoid";
|
||||
import slug from "slug";
|
||||
import { prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { createProject } from "./project.server";
|
||||
import { generate } from "random-words";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
||||
|
||||
export type { Organization };
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdef", 4);
|
||||
const apiKeyId = customAlphabet(
|
||||
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
12
|
||||
);
|
||||
|
||||
export function getOrganizationFromSlug({
|
||||
userId,
|
||||
slug,
|
||||
}: Pick<Organization, "slug"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
return prisma.organization.findFirst({
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
where: { slug, members: { some: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrganizations({ userId }: { userId: User["id"] }) {
|
||||
return prisma.organization.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
environments: {
|
||||
orderBy: { slug: "asc" },
|
||||
},
|
||||
projects: {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
jobs: {
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrganization(
|
||||
{
|
||||
@@ -135,12 +87,14 @@ export async function createEnvironment(
|
||||
const slug = envSlug(type);
|
||||
const apiKey = createApiKeyForEnv(type);
|
||||
const pkApiKey = createPkApiKeyForEnv(type);
|
||||
const shortcode = createShortcode().join("-");
|
||||
|
||||
return await prismaClient.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
apiKey,
|
||||
pkApiKey,
|
||||
shortcode,
|
||||
autoEnableInternalSources: type !== "DEVELOPMENT",
|
||||
organization: {
|
||||
connect: {
|
||||
@@ -158,27 +112,6 @@ export async function createEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
function envSlug(environmentType: RuntimeEnvironment["type"]) {
|
||||
switch (environmentType) {
|
||||
case "DEVELOPMENT": {
|
||||
return "dev";
|
||||
}
|
||||
case "PRODUCTION": {
|
||||
return "prod";
|
||||
}
|
||||
case "STAGING": {
|
||||
return "stg";
|
||||
}
|
||||
case "PREVIEW": {
|
||||
return "prev";
|
||||
}
|
||||
}
|
||||
function createShortcode() {
|
||||
return generate({ exactly: 2 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Task, TaskAttempt } from "@trigger.dev/database";
|
||||
import type { JobRun, Task, TaskAttempt } from "@trigger.dev/database";
|
||||
import { CachedTask, ServerTask } from "@trigger.dev/core";
|
||||
|
||||
export type TaskWithAttempts = Task & { attempts: TaskAttempt[] };
|
||||
export type TaskWithAttempts = Task & { attempts: TaskAttempt[]; run: JobRun };
|
||||
|
||||
export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask {
|
||||
return {
|
||||
@@ -24,6 +24,8 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
operation: task.operation,
|
||||
callbackUrl: task.callbackUrl,
|
||||
forceYield: task.run.forceYieldImmediately,
|
||||
childExecutionMode: task.childExecutionMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
@@ -36,6 +37,10 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
export async function findOrCreateMagicLinkUser(
|
||||
input: FindOrCreateMagicLink
|
||||
): Promise<LoggedInUser> {
|
||||
if (env.WHITELISTED_EMAILS && !new RegExp(env.WHITELISTED_EMAILS).test(input.email)) {
|
||||
throw new Error("This email is unauthorized");
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: input.email,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user