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