Compare commits

...

78 Commits

Author SHA1 Message Date
David BM
60684f6b04 feat: initial 2025-11-17 22:45:34 +01:00
David BM
95b08ccc29 chore: cleanup 2025-11-17 21:33:00 +01:00
David BM
f888da271f chore: cleanup 2025-11-17 21:33:00 +01:00
David BM
f6a6ea12a3 chore: refactor 2025-11-17 21:33:00 +01:00
David BM
d7dd8b9936 wip: working with usequery 2025-11-17 21:33:00 +01:00
David BM
35c85ee0dc feat: use invoke event trigger hook 2025-11-17 21:33:00 +01:00
David BM
2545f90a40 wip: invoke event hook 2025-11-17 21:33:00 +01:00
David BM
582b9b27c0 feat: update openapi.yaml for invoke event trigger 2025-11-17 21:33:00 +01:00
David BM
4cb4a14f5b Merge branch 'event-triggers/create' of https://github.com/nhost/nhost into event-triggers/delete 2025-11-17 20:55:37 +01:00
David BM
5861996fc6 chore: style event list item consistently with DataBase page 2025-11-17 20:13:33 +01:00
David BM
009a5d38a7 chore: refactor form and sidebar props 2025-11-17 16:53:08 +01:00
David BM
621c8faaae chore: remove sheet-drawer 2025-11-17 16:14:48 +01:00
David BM
4c4fc82074 chore: add red border to formselect 2025-11-17 16:10:25 +01:00
David BM
f0e3e76818 chore: cleanup 2025-11-17 10:32:09 +01:00
David BM
2b9801d8d3 feat: delete event triggers 2025-11-17 10:32:09 +01:00
David BM
eed0c45c93 Merge branch 'feat/event-triggers' of https://github.com/nhost/nhost into event-triggers/create 2025-11-17 10:31:31 +01:00
David BM
6e11788c0d feat(dashboard): event triggers: redeliver events (#3567) 2025-11-17 10:20:31 +01:00
David BM
d6889c762d feat(dashboard): event triggers: visualization (#3479) 2025-11-17 10:20:31 +01:00
David BM
1102f586c3 feat: initial commit 2025-11-17 10:20:31 +01:00
David Barroso
4e9de6a764 feat(docs): added instructions for oauth2 sign in (#3701) 2025-11-17 10:06:25 +01:00
David BM
c8e12ad96c chore: add tests, open form after submit validation failed 2025-11-15 20:38:46 +01:00
David BM
45888bbf23 Merge branch 'feat/event-triggers' of https://github.com/nhost/nhost into event-triggers/create 2025-11-13 20:12:25 +01:00
David BM
22879ab328 feat(dashboard): event triggers: redeliver events (#3567) 2025-11-13 19:46:19 +01:00
David BM
481726ed1e feat(dashboard): event triggers: visualization (#3479) 2025-11-13 19:46:19 +01:00
David BM
0ab2dd08ab feat: initial commit 2025-11-13 19:44:40 +01:00
David BM
9e8ad786ce chore: refactor 2025-11-13 10:58:12 +01:00
David BM
24bba56e02 chore: add tests 2025-11-13 00:27:01 +01:00
David BM
1477d24319 chore: refactor, fix errors 2025-11-12 11:41:13 +01:00
David BM
edd8544447 chore: refactor, remove field.tsx 2025-11-11 23:35:30 +01:00
David BM
25bf8dda2f fix: build 2025-11-10 14:53:38 +01:00
David BM
04e5365bb2 fix: lint 2025-11-10 11:56:13 +01:00
David BM
985157c60e chore: testing, cleanup 2025-11-10 11:45:35 +01:00
David BM
0c457b683a fix: feedback 2025-11-10 11:45:35 +01:00
David BM
0a0adb048d feat: can remove payload/request transform 2025-11-10 11:45:35 +01:00
David BM
abab84474b chore: improve display and fix pnpm build 2025-11-10 11:45:35 +01:00
David BM
e218fb1f4c feat: edit payload transform 2025-11-10 11:45:35 +01:00
David BM
eadf14203b fix: lint and build errors 2025-11-10 11:45:35 +01:00
David BM
c3fcb91244 wip: debounce 2025-11-10 11:45:35 +01:00
David BM
b1cf1dc827 wip: transform 2025-11-10 11:45:35 +01:00
David BM
a4d0e37955 feat: add skeleton for request options url transform 2025-11-10 11:45:35 +01:00
David BM
bac90afe37 feat: save edit 2025-11-10 11:45:35 +01:00
David BM
f110d262a6 feat: edit event trigger flow 2025-11-10 11:45:35 +01:00
David BM
f20ef4117c feat: create event trigger flow 2025-11-10 11:45:35 +01:00
David BM
30dede9ea0 chore: fix pnpm build 2025-11-10 11:45:35 +01:00
David BM
f1c8059e8e chore: rebase changes 2025-11-10 11:45:35 +01:00
David BM
1248fdafde feat: add edit 2025-11-10 11:45:35 +01:00
David BM
3f09199f1f feat: request url template 2025-11-10 11:45:35 +01:00
David BM
d84795ed3f chore: update openapi.yaml 2025-11-10 11:45:35 +01:00
David BM
3dab7b9146 wip: request options 2025-11-10 11:45:35 +01:00
David BM
78cfb90098 wip: request transform schema 2025-11-10 11:45:35 +01:00
David BM
509d4f716a feat: headers section 2025-11-10 11:45:35 +01:00
David BM
4f90ef18c5 wip: retry configuration section 2025-11-10 11:45:35 +01:00
David BM
29c9abd6c3 wip: extra settings 2025-11-10 11:45:35 +01:00
David BM
64bcf8b23a wip: add create network functions [skip ci] 2025-11-10 11:45:35 +01:00
David BM
86e5bc2bf6 fix: build 2025-11-10 11:45:35 +01:00
David BM
ab787cfc8c chore: onsubmit fix types 2025-11-10 11:45:35 +01:00
David BM
9e429a7310 feat: add delete to openapi.yaml 2025-11-10 11:45:35 +01:00
David BM
156602392e feat: submit form, webhook 2025-11-10 11:45:35 +01:00
David BM
ce2c8768c1 wip: asd form 2025-11-10 11:45:35 +01:00
David BM
9d44850fb5 wip: improve drawer 2025-11-10 11:45:34 +01:00
David BM
934de7bd10 wip: create form 2025-11-10 11:45:34 +01:00
David BM
7d45845500 wip: create 2025-11-10 11:45:34 +01:00
David BM
a2f4a83ac3 chore: remove autocleanupconfig 2025-11-10 11:45:34 +01:00
David BM
0ccc471787 wip: sheet drawer 2025-11-10 11:45:34 +01:00
David BM
0109ddee6c chore: run codegen-hasura-api 2025-11-10 11:45:34 +01:00
David BM
12ff139212 feat: openapi.yaml 2025-11-10 11:45:34 +01:00
David BM
804e0373b0 chore: rerun codegen-hasura-api 2025-11-10 11:45:34 +01:00
David BM
2f8cb6fcc8 chore: rerun codegen-hasura-api 2025-11-10 11:45:34 +01:00
David BM
4871d71eba chore: rerun codegen-hasura-api 2025-11-10 11:45:34 +01:00
David BM
244604fd83 chore: remove autocleanupconfig 2025-11-10 11:45:34 +01:00
David BM
eac4633434 feat: add skeleton loader 2025-11-10 11:45:34 +01:00
David BM
e27b83a0f4 fix: feedback on PR 2025-11-10 11:45:34 +01:00
David BM
f9a66720cd chore: remove unused dep 2025-11-10 11:45:34 +01:00
David BM
51f32536ff chore: remove autocleanupconfig 2025-11-10 11:45:34 +01:00
David BM
dc4a6a3caa feat: event trigger visualizations 2025-11-10 11:45:34 +01:00
David BM
46a9de4ace feat(dashboard): event triggers: redeliver events (#3567) 2025-11-10 11:44:44 +01:00
David BM
6a650a0b67 feat(dashboard): event triggers: visualization (#3479) 2025-11-10 11:44:44 +01:00
David BM
4f3c1d5f9f feat: initial commit 2025-11-10 11:44:44 +01:00
384 changed files with 14190 additions and 611 deletions

View File

@@ -52,6 +52,7 @@
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.2",
@@ -64,7 +65,7 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@segment/analytics-next": "^1.77.0",
"@simplewebauthn/browser": "^9.0.1",
"@stripe/react-stripe-js": "^2.6.2",

307
dashboard/pnpm-lock.yaml generated
View File

@@ -98,6 +98,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.1.2
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -135,8 +138,8 @@ importers:
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@segment/analytics-next':
specifier: ^1.77.0
version: 1.77.0(encoding@0.1.13)
@@ -2701,6 +2704,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.1.3':
resolution: {integrity: sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==}
peerDependencies:
@@ -2714,6 +2730,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.2':
resolution: {integrity: sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==}
peerDependencies:
@@ -2899,6 +2928,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.2':
resolution: {integrity: sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==}
peerDependencies:
@@ -3022,6 +3064,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.0':
resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==}
peerDependencies:
@@ -3087,6 +3138,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.0.4':
resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==}
peerDependencies:
@@ -3126,6 +3190,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.0.1':
resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==}
peerDependencies:
@@ -3165,6 +3242,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@1.0.3':
resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==}
peerDependencies:
@@ -3392,8 +3482,8 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.1.2':
resolution: {integrity: sha512-9XRsLwe6Yb9B/tlnYCPVUd/TFS4J7HuOZW345DCeC6vKIxQGMZdx21RK4VoZauPD5frgkXTYVS5y90L+3YBn4w==}
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -3423,6 +3513,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.0.1':
resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==}
peerDependencies:
@@ -3477,6 +3576,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.0.1':
resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==}
peerDependencies:
@@ -3531,6 +3639,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.0':
resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==}
peerDependencies:
@@ -3549,19 +3666,6 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.1.0':
resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.1.1':
resolution: {integrity: sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==}
peerDependencies:
@@ -3575,9 +3679,25 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@repeaterjs/repeater@3.0.6':
resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==}
@@ -6566,7 +6686,7 @@ packages:
lucide-react@0.552.0:
resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
@@ -10387,7 +10507,7 @@ snapshots:
'@headlessui/react': 1.7.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dialog': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dropdown-menu': 2.1.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-tooltip': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-tooltip': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/codemirror': 5.60.15
clsx: 1.2.1
@@ -11677,6 +11797,15 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-checkbox@1.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -11693,6 +11822,22 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-collapsible@1.1.2(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -11873,6 +12018,19 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-dismissable-layer@1.1.2(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -11986,6 +12144,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-id@1.1.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-label@2.1.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -12080,6 +12245,24 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-rect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/rect': 1.1.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -12110,6 +12293,16 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -12141,6 +12334,16 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -12371,20 +12574,20 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-tooltip@1.1.2(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.0
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-context': 1.1.0(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.73)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
@@ -12404,6 +12607,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -12449,6 +12658,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.73)(react@18.2.0)
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -12487,6 +12703,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-rect@1.1.1(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-use-size@1.1.0(@types/react@18.2.73)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.73)(react@18.2.0)
@@ -12501,15 +12724,6 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.73
'@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-visually-hidden@1.1.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -12519,8 +12733,19 @@ snapshots:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.73
'@types/react-dom': 18.3.0
'@radix-ui/rect@1.1.0': {}
'@radix-ui/rect@1.1.1': {}
'@repeaterjs/repeater@3.0.6': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}

View File

@@ -0,0 +1,45 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/v3/alert-dialog';
interface DiscardChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onDiscardChanges: () => void;
}
export default function DiscardChangesDialog({
open,
onOpenChange,
onDiscardChanges,
}: DiscardChangesDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="text-foreground">
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved local changes. Are you sure you want to discard
them?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDiscardChanges}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1 @@
export { default as DiscardChangesDialog } from './DiscardChangesDialog';

View File

@@ -6,7 +6,8 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { Input } from '@/components/ui/v3/input';
import { Input, type InputProps } from '@/components/ui/v3/input';
import { InfoTooltip } from '@/features/orgs/projects/common/components/InfoTooltip';
import { cn, isNotEmptyValue } from '@/lib/utils';
import {
type ChangeEvent,
@@ -41,6 +42,9 @@ interface FormInputProps<
transformValue?: (
value: PathValue<TFieldValues, TName>,
) => PathValue<TFieldValues, TName>;
disabled?: boolean;
autoComplete?: InputProps['autoComplete'];
infoTooltip?: string;
}
function InnerFormInput<
@@ -57,6 +61,9 @@ function InnerFormInput<
inline,
helperText,
transformValue,
disabled,
autoComplete,
infoTooltip,
}: FormInputProps<TFieldValues, TName>,
ref: ForwardedRef<HTMLInputElement>,
) {
@@ -96,13 +103,26 @@ function InnerFormInput<
<FormItem
className={cn({ 'flex w-full items-center gap-4 py-3': inline })}
>
<FormLabel
className={cn({
'mt-2 w-52 max-w-52 flex-shrink-0 self-start': inline,
})}
>
{label}
</FormLabel>
{infoTooltip ? (
<div className="flex flex-row items-center gap-2">
<FormLabel
className={cn({
'mt-2 w-52 max-w-52 flex-shrink-0 self-start': inline,
})}
>
{label}
</FormLabel>
<InfoTooltip>{infoTooltip}</InfoTooltip>
</div>
) : (
<FormLabel
className={cn({
'mt-2 w-52 max-w-52 flex-shrink-0 self-start': inline,
})}
>
{label}
</FormLabel>
)}
<div
className={cn({
'flex w-[calc(100%-13.5rem)] max-w-[calc(100%-13.5rem)] flex-col gap-2':
@@ -115,6 +135,8 @@ function InnerFormInput<
placeholder={placeholder}
onChange={handleOnChange}
value={tValue}
disabled={disabled}
autoComplete={autoComplete}
{...fieldProps}
ref={mergeRefs([field.ref, ref])}
className={cn(inputClasses, className)}

View File

@@ -22,6 +22,9 @@ import type {
PathValue,
} from 'react-hook-form';
const selectClasses =
'aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:border-red-500 aria-[invalid=true]:focus:ring-red-500';
interface FormSelectProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
@@ -33,6 +36,7 @@ interface FormSelectProps<
className?: string;
inline?: boolean;
helperText?: string | null;
disabled?: boolean;
transformValue?: (
value: PathValue<TFieldValues, TName>,
) => PathValue<TFieldValues, TName>;
@@ -49,6 +53,7 @@ function FormSelect<
className = '',
inline,
helperText,
disabled,
children,
transformValue,
}: PropsWithChildren<FormSelectProps<TFieldValues, TName>>) {
@@ -99,10 +104,11 @@ function FormSelect<
<Select
onValueChange={handleOnChange}
value={tValue}
disabled={disabled}
{...selectProps}
>
<FormControl>
<SelectTrigger className={className}>
<SelectTrigger className={cn(selectClasses, className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/v3/command';
import {
CalendarDays,
Check,
ChevronsUpDown,
CloudIcon,
@@ -19,6 +20,7 @@ import {
HomeIcon,
RocketIcon,
UserIcon,
Zap,
} from 'lucide-react';
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
@@ -81,6 +83,13 @@ export default function ProjectPagesComboBox() {
slug: 'graphql',
disabled: false,
},
{
label: 'Events',
value: 'events',
icon: <Zap className="h-4 w-4" />,
slug: 'events',
disabled: false,
},
{
label: 'Hasura',
value: 'hasura',
@@ -144,6 +153,13 @@ export default function ProjectPagesComboBox() {
slug: 'metrics',
disabled: !isPlatform,
},
{
label: 'Events',
value: 'events',
icon: <CalendarDays className="h-4 w-4" />,
slug: 'events',
disabled: false,
},
{
label: 'Settings',
value: 'settings',

View File

@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/v3/button';
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
import { cn, isNotEmptyValue } from '@/lib/utils';
import { getConfigServerUrl, isPlatform as getIsPlatform } from '@/utils/env';
import { Box, ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { Box, ChevronDown, ChevronRight, Plus, Zap } from 'lucide-react';
import Link from 'next/link';
import { type ReactElement } from 'react';
@@ -46,6 +46,12 @@ const projectPages = [
route: 'graphql',
slug: 'graphql',
},
{
name: 'Events',
icon: <Zap className="h-4 w-4" />,
route: 'events',
slug: 'events',
},
{
name: 'Hasura',
icon: <HasuraIcon className="h-4 w-4" />,
@@ -157,6 +163,7 @@ const projectSettingsPages = [
},
{ name: 'AI', slug: 'ai', route: 'ai' },
{ name: 'Observability', slug: 'metrics', route: 'metrics' },
{ name: 'Events', slug: 'events', route: 'events' },
{ name: 'Configuration Editor', slug: 'editor', route: 'editor' },
];

View File

@@ -100,7 +100,7 @@ export const CodeBlock = forwardRef(
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.200',
}}
className={clsx(
'not-prose relative mt-5 px-2',
'not-prose relative mt-5 w-full min-w-0 max-w-full px-2',
filename && 'pt-2',
className,
)}
@@ -124,8 +124,8 @@ export const CodeBlock = forwardRef(
className="absolute right-3 top-0"
/>
)}
<pre className="overflow-x-auto">
<code className="font-mono">{children}</code>
<pre className="w-full max-w-full whitespace-pre-wrap break-words">
<code className="break-all font-mono">{children}</code>
</pre>
</Box>
),

View File

@@ -0,0 +1,78 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/v3/hover-card';
import { cn } from '@/lib/utils';
import { copy } from '@/utils/copy';
import { HoverCardPortal } from '@radix-ui/react-hover-card';
import { format } from 'date-fns';
import { Copy } from 'lucide-react';
import type { ComponentPropsWithoutRef } from 'react';
type HoverCardContentProps = ComponentPropsWithoutRef<typeof HoverCardContent>;
interface HoverCardTimestampProps {
date: Date;
side?: HoverCardContentProps['side'];
sideOffset?: HoverCardContentProps['sideOffset'];
align?: HoverCardContentProps['align'];
alignOffset?: HoverCardContentProps['alignOffset'];
className?: string;
}
function Row({ value, label }: { value: string; label: string }) {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className="group flex items-center justify-between gap-4 text-sm"
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
copy(value, 'Timestamp');
}}
>
<dt className="text-muted-foreground">{label}</dt>
<dd className="flex items-center gap-1 truncate font-mono">
<span className="invisible group-hover:visible">
<Copy className="h-3 w-3" />
</span>
{value}
</dd>
</div>
);
}
export default function HoverCardTimestamp({
date,
side = 'right',
align = 'start',
alignOffset = -4,
sideOffset,
className,
}: HoverCardTimestampProps) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
<HoverCard openDelay={0} closeDelay={150}>
<HoverCardTrigger asChild>
<div className={cn('whitespace-nowrap font-mono', className)}>
{format(date, 'LLL dd, y HH:mm:ss')}
</div>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
className="z-10 w-auto p-2"
{...{ side, align, alignOffset, sideOffset }}
>
<dl className="flex flex-col gap-1">
<Row value={String(date.getTime())} label="Timestamp" />
<Row value={date.toISOString()} label="UTC" />
<Row value={format(date, 'LLL dd, y HH:mm:ss')} label={timezone} />
</dl>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
}

View File

@@ -0,0 +1 @@
export { default as HoverCardTimestamp } from './HoverCardTimestamp';

View File

@@ -1,58 +1,66 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { ChevronDownIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
}
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -56,11 +56,13 @@ Button.displayName = 'Button';
const ButtonWithLoading = React.forwardRef<
HTMLButtonElement,
ButtonProps & { loading?: boolean }
>(({ loading, disabled, children, ...props }, ref) => {
ButtonProps & { loading?: boolean; loaderClassName?: string }
>(({ loading, disabled, children, loaderClassName, ...props }, ref) => {
return (
<Button disabled={loading || disabled} ref={ref} {...props}>
{loading && <Loader2 className="mr-2 animate-spin" />}
{loading && (
<Loader2 className={cn('mr-2 animate-spin', loaderClassName)} />
)}
{children}
</Button>
);

View File

@@ -0,0 +1,7 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const { CollapsibleTrigger, CollapsibleContent } = CollapsiblePrimitive;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };

View File

@@ -0,0 +1,177 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Button } from '@/components/ui/v3/button';
import { Input } from '@/components/ui/v3/input';
import { Textarea } from '@/components/ui/v3/textarea';
import { cn } from '@/lib/utils';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group shadow-xs relative flex w-full items-center rounded-md border border-input outline-none transition-[color,box-shadow] dark:bg-input/30',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring',
// Error state.
'has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="button"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLDivElement).click();
}
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
wrapperClassName,
...props
}: React.ComponentProps<'input'> & { wrapperClassName?: string }) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
wrapperClassName={wrapperClassName}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};

View File

@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -3,26 +3,58 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
function TooltipProvider({
delayDuration = 0, // eslint-disable-line react/prop-types
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
const Tooltip = TooltipPrimitive.Root;
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@@ -0,0 +1,22 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/v3/tooltip';
import { Info } from 'lucide-react';
import type { ReactNode } from 'react';
interface InfoTooltipProps {
children: ReactNode;
}
export default function InfoTooltip({ children }: InfoTooltipProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-primary" />
</TooltipTrigger>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1 @@
export { default as InfoTooltip } from './InfoTooltip';

View File

@@ -0,0 +1,69 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/v3/tooltip';
import { cn } from '@/lib/utils';
import { type ReactNode, useEffect, useRef, useState } from 'react';
interface TextWithTooltipProps {
text: string | number | ReactNode;
className?: string;
containerClassName?: string;
slotProps?: {
container?: React.HTMLAttributes<HTMLDivElement>;
};
}
export default function TextWithTooltip({
text,
containerClassName,
className,
slotProps,
}: TextWithTooltipProps) {
const [isTruncated, setIsTruncated] = useState<boolean>(false);
const textRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkTruncation = () => {
if (textRef.current) {
const { scrollWidth, clientWidth } = textRef.current;
setIsTruncated(scrollWidth > clientWidth);
}
};
const resizeObserver = new ResizeObserver(() => {
checkTruncation();
});
if (textRef.current) {
resizeObserver.observe(textRef.current);
}
checkTruncation();
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div className={containerClassName} {...slotProps?.container}>
<Tooltip>
<TooltipTrigger disabled={!isTruncated} asChild>
<div
ref={textRef}
className={cn(
'truncate',
!isTruncated && 'pointer-events-none',
className,
)}
>
{text}
</div>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as TextWithTooltip } from './TextWithTooltip';

View File

@@ -0,0 +1 @@
export { default as useGetDataSources } from './useGetDataSources';

View File

@@ -0,0 +1,52 @@
import { fetchExportMetadata } from '@/features/orgs/projects/common/utils/fetchExportMetadata';
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type { ExportMetadataResponse } from '@/utils/hasura-api/generated/schemas';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
export interface UseGetDataSourcesOptions {
/**
* Props passed to the underlying query hook.
*/
queryOptions?: UseQueryOptions<ExportMetadataResponse, unknown, string[]>;
}
/**
* This hook gets the data sources names from the metadata.
*
* @param options - Options to use for the query.
* @returns The result of the query.
*/
export default function useGetDataSources({
queryOptions,
}: UseGetDataSourcesOptions = {}) {
const { project } = useProject();
const query = useQuery<ExportMetadataResponse, unknown, string[]>(
['export-metadata', project?.subdomain],
() => {
const appUrl = generateAppServiceUrl(
project!.subdomain,
project!.region,
'hasura',
);
const adminSecret = project?.config?.hasura.adminSecret!;
return fetchExportMetadata({ appUrl, adminSecret });
},
{
...queryOptions,
select: (data) =>
data.metadata?.sources?.reduce<string[]>((acc, source) => {
if (source.name) {
acc.push(source.name);
}
return acc;
}, []) ?? [],
},
);
return query;
}

View File

@@ -0,0 +1 @@
export { default as useGetMetadata } from './useGetMetadata';

View File

@@ -0,0 +1,57 @@
import { fetchExportMetadata } from '@/features/orgs/projects/common/utils/fetchExportMetadata';
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type {
ExportMetadataResponse,
ExportMetadataResponseMetadata,
} from '@/utils/hasura-api/generated/schemas';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
export interface UseGetMetadataOptions {
/**
* Props passed to the underlying query hook.
*/
queryOptions?: UseQueryOptions<
ExportMetadataResponse,
unknown,
ExportMetadataResponseMetadata
>;
}
/**
* This hook gets the metadata from the Hasura API.
*
* @param options - Options to use for the query.
* @returns The result of the query.
*/
export default function useGetMetadata({
queryOptions,
}: UseGetMetadataOptions = {}) {
const { project } = useProject();
const query = useQuery<
ExportMetadataResponse,
unknown,
ExportMetadataResponseMetadata
>(
['export-metadata', project?.subdomain],
() => {
const appUrl = generateAppServiceUrl(
project!.subdomain,
project!.region,
'hasura',
);
const adminSecret = project?.config?.hasura.adminSecret!;
return fetchExportMetadata({ appUrl, adminSecret });
},
{
...queryOptions,
select: (data) => data.metadata,
},
);
return query;
}

View File

@@ -1,6 +1,6 @@
import type { MetadataOperationOptions } from '@/features/orgs/projects/remote-schemas/types';
import { metadataOperation } from '@/utils/hasura-api/generated/default/default';
import type { ExportMetadataResponse } from '@/utils/hasura-api/generated/schemas';
import type { MetadataOperationOptions } from '@/utils/hasura-api/types';
export default async function fetchExportMetadata({
appUrl,

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import { Badge } from '@/components/ui/v3/badge';
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
import { DataGridCustomizerControls } from '@/features/orgs/projects/common/components/DataGridCustomizerControls';
import { InvokeEventTriggerButton } from '@/features/orgs/projects/database/dataGrid/components/InvokeEventTriggerButton';
import { useDeleteRecordMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteRecordMutation';
import type { DataBrowserGridColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider';
@@ -142,6 +143,11 @@ export default function DataBrowserGridControls({
>
Delete
</Button>
{numberOfSelectedRows === 1 && (
<InvokeEventTriggerButton
selectedValues={selectedRows[0].values}
/>
)}
</div>
)}

View File

@@ -0,0 +1,165 @@
import { Button } from '@/components/ui/v3/button';
import { Dialog } from '@/components/ui/v3/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/v3/dropdown-menu';
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { InvocationLogDetailsDialogContent } from '@/features/orgs/projects/events/event-triggers/components/InvocationLogDetailsDialogContent';
import { DEFAULT_RETRY_TIMEOUT_SECONDS } from '@/features/orgs/projects/events/event-triggers/constants';
import fetchEventAndInvocationLogsById from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventAndInvocationLogsById/fetchEventAndInvocationLogsById';
import { useGetEventTriggersByTable } from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventTriggersByTable';
import { useInvokeEventTriggerMutation } from '@/features/orgs/projects/events/event-triggers/hooks/useInvokeEventTriggerMutation';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { getToastStyleProps } from '@/utils/constants/settings';
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas/eventInvocationLogEntry';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
export interface InvokeEventTriggerButtonProps {
selectedValues: Record<string, unknown>;
}
export default function InvokeEventTriggerButton({
selectedValues,
}: InvokeEventTriggerButtonProps) {
const [showDialog, setShowDialog] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const [newLog, setNewLog] = useState<EventInvocationLogEntry | null>(null);
const { project } = useProject();
const appUrl = generateAppServiceUrl(
project?.subdomain!,
project?.region!,
'hasura',
);
const adminSecret = project?.config?.hasura.adminSecret!;
const router = useRouter();
const { dataSourceSlug, schemaSlug, tableSlug } = router.query;
const { data: eventTriggerNames, isLoading } = useGetEventTriggersByTable({
table: { name: tableSlug as string, schema: schemaSlug as string },
dataSource: dataSourceSlug as string,
queryOptions: {
enabled:
typeof tableSlug === 'string' &&
typeof schemaSlug === 'string' &&
typeof dataSourceSlug === 'string',
},
});
const handleDialogOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setNewLog(null);
}
setShowDialog(newOpen);
};
const { mutateAsync: invokeEventTrigger } = useInvokeEventTriggerMutation();
const resetState = () => {
setNewLog(null);
setShowDialog(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const handleInvokeEventTrigger = async (name: string) => {
let eventId: string;
try {
const response = await invokeEventTrigger({
args: {
name,
source: dataSourceSlug as string,
payload: selectedValues,
},
});
eventId = response.event_id;
toast.success(
'Event trigger invoked successfully, fetching invocation logs...',
getToastStyleProps(),
);
} catch (error) {
toast.error('Failed to invoke event trigger', getToastStyleProps());
resetState();
return;
}
setShowDialog(true);
const start = Date.now();
const timeoutMs = DEFAULT_RETRY_TIMEOUT_SECONDS * 1000; // TODO: Get from retry_conf.timeout_sec
intervalRef.current = setInterval(async () => {
const elapsed = Date.now() - start;
if (elapsed >= timeoutMs && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
return;
}
try {
const newData = await fetchEventAndInvocationLogsById({
appUrl,
adminSecret,
args: {
event_id: eventId,
source: dataSourceSlug as string,
},
});
const firstInvocation = newData?.invocations?.[0];
if (firstInvocation) {
setNewLog(firstInvocation);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
} catch (error) {
toast.error('Failed to fetch invocation logs', getToastStyleProps());
resetState();
}
}, 1000);
};
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" aria-label="Open menu" size="sm">
Invoke Event Trigger
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuLabel>Invoke</DropdownMenuLabel>
<DropdownMenuGroup>
{isLoading ? (
<DropdownMenuItem disabled>
<Loader2 className="mr-2 size-4 animate-spin" />
Loading...
</DropdownMenuItem>
) : (
eventTriggerNames?.map((name) => (
<DropdownMenuItem
key={name}
onSelect={() => handleInvokeEventTrigger(name)}
>
{name}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={showDialog} onOpenChange={handleDialogOpenChange}>
<InvocationLogDetailsDialogContent log={newLog} isLoading={!newLog} />
</Dialog>
</>
);
}

View File

@@ -0,0 +1 @@
export { default as InvokeEventTriggerButton } from './InvokeEventTriggerButton';

View File

@@ -0,0 +1,142 @@
import { Button } from '@/components/ui/v3/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/v3/dropdown-menu';
import { TextWithTooltip } from '@/features/orgs/projects/common/components/TextWithTooltip';
import { DeleteCronTriggerDialog } from '@/features/orgs/projects/events/cron-triggers/components/DeleteCronTriggerDialog';
import { EditCronTriggerForm } from '@/features/orgs/projects/events/cron-triggers/components/EditCronTriggerForm';
import type { BaseEventTriggerFormTriggerProps } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm';
import { cn } from '@/lib/utils';
import type { CronTrigger } from '@/utils/hasura-api/generated/schemas';
import { Ellipsis, SquarePen, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
const menuItemClassName =
'flex h-9 cursor-pointer items-center gap-2 rounded-none border border-b-1 !text-sm+ font-medium leading-4';
export interface CronTriggerListItemProps {
cronTrigger: CronTrigger;
}
export default function CronTriggerListItem({
cronTrigger,
}: CronTriggerListItemProps) {
const router = useRouter();
const { orgSlug, appSubdomain, cronTriggerSlug } = router.query;
const editTriggerRef = useRef<BaseEventTriggerFormTriggerProps | null>(null);
const isSelected = cronTrigger.name === cronTriggerSlug;
const href = `/orgs/${orgSlug}/projects/${appSubdomain}/events/cron-trigger/${cronTrigger.name}`;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showDeleteEventTriggerDialog, setShowDeleteEventTriggerDialog] =
useState(false);
return (
<>
<div className="group pb-1">
<Button
asChild
variant="link"
size="sm"
className={cn(
'flex w-full max-w-full justify-between pl-0 text-sm+ hover:bg-accent hover:no-underline',
{
'bg-table-selected': isSelected,
},
)}
>
<div className="flex w-full max-w-full items-center">
<Link
href={href}
className={cn(
'flex h-full w-[calc(100%-1.6rem)] items-center p-[0.625rem] pr-0 text-left',
{
'text-primary-main': isSelected,
},
)}
>
<TextWithTooltip
containerClassName="w-full"
className={cn('!truncate text-sm+', {
'text-primary-main': isSelected,
})}
text={cronTrigger.name}
/>
</Link>
<DropdownMenu
modal={false}
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<DropdownMenuTrigger
asChild
className={cn(
'relative z-10 opacity-0 transition-opacity group-hover:opacity-100',
{
'opacity-100': isSelected || isMenuOpen,
},
)}
>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-none bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<Ellipsis className="size-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="w-52 p-0"
forceMount
>
<DropdownMenuItem
onSelect={() => {
editTriggerRef.current?.open?.();
}}
className={menuItemClassName}
>
<SquarePen className="size-4" />
Edit Cron Trigger
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setShowDeleteEventTriggerDialog(true)}
className={cn(
menuItemClassName,
'text-destructive focus:text-destructive',
)}
>
<Trash2 className="size-4" />
Delete Cron Trigger
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Button>
</div>
<EditCronTriggerForm
cronTrigger={cronTrigger}
trigger={(controls) => {
editTriggerRef.current = controls;
return null;
}}
/>
<DeleteCronTriggerDialog
open={showDeleteEventTriggerDialog}
setOpen={setShowDeleteEventTriggerDialog}
cronTriggerToDelete={cronTrigger.name}
/>
</>
);
}

View File

@@ -0,0 +1,142 @@
import { Button } from '@/components/ui/v3/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/v3/dropdown-menu';
import { TextWithTooltip } from '@/features/orgs/projects/common/components/TextWithTooltip';
import type { BaseEventTriggerFormTriggerProps } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm';
import { DeleteEventTriggerDialog } from '@/features/orgs/projects/events/event-triggers/components/DeleteEventTriggerDialog';
import { EditEventTriggerForm } from '@/features/orgs/projects/events/event-triggers/components/EditEventTriggerForm';
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { cn } from '@/lib/utils';
import { Ellipsis, SquarePen, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
const menuItemClassName =
'flex h-9 cursor-pointer items-center gap-2 rounded-none border border-b-1 !text-sm+ font-medium leading-4';
export interface EventTriggerListItemProps {
eventTrigger: EventTriggerViewModel;
}
export default function EventTriggerListItem({
eventTrigger,
}: EventTriggerListItemProps) {
const router = useRouter();
const { orgSlug, appSubdomain, eventTriggerSlug } = router.query;
const editTriggerRef = useRef<BaseEventTriggerFormTriggerProps | null>(null);
const isSelected = eventTrigger.name === eventTriggerSlug;
const href = `/orgs/${orgSlug}/projects/${appSubdomain}/events/event-trigger/${eventTrigger.name}`;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showDeleteEventTriggerDialog, setShowDeleteEventTriggerDialog] =
useState(false);
return (
<>
<div className="group pb-1">
<Button
asChild
variant="link"
size="sm"
className={cn(
'flex w-full max-w-full justify-between pl-0 text-sm+ hover:bg-accent hover:no-underline',
{
'bg-table-selected': isSelected,
},
)}
>
<div className="flex w-full max-w-full items-center">
<Link
href={href}
className={cn(
'flex h-full w-[calc(100%-1.6rem)] items-center p-[0.625rem] pr-0 text-left',
{
'text-primary-main': isSelected,
},
)}
>
<TextWithTooltip
containerClassName="w-full"
className={cn('!truncate text-sm+', {
'text-primary-main': isSelected,
})}
text={eventTrigger.name}
/>
</Link>
<DropdownMenu
modal={false}
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<DropdownMenuTrigger
asChild
className={cn(
'relative z-10 opacity-0 transition-opacity group-hover:opacity-100',
{
'opacity-100': isSelected || isMenuOpen,
},
)}
>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-none bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<Ellipsis className="size-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="w-52 p-0"
forceMount
>
<DropdownMenuItem
onSelect={() => {
editTriggerRef.current?.open?.();
}}
className={menuItemClassName}
>
<SquarePen className="size-4" />
Edit Event Trigger
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setShowDeleteEventTriggerDialog(true)}
className={cn(
menuItemClassName,
'text-destructive focus:text-destructive',
)}
>
<Trash2 className="size-4" />
Delete Event Trigger
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Button>
</div>
<EditEventTriggerForm
eventTrigger={eventTrigger}
trigger={(controls) => {
editTriggerRef.current = controls;
return null;
}}
/>
<DeleteEventTriggerDialog
open={showDeleteEventTriggerDialog}
setOpen={setShowDeleteEventTriggerDialog}
eventTriggerToDelete={eventTrigger.name}
/>
</>
);
}

View File

@@ -0,0 +1,219 @@
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
import { Backdrop } from '@/components/ui/v2/Backdrop';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { IconButton } from '@/components/ui/v2/IconButton';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/v3/accordion';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { CreateCronTriggerForm } from '@/features/orgs/projects/events/cron-triggers/components/CreateCronTriggerForm';
import { useGetCronTriggers } from '@/features/orgs/projects/events/cron-triggers/hooks/useGetCronTriggers';
import { CreateEventTriggerForm } from '@/features/orgs/projects/events/event-triggers/components/CreateEventTriggerForm';
import { useGetEventTriggers } from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventTriggers';
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { Database } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import CronTriggerListItem from './CronTriggerListItem';
import EventsBrowserSidebarSkeleton from './EventsBrowserSidebarSkeleton';
import EventTriggerListItem from './EventTriggerListItem';
export interface EventsBrowserSidebarProps extends Omit<BoxProps, 'children'> {}
function EventsBrowserSidebarContent() {
const { data, isLoading, error } = useGetEventTriggers();
const {
data: cronTriggers,
isLoading: isLoadingCronTriggers,
error: errorCronTriggers,
} = useGetCronTriggers();
if (isLoading || isLoadingCronTriggers) {
return <EventsBrowserSidebarSkeleton />;
}
if (error instanceof Error || errorCronTriggers instanceof Error) {
return (
<div className="flex h-full flex-col px-2">
<div className="flex flex-row items-center justify-between">
<p className="font-medium leading-7 [&:not(:first-child)]:mt-6">
Events could not be loaded.
</p>
</div>
</div>
);
}
const eventTriggersByDataSource = data?.reduce<
Record<string, EventTriggerViewModel[]>
>((acc, eventTrigger) => {
const key = eventTrigger.dataSource;
if (!acc[key]) {
acc[key] = [];
}
acc[key] = [...acc[key], eventTrigger];
return acc;
}, {});
if (eventTriggersByDataSource) {
Object.keys(eventTriggersByDataSource).forEach((dataSource) => {
eventTriggersByDataSource[dataSource].sort((a, b) =>
a.name.localeCompare(b.name),
);
});
}
return (
<div className="flex h-full flex-col px-2">
<div className="flex flex-row items-center justify-between">
<p className="font-semibold leading-7 [&:not(:first-child)]:mt-6">
Event Triggers ({data?.length ?? 0})
</p>
<CreateEventTriggerForm />
</div>
<div className="flex flex-row gap-2">
<Accordion
type="single"
collapsible
className="w-full"
defaultValue="default"
>
{Object.entries(eventTriggersByDataSource ?? {}).map(
([dataSource, eventTriggers]) => (
<AccordionItem
key={dataSource}
value={dataSource}
id={dataSource}
>
<AccordionTrigger className="flex-row-reverse justify-end gap-2 text-sm+ [&[data-state=closed]>svg:last-child]:-rotate-90 [&[data-state=open]>svg:last-child]:rotate-0">
<div className="flex flex-row-reverse items-center gap-2">
{dataSource}
<Database className="size-4 !rotate-0" />
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 text-balance pl-4">
{eventTriggers.map((eventTrigger) => (
<EventTriggerListItem
key={eventTrigger.name}
eventTrigger={eventTrigger}
/>
))}
</AccordionContent>
</AccordionItem>
),
)}
</Accordion>
</div>
<div className="flex flex-row gap-2">
<Accordion
type="single"
collapsible
className="w-full"
defaultValue="default"
>
<AccordionItem value="default" id="default">
<AccordionTrigger className="flex-row-reverse justify-end gap-2 text-sm+ font-semibold [&[data-state=closed]>svg:last-child]:-rotate-90 [&[data-state=open]>svg:last-child]:rotate-0">
Cron Triggers ({cronTriggers?.length ?? 0})
<CreateCronTriggerForm />
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 text-balance pl-4">
{(cronTriggers ?? []).map((cronTrigger) => (
<CronTriggerListItem
key={cronTrigger.name}
cronTrigger={cronTrigger}
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}
export default function EventsBrowserSidebar({
className,
...props
}: EventsBrowserSidebarProps) {
const isPlatform = useIsPlatform();
const { project } = useProject();
const [expanded, setExpanded] = useState(false);
function toggleExpanded() {
setExpanded(!expanded);
}
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
if (event.key === 'Escape') {
setExpanded(false);
}
}
useEffect(() => {
if (typeof document !== 'undefined') {
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}
return () =>
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}, []);
if (isPlatform && !project?.config?.hasura.adminSecret) {
return null;
}
return (
<>
<Backdrop
open={expanded}
className="absolute bottom-0 left-0 right-0 top-0 z-[34] sm:hidden"
role="button"
tabIndex={-1}
onClick={() => setExpanded(false)}
aria-label="Close sidebar overlay"
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
setExpanded(false);
}}
/>
<Box
component="aside"
className={twMerge(
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 pb-17 pt-2 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:pb-0 sm:pt-2.5 sm:transition-none',
expanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
className,
)}
{...props}
>
<RetryableErrorBoundary>
<EventsBrowserSidebarContent />
</RetryableErrorBoundary>
</Box>
<IconButton
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
onClick={toggleExpanded}
aria-label="Toggle sidebar"
>
<Image
width={16}
height={16}
src="/assets/table.svg"
alt="A monochrome table"
/>
</IconButton>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/v3/skeleton';
export default function EventsBrowserSidebarSkeleton() {
return (
<div className="flex h-full flex-col px-2">
<div className="flex flex-row items-center justify-between">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-9 rounded-md" />
</div>
<div className="mt-3 flex flex-col gap-4">
{[0, 1].map((groupIndex) => (
<div key={groupIndex} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-4 rounded-md" />
</div>
<div className="flex flex-col gap-1 pl-4">
{[0, 1, 2].map((itemIndex) => (
<Skeleton key={itemIndex} className="h-9 w-52" />
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as EventsBrowserSidebar } from './EventsBrowserSidebar';

View File

@@ -0,0 +1,45 @@
import { CalendarDays } from 'lucide-react';
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface EventsEmptyStateProps
extends Omit<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
'title'
> {
/**
* Title of the empty state.
*/
title: ReactNode;
/**
* Description of the empty state.
*/
description: ReactNode;
}
export default function EventsEmptyState({
title,
description,
className,
...props
}: EventsEmptyStateProps) {
return (
<div
className={twMerge(
'grid w-full place-content-center gap-2 px-4 py-16 text-center',
className,
)}
{...props}
>
<div className="mx-auto">
<CalendarDays className="h-12 w-12" />
</div>
<h3 className="scroll-m-20 text-2xl font-medium tracking-tight">
{title}
</h3>
<p className="leading-7 [&:not(:first-child)]:mt-6">{description}</p>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as EventsEmptyState } from './EventsEmptyState';

View File

@@ -0,0 +1,27 @@
import { cn, isNotEmptyValue } from '@/lib/utils';
interface HttpStatusTextProps {
status?: number | null;
className?: string;
}
export default function HttpStatusText({
status,
className,
}: HttpStatusTextProps) {
return (
<span
className={cn(
'font-mono text-xs text-yellow-600 dark:text-yellow-400',
{
'text-green-600 dark:text-green-400':
isNotEmptyValue(status) && status >= 200 && status < 300,
'text-red-600 dark:text-red-400':
isNotEmptyValue(status) && status >= 400,
},
className,
)}
>
{status ?? 'NULL'}
</span>
);
}

View File

@@ -0,0 +1 @@
export { default as HttpStatusText } from './HttpStatusText';

View File

@@ -0,0 +1,73 @@
import { Button } from '@/components/ui/v3/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/v3/select';
export interface PaginationControlsProps {
offset: number;
limit: number;
hasNoPreviousPage: boolean;
hasNoNextPage: boolean;
onPrev: () => void;
onNext: () => void;
onChangeLimit: (value: number) => void;
}
export default function PaginationControls({
offset,
limit,
hasNoPreviousPage,
hasNoNextPage,
onPrev,
onNext,
onChangeLimit,
}: PaginationControlsProps) {
return (
<div className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={hasNoPreviousPage}
onClick={onPrev}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{offset} - {offset + limit}
</span>
<Button
variant="outline"
size="sm"
disabled={hasNoNextPage}
onClick={onNext}
>
Next
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
defaultValue={String(limit)}
onValueChange={(value) => onChangeLimit(parseInt(value, 10))}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="75">75</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type {
BaseCronTriggerFormInitialData,
BaseCronTriggerFormTriggerProps,
BaseCronTriggerFormValues,
} from './BaseCronTriggerFormTypes';
import type { ReactNode } from 'react';
export interface BaseCronTriggerFormProps {
initialData?: BaseCronTriggerFormInitialData;
trigger: (props: BaseCronTriggerFormTriggerProps) => ReactNode;
onSubmit: (data: BaseCronTriggerFormValues) => void | Promise<void>;
isEditing?: boolean;
submitButtonText: string;
titleText: string;
descriptionText: string;
}
export default function BaseCronTriggerForm({
initialData,
trigger,
onSubmit,
isEditing,
submitButtonText,
titleText,
descriptionText,
}: BaseCronTriggerFormProps) {
return <div>BaseCronTriggerForm</div>;
}

View File

@@ -0,0 +1,122 @@
import { z } from 'zod';
// TODO: Check if validation schema is complete
export const cronHeaderTypes = [
{
label: 'Value',
value: 'fromValue',
},
{
label: 'Env Var',
value: 'fromEnv',
},
] as const;
export const cronRequestTransformMethods = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
] as const;
export const cronRequestOptionsTransformQueryParamsTypeOptions = [
'Key Value',
'URL string template',
] as const;
export const cronValidationSchema = z.object({
triggerName: z
.string({ required_error: 'Trigger name required' })
.min(1, { message: 'Trigger name required' })
.max(42, { message: 'Trigger name must be at most 42 characters' })
.regex(/^[a-zA-Z0-9_-]+$/, {
message:
'Trigger name can only contain alphanumeric characters, underscores, and hyphens',
}),
webhook: z.string().min(1, { message: 'Webhook URL required' }),
schedule: z
.string()
.min(1, { message: 'Schedule (cron expression) required' }),
payload: z.any().optional(),
headers: z
.array(
z.object({
name: z.string().min(1, 'Name is required'),
type: z.enum(
cronHeaderTypes.map((header) => header.value) as [
(typeof cronHeaderTypes)[number]['value'],
],
),
value: z.string().min(1, 'Value is required'),
}),
)
.optional(),
retryConf: z
.object({
numRetries: z.coerce.number().min(0),
intervalSec: z.coerce.number().min(0),
timeoutSec: z.coerce.number().min(0),
})
.optional(),
includeInMetadata: z.boolean().default(false),
comment: z.string().optional(),
requestOptionsTransform: z
.object({
method: z.enum(cronRequestTransformMethods).optional(),
urlTemplate: z.string().optional(),
queryParams: z.discriminatedUnion('queryParamsType', [
z.object({
queryParamsType: z.literal(
cronRequestOptionsTransformQueryParamsTypeOptions[0],
),
queryParams: z.array(
z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
}),
),
}),
z.object({
queryParamsType: z.literal(
cronRequestOptionsTransformQueryParamsTypeOptions[1],
),
queryParamsURL: z.string(),
}),
]),
})
.optional(),
payloadTransform: z
.object({
sampleInput: z.string(),
requestBodyTransform: z.discriminatedUnion('requestBodyTransformType', [
z.object({
requestBodyTransformType: z.literal('disabled'),
}),
z.object({
requestBodyTransformType: z.literal('application/json'),
template: z.string(),
}),
z.object({
requestBodyTransformType: z.literal(
'application/x-www-form-urlencoded',
),
formTemplate: z.array(
z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
}),
),
}),
]),
})
.optional(),
});
export type BaseCronTriggerFormValues = z.infer<typeof cronValidationSchema>;
export type BaseCronTriggerFormInitialData = BaseCronTriggerFormValues;
export interface BaseCronTriggerFormTriggerProps {
open: () => void;
}

View File

@@ -0,0 +1 @@
export { default as BaseCronTriggerForm } from './BaseCronTriggerForm';

View File

@@ -0,0 +1,3 @@
export default function CreateCronTriggerForm() {
return <div />;
}

View File

@@ -0,0 +1 @@
export { default as CreateCronTriggerForm } from './CreateCronTriggerForm';

View File

@@ -0,0 +1,91 @@
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/v3/tabs';
import { EventsEmptyState } from '@/features/orgs/projects/events/common/components/EventsEmptyState';
import { useGetCronTriggers } from '@/features/orgs/projects/events/cron-triggers/hooks/useGetCronTriggers';
import EventTriggerViewSkeleton from '@/features/orgs/projects/events/event-triggers/components/EventTriggerView/EventTriggerViewSkeleton';
import { isEmptyValue } from '@/lib/utils';
import { useRouter } from 'next/router';
export default function CronTriggerView() {
const router = useRouter();
const { cronTriggerSlug } = router.query;
const { data: cronTriggers, isLoading, error } = useGetCronTriggers();
const cronTrigger = cronTriggers?.find(
(trigger) => trigger.name === cronTriggerSlug,
);
if (isLoading && cronTriggerSlug) {
return <EventTriggerViewSkeleton />;
}
if (error instanceof Error) {
return (
<EventsEmptyState
title="Event trigger not found"
description={
<span>
Event trigger{' '}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">
{cronTriggerSlug}
</code>{' '}
could not be loaded.
</span>
}
/>
);
}
if (isEmptyValue(cronTrigger)) {
return (
<EventsEmptyState
title="Cron trigger not found"
description={
<span>
Cron trigger{' '}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">
{cronTriggerSlug}
</code>{' '}
does not exist.
</span>
}
/>
);
}
return (
<div className="w-full px-10 py-8">
<div className="mx-auto w-full max-w-5xl rounded-lg bg-background p-4">
<div className="mb-6">
<h1 className="mb-1 text-xl font-semibold text-gray-900 dark:text-gray-100">
{cronTrigger!.name}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Cron Trigger Configuration
</p>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="pending-processed-events">Events</TabsTrigger>
</TabsList>
<TabsContent value="overview">
{/* <EventTriggerOverview eventTrigger={eventTrigger!} /> */}
<div>Overview</div>
</TabsContent>
<TabsContent value="pending-processed-events">
{/* <EventTriggerEventsDataTable eventTrigger={eventTrigger!} /> */}
<div>Events</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as CronTriggerView } from './CronTriggerView';

View File

@@ -0,0 +1,13 @@
export interface DeleteCronTriggerDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
cronTriggerToDelete: string;
}
export default function DeleteCronTriggerDialog({
open,
setOpen,
cronTriggerToDelete,
}: DeleteCronTriggerDialogProps) {
return <div />;
}

View File

@@ -0,0 +1 @@
export { default as DeleteCronTriggerDialog } from './DeleteCronTriggerDialog';

View File

@@ -0,0 +1,15 @@
import type { CronTrigger } from '@/utils/hasura-api/generated/schemas';
import type { ReactNode } from 'react';
import type { BaseCronTriggerFormTriggerProps } from '../BaseCronTriggerForm/BaseCronTriggerFormTypes';
export interface EditCronTriggerFormProps {
cronTrigger: CronTrigger;
trigger: (props: BaseCronTriggerFormTriggerProps) => ReactNode;
}
export default function EditCronTriggerForm({
cronTrigger,
trigger,
}: EditCronTriggerFormProps) {
return <div />;
}

View File

@@ -0,0 +1 @@
export { default as EditCronTriggerForm } from './EditCronTriggerForm';

View File

@@ -0,0 +1,48 @@
import { metadataOperation } from '@/utils/hasura-api/generated/default/default';
import type {
CronTriggerArgs,
GetCronTriggersOperation,
GetCronTriggersResponse,
} from '@/utils/hasura-api/generated/schemas';
import type { MetadataOperationOptions } from '@/utils/hasura-api/types';
/**
* This function fetches the cron triggers of the project.
*
* @param appUrl - The URL of the app service.
* @param adminSecret - The admin secret of the project.
* @returns The cron triggers of the project.
*
* Example payload:
* {
* "type": "get_cron_triggers",
* "args": {}
* }
*/
export default async function getCronTriggers({
appUrl,
adminSecret,
}: MetadataOperationOptions): Promise<CronTriggerArgs[]> {
try {
const operation: GetCronTriggersOperation = {
type: 'get_cron_triggers',
args: {},
};
const response = await metadataOperation(operation, {
baseUrl: appUrl,
adminSecret,
});
if (response.status === 200) {
const { cron_triggers } = response.data as GetCronTriggersResponse;
return cron_triggers ?? [];
}
throw new Error(response.data.error);
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1 @@
export { default as useGetCronTriggers } from './useGetCronTriggers';

View File

@@ -0,0 +1,61 @@
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type { CronTriggerArgs } from '@/utils/hasura-api/generated/schemas';
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
import getCronTriggers from './getCronTriggers';
export interface UseGetCronTriggersOptions {
/**
* Options passed to the underlying query hook.
*/
queryOptions?: Omit<
UseQueryOptions<
CronTriggerArgs[],
unknown,
CronTriggerArgs[],
readonly ['get-cron-triggers']
>,
'queryKey' | 'queryFn'
>;
}
/**
* This hook is a wrapper around a fetch call that gets all the cron triggers of the project.
*
* @returns The cron triggers of the project.
*/
export default function useGetCronTriggers({
queryOptions,
}: UseGetCronTriggersOptions = {}) {
const { project, loading } = useProject();
const query = useQuery(
['get-cron-triggers'],
() => {
const appUrl = generateAppServiceUrl(
project!.subdomain,
project!.region,
'hasura',
);
const adminSecret = project?.config?.hasura.adminSecret!;
return getCronTriggers({
appUrl,
adminSecret,
});
},
{
...queryOptions,
enabled: !!(
project?.subdomain &&
project?.region &&
project?.config?.hasura.adminSecret &&
queryOptions?.enabled !== false &&
!loading
),
},
);
return query;
}

View File

@@ -0,0 +1,560 @@
import { DiscardChangesDialog } from '@/components/common/DiscardChangesDialog';
import { FormInput } from '@/components/form/FormInput';
import { FormSelect } from '@/components/form/FormSelect';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/v3/accordion';
import { Button, ButtonWithLoading } from '@/components/ui/v3/button';
import { Checkbox } from '@/components/ui/v3/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
import { SelectItem } from '@/components/ui/v3/select';
import { Separator } from '@/components/ui/v3/separator';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/v3/sheet';
import { InfoTooltip } from '@/features/orgs/projects/common/components/InfoTooltip';
import { useGetMetadata } from '@/features/orgs/projects/common/hooks/useGetMetadata';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import {
ALL_TRIGGER_OPERATIONS,
defaultFormValues,
defaultPayloadTransformValues,
defaultRequestOptionsTransformValues,
updateTriggerOnOptions,
validationSchema,
type BaseEventTriggerFormInitialData,
type BaseEventTriggerFormValues,
} from './BaseEventTriggerFormTypes';
import HeadersSection from './sections/HeadersSection';
import PayloadTransformSection from './sections/PayloadTransformSection/PayloadTransformSection';
import { RequestOptionsSection } from './sections/RequestOptionsSection';
import RetryConfigurationSection from './sections/RetryConfigurationSection';
import UpdateTriggerColumnsSection from './sections/UpdateTriggerColumnsSection';
const ACCORDION_SECTION_VALUES = [
'retry-configuration',
'transformation-configuration',
] as const;
type AccordionSectionValue = (typeof ACCORDION_SECTION_VALUES)[number];
export interface BaseEventTriggerFormTriggerProps {
open: () => void;
}
export interface BaseEventTriggerFormProps {
initialData?: BaseEventTriggerFormInitialData;
trigger: (props: BaseEventTriggerFormTriggerProps) => ReactNode;
onSubmit: (data: BaseEventTriggerFormValues) => void | Promise<void>;
isEditing?: boolean;
submitButtonText: string;
titleText: string;
descriptionText: string;
}
export default function BaseEventTriggerForm({
initialData,
trigger,
isEditing,
onSubmit,
titleText,
descriptionText,
submitButtonText,
}: BaseEventTriggerFormProps) {
const { data: metadata } = useGetMetadata();
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] =
useState(false);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [openAccordionSections, setOpenAccordionSections] = useState<
AccordionSectionValue[]
>([]);
const dataSources = metadata?.sources?.map((source) => source.name!) ?? [];
const form = useForm<BaseEventTriggerFormValues>({
resolver: zodResolver(validationSchema),
defaultValues: initialData ?? defaultFormValues,
});
const { watch, reset, setValue } = form;
const { isDirty } = form.formState;
const resetFormValues = useCallback(() => {
reset(initialData ?? defaultFormValues);
}, [initialData, reset]);
const openForm = useCallback(() => {
resetFormValues();
setShowUnsavedChangesDialog(false);
setIsSheetOpen(true);
setOpenAccordionSections([]);
}, [resetFormValues]);
const closeForm = useCallback(
(options?: { reset?: boolean }) => {
if (options?.reset !== false) {
resetFormValues();
}
setIsSheetOpen(false);
setShowUnsavedChangesDialog(false);
setOpenAccordionSections([]);
},
[resetFormValues],
);
const handleSheetOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
return;
}
if (isDirty) {
setShowUnsavedChangesDialog(true);
return;
}
closeForm();
},
[closeForm, isDirty],
);
const handleFormSubmit = form.handleSubmit(
async (values) => {
await onSubmit(values);
closeForm();
},
() => {
setOpenAccordionSections([...ACCORDION_SECTION_VALUES]);
},
);
const selectedDataSource = watch('dataSource');
const selectedTableSchema = watch('tableSchema');
const selectedTableName = watch('tableName');
const selectedTriggerOperations = watch('triggerOperations');
const selectedUpdateTriggerOn = watch('updateTriggerOn');
const hasUpdateTrigger = selectedTriggerOperations.includes('update');
const hasToChooseUpdateTriggerColumns = selectedUpdateTriggerOn === 'choose';
const isRequestOptionsTransformEnabled = !!watch('requestOptionsTransform');
const isPayloadTransformEnabled = !!watch('payloadTransform');
const schemas = useMemo(() => {
const databaseSchemas =
metadata?.sources
?.find((source) => source.name === selectedDataSource)
?.tables?.map((table) => table.table.schema!) ?? [];
const deduped = [...new Set(databaseSchemas)].sort();
return deduped;
}, [selectedDataSource, metadata]);
const tables = useMemo(
() =>
metadata?.sources
?.find((source) => source.name === selectedDataSource)
?.tables?.filter((table) => table.table.schema === selectedTableSchema)
?.map((table) => table.table.name!) ?? [],
[selectedDataSource, selectedTableSchema, metadata],
);
const handleDiscardChanges = () => {
closeForm();
};
const triggerNode = trigger({ open: openForm });
const handleAccordionValueChange = useCallback((value: string[]) => {
setOpenAccordionSections(value as AccordionSectionValue[]);
}, []);
return (
<>
{triggerNode}
<Sheet open={isSheetOpen} onOpenChange={handleSheetOpenChange}>
<SheetContent
showOverlay
className="w-xl md:w-4xl box flex flex-auto flex-col gap-0 p-0 sm:max-w-4xl"
onPointerDownOutside={(e) => {
let element: Element | null = e.target as Element;
while (element) {
const className =
typeof element.className === 'string' ? element.className : '';
const ariaLive = element.getAttribute('aria-live');
if (
ariaLive === 'polite' ||
ariaLive === 'assertive' ||
(className.includes('rounded-lg') &&
className.includes('text-white') &&
className.includes('max-w-xl'))
) {
e.preventDefault();
return;
}
element = element.parentElement;
}
}}
>
<SheetHeader className="p-6">
<SheetTitle className="text-lg">{titleText}</SheetTitle>
<SheetDescription>{descriptionText}</SheetDescription>
</SheetHeader>
<Separator />
<Form {...form}>
<form
id="event-trigger-form"
onSubmit={handleFormSubmit}
className="flex flex-auto flex-col gap-4 overflow-y-auto pb-4"
>
<div className="flex flex-auto flex-col">
<div className="flex flex-col gap-6 p-6 text-foreground">
<FormInput
control={form.control}
name="triggerName"
label="Trigger Name"
placeholder="trigger_name"
disabled={isEditing}
className="max-w-lg"
autoComplete="off"
/>
<div className="flex max-w-lg flex-row justify-between gap-6 lg:gap-20">
<FormSelect
control={form.control}
name="dataSource"
label="Data Source"
placeholder="Select"
disabled={isEditing}
className="min-w-[120px] max-w-60 text-foreground"
>
{dataSources?.map((dataSource) => (
<SelectItem key={dataSource} value={dataSource}>
{dataSource}
</SelectItem>
))}
</FormSelect>
<div className="flex w-full flex-row items-center justify-start self-start">
<div className="w-auto self-start">
<FormSelect
control={form.control}
name="tableSchema"
label="Schema"
placeholder="Select"
disabled={!selectedDataSource || isEditing}
className="relative w-full min-w-[120px] max-w-32 rounded-r-none border-r-0 text-foreground focus:z-10"
>
{schemas?.map((tableSchema) => (
<SelectItem key={tableSchema} value={tableSchema}>
{tableSchema}
</SelectItem>
))}
</FormSelect>
</div>
<div className="w-full self-start">
<FormSelect
control={form.control}
name="tableName"
label="Table"
placeholder="Select"
disabled={!selectedTableSchema || isEditing}
className="relative w-full min-w-[120px] max-w-72 rounded-l-none text-foreground focus:z-10"
>
{tables?.map((tableName) => (
<SelectItem key={tableName} value={tableName}>
{tableName}
</SelectItem>
))}
</FormSelect>
</div>
</div>
</div>
</div>
<Separator />
<div className="flex flex-col gap-6 p-6">
<FormField
control={form.control}
name="triggerOperations"
render={({ field }) => (
<div className="flex flex-col gap-6">
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
Trigger Operations
</h3>
<FormDescription>
Trigger event on these table operations
</FormDescription>
</div>
<div className="flex flex-row items-center justify-start gap-8">
<FormDescription className="flex flex-row items-center gap-1">
On{' '}
<span className="font-mono">
{selectedTableName}
</span>
table:
</FormDescription>
{ALL_TRIGGER_OPERATIONS.map((operation) => (
<FormItem
key={operation}
className="flex w-auto flex-row items-center space-x-2 space-y-0"
>
<FormControl>
<Checkbox
id={`trigger-operation-${operation}`}
checked={field.value.includes(operation)}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, operation]
: field.value.filter(
(value) => value !== operation,
);
field.onChange(newValue);
}}
/>
</FormControl>
<FormLabel
htmlFor={`trigger-operation-${operation}`}
className="cursor-pointer font-normal text-foreground"
>
{operation}
</FormLabel>
</FormItem>
))}
</div>
<FormMessage />
</div>
)}
/>
{hasUpdateTrigger && (
<>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="updateTriggerOn"
render={({ field }) => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
Trigger columns for update operation
</h3>
<FormDescription>
For update triggers, webhook will be triggered
only when selected columns are modified
</FormDescription>
</div>
<FormControl>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
className="flex flex-row items-center gap-12"
>
{updateTriggerOnOptions.map(
(updateTriggerOnValue) => (
<FormItem
key={updateTriggerOnValue}
className="flex w-auto flex-row items-center space-x-2 space-y-0"
>
<FormControl>
<RadioGroupItem
value={updateTriggerOnValue}
id={`update-trigger-on-${updateTriggerOnValue}`}
/>
</FormControl>
<FormLabel
htmlFor={`update-trigger-on-${updateTriggerOnValue}`}
className="cursor-pointer font-normal text-foreground"
>
{updateTriggerOnValue}
</FormLabel>
</FormItem>
),
)}
</RadioGroup>
</FormControl>
<FormMessage />
</div>
)}
/>
</div>
{hasToChooseUpdateTriggerColumns && (
<UpdateTriggerColumnsSection />
)}
</>
)}
</div>
<Separator />
<div className="flex flex-col gap-6 px-6 py-6 text-foreground">
<div className="flex flex-row items-center gap-2">
<h3 className="text-sm font-medium">
Webhook (HTTP/S) Handler{' '}
</h3>
<FormDescription>
<InfoTooltip>
Environment variables and secrets are available using
the {'{{VARIABLE}}'} tag.
</InfoTooltip>
</FormDescription>
</div>
<FormInput
control={form.control}
name="webhook"
label="Webhook URL or template"
placeholder="https://httpbin.org/post or {{MY_WEBHOOK_URL}}/handler"
className="max-w-lg text-foreground"
/>
</div>
<Separator />
<Accordion
type="multiple"
value={openAccordionSections}
onValueChange={handleAccordionValueChange}
>
<AccordionItem value="retry-configuration" className="px-6">
<AccordionTrigger className="text-base text-foreground">
Retry and Headers Settings
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-8 border-l">
<RetryConfigurationSection className="pl-4" />
<Separator />
<HeadersSection className="pl-4" />
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="transformation-configuration"
className="px-6"
>
<AccordionTrigger className="text-base text-foreground">
Configure Transformation
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-8 border-l">
<div className="space-y-4 pl-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
Enable Transformations
</h3>
</div>
<div className="flex flex-row items-center gap-8">
<FormItem className="flex w-auto flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
id="enable-request-transform"
checked={isRequestOptionsTransformEnabled}
onCheckedChange={(checked) => {
const enabled = !!checked;
setValue(
'requestOptionsTransform',
enabled
? defaultRequestOptionsTransformValues
: undefined,
{ shouldDirty: true },
);
}}
/>
</FormControl>
<FormLabel
htmlFor="enable-request-transform"
className="cursor-pointer font-normal text-foreground"
>
Request Options Transform
</FormLabel>
</FormItem>
<FormItem className="flex w-auto flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
id="enable-payload-transform"
checked={isPayloadTransformEnabled}
onCheckedChange={(checked) => {
const enabled = !!checked;
setValue(
'payloadTransform',
enabled
? defaultPayloadTransformValues
: undefined,
{ shouldDirty: true },
);
}}
/>
</FormControl>
<FormLabel
htmlFor="enable-payload-transform"
className="cursor-pointer font-normal text-foreground"
>
Payload Transform
</FormLabel>
</FormItem>
</div>
</div>
{(isRequestOptionsTransformEnabled ||
isPayloadTransformEnabled) && <Separator />}
{isRequestOptionsTransformEnabled && (
<RequestOptionsSection className="pl-4" />
)}
{isRequestOptionsTransformEnabled &&
isPayloadTransformEnabled && <Separator />}
{isPayloadTransformEnabled && (
<PayloadTransformSection className="pl-4" />
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</form>
</Form>
<SheetFooter className="flex-shrink-0 border-t p-2">
<div className="flex flex-1 flex-row items-start justify-between gap-2">
<SheetClose asChild>
<Button
variant="ghost"
className="text-foreground"
disabled={form.formState.isSubmitting}
>
Cancel
</Button>
</SheetClose>
<ButtonWithLoading
type="submit"
form="event-trigger-form"
loading={form.formState.isSubmitting}
disabled={
form.formState.isSubmitting || !form.formState.isDirty
}
>
{submitButtonText}
</ButtonWithLoading>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<DiscardChangesDialog
open={showUnsavedChangesDialog}
onOpenChange={setShowUnsavedChangesDialog}
onDiscardChanges={handleDiscardChanges}
/>
</>
);
}

View File

@@ -0,0 +1,211 @@
import {
DEFAULT_NUM_RETRIES,
DEFAULT_RETRY_INTERVAL_SECONDS,
DEFAULT_RETRY_TIMEOUT_SECONDS,
} from '@/features/orgs/projects/events/event-triggers/constants';
import { getSampleInputPayload } from '@/features/orgs/projects/events/event-triggers/utils/getSampleInputPayload';
import { z } from 'zod';
export const headerTypes = [
{
label: 'Value',
value: 'fromValue',
},
{
label: 'Env Var',
value: 'fromEnv',
},
] as const;
export const ALL_TRIGGER_OPERATIONS = [
'insert',
'update',
'delete',
'manual',
] as const;
export type TriggerOperation = (typeof ALL_TRIGGER_OPERATIONS)[number];
export const updateTriggerOnOptions = ['all', 'choose'] as const;
export const requestTransformMethods = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
] as const;
export const requestOptionsTransformQueryParamsTypeOptions = [
'Key Value',
'URL string template',
] as const;
export const validationSchema = z
.object({
triggerName: z
.string({ required_error: 'Trigger name required' })
.min(1, { message: 'Trigger name required' })
.max(42, { message: 'Trigger name must be at most 42 characters' })
.regex(/^[a-zA-Z0-9_-]+$/, {
message:
'Trigger name can only contain alphanumeric characters, underscores, and hyphens',
}),
dataSource: z
.string({ required_error: 'Data source required' })
.min(1, { message: 'Data source required' }),
tableName: z
.string({ required_error: 'Table name required' })
.min(1, { message: 'Table name required' }),
tableSchema: z
.string({ required_error: 'Schema required' })
.min(1, { message: 'Schema required' }),
webhook: z.string().min(1, { message: 'Webhook is required' }),
triggerOperations: z
.array(z.enum(ALL_TRIGGER_OPERATIONS))
.refine((value) => value.some((item) => item), {
message: 'At least one trigger operation is required',
}),
updateTriggerOn: z.enum(updateTriggerOnOptions).optional(),
updateTriggerColumns: z.array(z.string()).optional(),
retryConf: z.object({
numRetries: z.coerce.number().min(0).default(DEFAULT_NUM_RETRIES),
intervalSec: z.coerce
.number()
.min(0)
.default(DEFAULT_RETRY_INTERVAL_SECONDS),
timeoutSec: z.coerce
.number()
.min(0)
.default(DEFAULT_RETRY_TIMEOUT_SECONDS),
}),
headers: z.array(
z.object({
name: z.string().min(1, 'Name is required'),
type: z.enum(
headerTypes.map((header) => header.value) as [
(typeof headerTypes)[number]['value'],
],
),
value: z.string().min(1, 'Value is required'),
}),
),
sampleContext: z.array(
z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
}),
),
requestOptionsTransform: z
.object({
method: z.enum(requestTransformMethods).optional(),
urlTemplate: z.string().optional(),
queryParams: z.discriminatedUnion('queryParamsType', [
z.object({
queryParamsType: z.literal(
requestOptionsTransformQueryParamsTypeOptions[0],
),
queryParams: z.array(
z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
}),
),
}),
z.object({
queryParamsType: z.literal(
requestOptionsTransformQueryParamsTypeOptions[1],
),
queryParamsURL: z.string(),
}),
]),
})
.optional(),
payloadTransform: z
.object({
sampleInput: z.string(),
requestBodyTransform: z.discriminatedUnion('requestBodyTransformType', [
z.object({
requestBodyTransformType: z.literal('disabled'),
}),
z.object({
requestBodyTransformType: z.literal('application/json'),
template: z.string(),
}),
z.object({
requestBodyTransformType: z.literal(
'application/x-www-form-urlencoded',
),
formTemplate: z.array(
z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
}),
),
}),
]),
})
.optional(),
})
.refine(
(data) => {
if (data.updateTriggerOn === 'choose') {
return (data.updateTriggerColumns?.length ?? 0) > 0;
}
return true;
},
{
message: 'At least one column is required for update trigger',
path: ['updateTriggerColumns'],
},
);
export const defaultRequestOptionsTransformValues: NonNullable<
BaseEventTriggerFormValues['requestOptionsTransform']
> = {
method: undefined,
urlTemplate: '',
queryParams: {
queryParamsType: 'Key Value',
queryParams: [],
},
};
export const defaultPayloadTransformValues: NonNullable<
BaseEventTriggerFormValues['payloadTransform']
> = {
sampleInput: getSampleInputPayload({}),
requestBodyTransform: {
requestBodyTransformType: 'application/json',
template: `{
"table": {
"name": {{$body.table.name}},
"schema": {{$body.table.schema}}
}
}`,
},
};
export const defaultFormValues: BaseEventTriggerFormValues = {
triggerName: '',
dataSource: '',
tableName: '',
tableSchema: '',
webhook: '',
triggerOperations: [],
updateTriggerOn: 'all',
updateTriggerColumns: [],
retryConf: {
numRetries: DEFAULT_NUM_RETRIES,
intervalSec: DEFAULT_RETRY_INTERVAL_SECONDS,
timeoutSec: DEFAULT_RETRY_TIMEOUT_SECONDS,
},
headers: [],
sampleContext: [],
requestOptionsTransform: undefined,
payloadTransform: undefined,
};
export type BaseEventTriggerFormValues = z.infer<typeof validationSchema>;
export type BaseEventTriggerFormInitialData = BaseEventTriggerFormValues;

View File

@@ -0,0 +1,2 @@
export * from './BaseEventTriggerForm';
export { default as BaseEventTriggerForm } from './BaseEventTriggerForm';

View File

@@ -0,0 +1,125 @@
import { FormInput } from '@/components/form/FormInput';
import { FormSelect } from '@/components/form/FormSelect';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Button } from '@/components/ui/v3/button';
import { FormDescription } from '@/components/ui/v3/form';
import { SelectItem } from '@/components/ui/v3/select';
import { InfoTooltip } from '@/features/orgs/projects/common/components/InfoTooltip';
import {
headerTypes,
type BaseEventTriggerFormValues,
} from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useFieldArray, useFormContext } from 'react-hook-form';
interface HeadersSectionProps {
className?: string;
}
export default function HeadersSection({ className }: HeadersSectionProps) {
const form = useFormContext<BaseEventTriggerFormValues>();
const { watch } = form;
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'headers',
});
const types = watch('headers').map((header) => header.type);
return (
<div className={`flex flex-col gap-6 ${className}`}>
<div className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2">
<h3 className="text-base font-medium text-foreground">
Additional Headers{' '}
</h3>
<FormDescription>
<InfoTooltip>
Custom headers to be sent with the webhook request.
</InfoTooltip>
</FormDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-primary hover:bg-muted hover:text-primary"
onClick={() => append({ name: '', type: 'fromValue', value: '' })}
>
<PlusIcon className="size-5" />
</Button>
</div>
<div className="flex flex-col gap-4">
{fields.length > 0 && (
<div className="grid grid-flow-row grid-cols-9 text-sm+ text-foreground">
<span className="col-span-3">Key</span>
<div className="col-span-1" />
<span className="col-span-4">Value</span>
</div>
)}
{fields.map((fieldItem, index) => (
<div
key={fieldItem.id}
className="grid grid-flow-row grid-cols-9 items-center gap-2"
>
<div className="col-span-3 self-start">
<FormInput
control={form.control}
name={`headers.${index}.name`}
label=""
placeholder="Header name"
className="text-foreground"
autoComplete="off"
/>
</div>
<div className="col-span-1 flex h-10 items-center justify-center self-start pt-2">
<span className="text-center text-foreground">:</span>
</div>
<div className="col-span-4 flex items-center self-start">
<div className="self-start">
<FormSelect
control={form.control}
name={`headers.${index}.type`}
label=""
placeholder="Select type"
className="relative min-w-[120px] max-w-60 rounded-r-none border-r-0 text-foreground focus:z-10"
>
{headerTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</FormSelect>
</div>
<div className="flex-1">
<FormInput
control={form.control}
name={`headers.${index}.value`}
label=""
placeholder={
types[index] === 'fromValue'
? 'Header value'
: 'Env variable'
}
className="relative rounded-l-none text-foreground focus:z-10"
autoComplete="off"
/>
</div>
</div>
<div className="col-span-1 self-start pt-3">
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => remove(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import { FormInput } from '@/components/form/FormInput';
import { FormSelect } from '@/components/form/FormSelect';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/v3/alert';
import { Button } from '@/components/ui/v3/button';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { SelectItem } from '@/components/ui/v3/select';
import { Textarea } from '@/components/ui/v3/textarea';
import { InfoTooltip } from '@/features/orgs/projects/common/components/InfoTooltip';
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
import { type BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { getSampleInputPayload } from '@/features/orgs/projects/events/event-triggers/utils/getSampleInputPayload';
import { RefreshCw } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import TransformedRequestBody from './TransformedRequestBody';
interface PayloadTransformSectionProps {
className?: string;
}
export default function PayloadTransformSection({
className,
}: PayloadTransformSectionProps) {
const form = useFormContext<BaseEventTriggerFormValues>();
const { watch, setValue } = form;
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'payloadTransform.requestBodyTransform.formTemplate',
});
const values = watch();
const selectedTableSchema = watch('tableSchema');
const selectedTableName = watch('tableName');
const { data: selectedTableData } = useTableQuery(
[`default.${selectedTableSchema}.${selectedTableName}`],
{
schema: selectedTableSchema,
table: selectedTableName,
queryOptions: {
enabled: !!selectedTableSchema && !!selectedTableName,
},
},
);
const handleRefreshPayload = () => {
setValue(
'payloadTransform.sampleInput',
getSampleInputPayload({
formValues: values,
columns: selectedTableData?.columns,
}),
);
};
return (
<div className={`flex flex-col gap-6 ${className}`}>
<div className="space-y-2">
<h3 className="text-base font-medium text-foreground">
Payload Transform
</h3>
<FormDescription>
Change the payload to adapt to your API&apos;s expected format.
</FormDescription>
</div>
<div className="flex flex-col gap-12">
<FormField
name="payloadTransform.sampleInput"
control={form.control}
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center gap-2">
<FormLabel className="text-foreground">Sample Input</FormLabel>
<FormDescription>
<InfoTooltip>
<p>Sample input defined by your definition.</p>
</InfoTooltip>
</FormDescription>
<Button
className="flex flex-row items-center gap-2 text-foreground"
size="sm"
variant="outline"
type="button"
onClick={handleRefreshPayload}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<FormControl>
<Textarea
{...field}
id="payloadTransform.sampleInput"
className="min-h-[250px] max-w-lg font-mono text-foreground aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:ring-destructive/20"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<div className="flex max-w-lg flex-row justify-between gap-4 text-foreground">
<div className="flex flex-row items-center gap-2">
<h4 className="text-sm font-medium text-foreground">
Request Body Transform
</h4>
<FormDescription className="flex flex-row items-center gap-2">
<InfoTooltip>
<p>
The template which will transform your request body into the
required specification.
</p>
<p>
You can use {'{{$body}}'} to access the original request
body
</p>
</InfoTooltip>
</FormDescription>
</div>
<FormSelect
control={form.control}
name="payloadTransform.requestBodyTransform.requestBodyTransformType"
label="Transform Type"
placeholder="Select"
className="min-w-[120px] text-left text-foreground"
>
{[
'disabled',
'application/json',
'application/x-www-form-urlencoded',
].map((requestBodyTransformType) => (
<SelectItem
key={requestBodyTransformType}
value={requestBodyTransformType}
>
{requestBodyTransformType}
</SelectItem>
))}
</FormSelect>
</div>
{values?.payloadTransform?.requestBodyTransform
?.requestBodyTransformType === 'application/json' && (
<FormField
name="payloadTransform.requestBodyTransform.template"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-foreground">
Request Body Transform JSON Template
</FormLabel>
<FormControl>
<Textarea
{...field}
id="payloadTransform.requestBodyTransform.template"
className="min-h-[250px] max-w-lg font-mono text-foreground aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:ring-destructive/20"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{values?.payloadTransform?.requestBodyTransform
?.requestBodyTransformType ===
'application/x-www-form-urlencoded' && (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
Form Template
</h4>
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-primary hover:bg-muted hover:text-primary"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="size-5" />
</Button>
</div>
<div className="flex flex-col gap-4">
{fields.length > 0 && (
<div className="grid grid-flow-row grid-cols-9 text-sm+ text-foreground">
<span className="col-span-3">Key</span>
<div className="col-span-1" />
<span className="col-span-4">Value</span>
</div>
)}
{fields.map((fieldItem, index) => (
<div
key={fieldItem.id}
className="grid grid-flow-row grid-cols-9 items-center gap-2"
>
<div className="col-span-3 self-start">
<FormInput
control={form.control}
name={`payloadTransform.requestBodyTransform.formTemplate.${index}.key`}
label=""
placeholder="Key"
className="text-foreground"
autoComplete="off"
/>
</div>
<div className="col-span-1 flex h-10 items-center justify-center self-start pt-2">
<span className="text-center text-foreground">:</span>
</div>
<div className="col-span-4 self-start">
<FormInput
control={form.control}
name={`payloadTransform.requestBodyTransform.formTemplate.${index}.value`}
label=""
placeholder="Value"
className="text-foreground"
autoComplete="off"
/>
</div>
<div className="col-span-1 self-start pt-3">
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => remove(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{values?.payloadTransform?.requestBodyTransform
?.requestBodyTransformType === 'disabled' && (
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Request Body Transformation Disabled</AlertTitle>
<AlertDescription>
The request body is disabled. No request body will be sent with
this event trigger. Enable the request body transform to
customize the payload sent with this event trigger.
</AlertDescription>
</Alert>
)}
</div>
<TransformedRequestBody />
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/v3/alert';
import { FormDescription, FormItem, FormLabel } from '@/components/ui/v3/form';
import { Skeleton } from '@/components/ui/v3/skeleton';
import { Textarea } from '@/components/ui/v3/textarea';
import type { BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useTestWebhookTransformQuery } from '@/features/orgs/projects/events/event-triggers/hooks/useTestWebhookTransformQuery';
import buildTestWebhookTransformDTO from '@/features/orgs/projects/events/event-triggers/utils/buildTestWebhookTransformDTO/buildTestWebhookTransformDTO';
import type { TestWebhookTransformArgs } from '@/utils/hasura-api/generated/schemas';
import debounce from 'lodash.debounce';
import { useEffect, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
export default function TransformedRequestBody() {
const form = useFormContext<BaseEventTriggerFormValues>();
const values = form.watch();
let args: TestWebhookTransformArgs;
let buildArgsError: string | null = null;
try {
args = buildTestWebhookTransformDTO({ formValues: values });
} catch (error) {
buildArgsError =
error instanceof Error
? error.message
: 'Invalid sample input. Please enter a valid JSON string.';
const sanitizedValues = {
...values,
payloadTransform: {
...(values.payloadTransform ?? {}),
sampleInput: '{}',
},
} as BaseEventTriggerFormValues;
args = buildTestWebhookTransformDTO({
formValues: sanitizedValues,
});
}
const [debouncedArgs, setDebouncedArgs] =
useState<TestWebhookTransformArgs>(args);
const debouncedSetArgs = useMemo(
() =>
debounce((nextArgs: TestWebhookTransformArgs) => {
setDebouncedArgs(nextArgs);
}, 500),
[],
);
useEffect(() => {
debouncedSetArgs(args);
return () => debouncedSetArgs.cancel();
}, [args, debouncedSetArgs]);
const { data, isLoading, error } = useTestWebhookTransformQuery(
debouncedArgs,
{
queryOptions: {
enabled: !buildArgsError,
},
},
);
const canRun = Boolean(debouncedArgs.webhook_url) && !buildArgsError;
return (
<FormItem>
<FormLabel className="text-foreground">
Transformed Request Body
</FormLabel>
<FormDescription>
Sample request body to be delivered based on your input and
transformation template.
</FormDescription>
{buildArgsError && (
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Invalid sample input</AlertTitle>
<AlertDescription>{buildArgsError}</AlertDescription>
</Alert>
)}
{!canRun && (
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Webhook URL not configured</AlertTitle>
<AlertDescription>
Please configure your webhook URL to generate request body transform
</AlertDescription>
</Alert>
)}
{canRun && isLoading && (
<Skeleton className="h-[250px] w-full max-w-lg" />
)}
{!buildArgsError && error && (
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Error with webhook handler</AlertTitle>
<AlertDescription>{error.error}</AlertDescription>
</Alert>
)}
{!isLoading && !error && canRun && (
<Textarea
className="min-h-[250px] max-w-lg font-mono text-foreground"
value={JSON.stringify(data?.body, null, 2)}
disabled
/>
)}
</FormItem>
);
}

View File

@@ -0,0 +1,85 @@
import { FormInput } from '@/components/form/FormInput';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { Button } from '@/components/ui/v3/button';
import { type BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useFieldArray, useFormContext } from 'react-hook-form';
export default function KeyValueQueryParams() {
const form = useFormContext<BaseEventTriggerFormValues>();
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'requestOptionsTransform.queryParams.queryParams',
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
Key Value Query Params{' '}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-primary hover:bg-muted hover:text-primary"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="size-5" />
</Button>
</div>
<div className="flex flex-col gap-4">
{fields.length > 0 && (
<div className="grid grid-flow-row grid-cols-9 text-sm+ text-foreground">
<span className="col-span-3">Key</span>
<div className="col-span-1" />
<span className="col-span-4">Value</span>
</div>
)}
{fields.map((fieldItem, index) => (
<div
key={fieldItem.id}
className="grid grid-flow-row grid-cols-9 items-center gap-2"
>
<div className="col-span-3 self-start">
<FormInput
control={form.control}
name={`requestOptionsTransform.queryParams.queryParams.${index}.key`}
label=""
placeholder="Key"
className="text-foreground"
autoComplete="off"
/>
</div>
<div className="col-span-1 flex h-10 items-center justify-center self-start pt-2">
<span className="text-center text-foreground">:</span>
</div>
<div className="col-span-4 self-start">
<FormInput
control={form.control}
name={`requestOptionsTransform.queryParams.queryParams.${index}.value`}
label=""
placeholder="Value"
className="text-foreground"
autoComplete="off"
/>
</div>
<div className="col-span-1 self-start pt-3">
<Button
type="button"
variant="ghost"
size="sm"
className="px-4 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => remove(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@/components/ui/v3/input-group';
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
import {
requestOptionsTransformQueryParamsTypeOptions,
requestTransformMethods,
type BaseEventTriggerFormValues,
} from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useFormContext } from 'react-hook-form';
import KeyValueQueryParams from './KeyValueQueryParams';
import RequestURLTransformPreview from './RequestURLTransformPreview';
import URLTemplateQueryParams from './URLTemplateQueryParams';
interface RequestOptionsSectionProps {
className?: string;
}
export default function RequestOptionsSection({
className,
}: RequestOptionsSectionProps) {
const form = useFormContext<BaseEventTriggerFormValues>();
const { watch } = form;
const queryParamsType = watch(
'requestOptionsTransform.queryParams.queryParamsType',
);
return (
<div className={`flex flex-col gap-6 ${className}`}>
<div className="space-y-2">
<h3 className="text-base font-medium text-foreground">
Request Options
</h3>
<FormDescription>
Configuration to transform the request before sending it to the
webhook
</FormDescription>
</div>
<div className="flex flex-col gap-8">
<FormField
name="requestOptionsTransform.method"
control={form.control}
render={({ field }) => (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">
Request Method
</h4>
</div>
<FormControl>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
className="flex flex-row items-center gap-12"
>
{requestTransformMethods.map((requestTransformMethod) => (
<FormItem
key={requestTransformMethod}
className="flex w-auto flex-row items-center space-x-2 space-y-0"
>
<FormControl>
<RadioGroupItem
value={requestTransformMethod}
id={`request-options-transform-method-${requestTransformMethod}`}
/>
</FormControl>
<FormLabel
htmlFor={`request-options-transform-method-${requestTransformMethod}`}
className="cursor-pointer font-normal text-foreground"
>
{requestTransformMethod}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</div>
)}
/>
<FormField
name="requestOptionsTransform.urlTemplate"
control={form.control}
render={({ field }) => (
<FormItem className="max-w-lg">
<FormLabel className="text-foreground">
Request URL Template
</FormLabel>
<FormControl>
<InputGroup>
<InputGroupAddon className="border-r pr-2">
<InputGroupText>{'{{$base_url}}'}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
{...field}
id="requestOptionsTransform.urlTemplate"
placeholder="URL Template (Optional)..."
className="w-full pl-2 text-foreground aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:ring-destructive/20"
wrapperClassName="w-full"
/>
</InputGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="requestOptionsTransform.queryParams.queryParamsType"
control={form.control}
render={({ field }) => (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">
Query params type
</h4>
</div>
<FormControl>
<RadioGroup
name={field.name}
value={field.value}
defaultValue={
requestOptionsTransformQueryParamsTypeOptions[0]
}
onValueChange={field.onChange}
className="flex flex-row items-center gap-12"
>
{requestOptionsTransformQueryParamsTypeOptions.map(
(requestOptionsTransformQueryParamsType) => (
<FormItem
key={requestOptionsTransformQueryParamsType}
className="flex w-auto flex-row items-center space-x-2 space-y-0"
>
<FormControl>
<RadioGroupItem
value={requestOptionsTransformQueryParamsType}
id={`request-options-transform-query-params-type-${requestOptionsTransformQueryParamsType}`}
/>
</FormControl>
<FormLabel
htmlFor={`request-options-transform-query-params-type-${requestOptionsTransformQueryParamsType}`}
className="cursor-pointer font-normal text-foreground"
>
{requestOptionsTransformQueryParamsType}
</FormLabel>
</FormItem>
),
)}
</RadioGroup>
</FormControl>
<FormMessage />
</div>
)}
/>
{queryParamsType === 'Key Value' ? (
<KeyValueQueryParams />
) : (
<URLTemplateQueryParams />
)}
<RequestURLTransformPreview />
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/v3/alert';
import { Skeleton } from '@/components/ui/v3/skeleton';
import type { BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useTestWebhookTransformQuery } from '@/features/orgs/projects/events/event-triggers/hooks/useTestWebhookTransformQuery';
import buildTestWebhookTransformDTO from '@/features/orgs/projects/events/event-triggers/utils/buildTestWebhookTransformDTO/buildTestWebhookTransformDTO';
import { isEmptyValue } from '@/lib/utils';
import type { TestWebhookTransformArgs } from '@/utils/hasura-api/generated/schemas';
import debounce from 'lodash.debounce';
import { useEffect, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
export default function RequestURLTransformPreview() {
const form = useFormContext<BaseEventTriggerFormValues>();
const values = form.watch();
const args = buildTestWebhookTransformDTO({ formValues: values });
const [debouncedArgs, setDebouncedArgs] =
useState<TestWebhookTransformArgs>(args);
const debouncedSetArgs = useMemo(
() =>
debounce((nextArgs: TestWebhookTransformArgs) => {
setDebouncedArgs(nextArgs);
}, 500),
[],
);
useEffect(() => {
debouncedSetArgs(args);
return () => debouncedSetArgs.cancel();
}, [args, debouncedSetArgs]);
const { data, isLoading, error } =
useTestWebhookTransformQuery(debouncedArgs);
const url = data?.webhook_url;
if (error) {
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-foreground">
URL transform preview
</h3>
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Validation failed</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
</div>
);
}
if (isEmptyValue(values.webhook) || error) {
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-foreground">
URL transform preview
</h3>
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Error with webhook handler</AlertTitle>
<AlertDescription>
Please configure your webhook handler to generate request url
transform
</AlertDescription>
</Alert>
</div>
);
}
if (isLoading || isEmptyValue(url)) {
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-foreground">
URL transform preview (loading...)
</h3>
<Skeleton className="h-4 w-full max-w-lg" />
</div>
);
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-foreground">
URL transform preview
</h3>
<p className="max-w-lg rounded-md bg-muted-foreground/10 p-2 font-mono text-sm text-muted-foreground dark:bg-muted">
{url}
</p>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/v3/form';
import { Textarea } from '@/components/ui/v3/textarea';
import { type BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useFormContext } from 'react-hook-form';
export default function URLTemplateQueryParams() {
const form = useFormContext<BaseEventTriggerFormValues>();
return (
<FormField
name="requestOptionsTransform.queryParams.queryParamsURL"
control={form.control}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
id="requestOptionsTransform.queryParams.queryParamsURL"
placeholder={`You can also use Kriti Template here to customise the query parameter string.
e.g. {{concat(["userId=", $session_variables["x-hasura-user-id"]])}}`}
className="min-h-[120px] max-w-lg text-foreground aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:ring-destructive/20"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1 @@
export { default as RequestOptionsSection } from './RequestOptionsSection';

View File

@@ -0,0 +1,67 @@
import { FormInput } from '@/components/form/FormInput';
import { FormDescription } from '@/components/ui/v3/form';
import type { BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useFormContext } from 'react-hook-form';
interface RetryConfigurationSectionProps {
className?: string;
}
export default function RetryConfigurationSection({
className,
}: RetryConfigurationSectionProps) {
const form = useFormContext<BaseEventTriggerFormValues>();
return (
<div className={`flex flex-col gap-6 ${className}`}>
<div className="space-y-2">
<h3 className="text-base font-medium text-foreground">
Retry Configuration
</h3>
<FormDescription>
Configuration to retry the webhook in case of failure
</FormDescription>
</div>
<div className="flex flex-col gap-8 text-foreground lg:flex-row">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<FormInput
control={form.control}
name="retryConf.numRetries"
label="Number of Retries"
placeholder="number of retries (default: 0)"
type="number"
className="text-foreground"
autoComplete="off"
infoTooltip="Number of retries that Hasura makes to the webhook in case of failure"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<FormInput
control={form.control}
name="retryConf.intervalSec"
label="Retry interval (in seconds)"
placeholder="retry interval (default: 10)"
type="number"
className="text-foreground"
autoComplete="off"
infoTooltip="Interval in seconds between each retry of the webhook"
/>
</div>
<div className="flex flex-col gap-2">
<FormInput
control={form.control}
name="retryConf.timeoutSec"
label="Timeout (in seconds)"
placeholder="timeout (default: 60)"
type="number"
className="text-foreground"
autoComplete="off"
infoTooltip="Request timeout (in seconds) for the webhook"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/v3/alert';
import { Checkbox } from '@/components/ui/v3/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { Skeleton } from '@/components/ui/v3/skeleton';
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
import type { BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { isEmptyValue } from '@/lib/utils';
import { useFormContext } from 'react-hook-form';
export default function UpdateTriggerColumnsSection() {
const form = useFormContext<BaseEventTriggerFormValues>();
const selectedTableSchema = form.watch('tableSchema');
const selectedTableName = form.watch('tableName');
const canFetchColumns = Boolean(selectedTableSchema && selectedTableName);
const { data: selectedTableData, isLoading } = useTableQuery(
[`default.${selectedTableSchema}.${selectedTableName}`],
{
schema: selectedTableSchema,
table: selectedTableName,
queryOptions: {
enabled: canFetchColumns,
},
},
);
const columns =
selectedTableData?.columns
?.map((column) => (column.column_name as string) ?? null)
.filter(Boolean) ?? [];
if (!canFetchColumns) {
return (
<Alert variant="destructive" className="max-w-lg">
<AlertTitle>Table not selected</AlertTitle>
<AlertDescription>
Please select a table to list its columns
</AlertDescription>
</Alert>
);
}
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-4 w-full max-w-xs" />
<div className="flex max-w-lg flex-row gap-6">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
</div>
);
}
return (
<FormField
control={form.control}
name="updateTriggerColumns"
render={({ field }) => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
List of columns to trigger
</h3>
</div>
<div className="flex flex-row items-center justify-start gap-8">
{isEmptyValue(columns) ? (
<p className="text-muted-foreground">
Select a table first to see the columns
</p>
) : (
columns.map((column) => (
<FormItem
key={column}
className="flex w-auto flex-row items-center space-x-2 space-y-0"
>
<FormControl>
<Checkbox
id={`column-on-update-${column}`}
checked={field.value?.includes(column)}
onCheckedChange={(checked) => {
const newValue = checked
? [...(field.value ?? []), column]
: (field.value ?? []).filter(
(value) => value !== column,
);
field.onChange(newValue);
}}
/>
</FormControl>
<FormLabel
htmlFor={`column-on-update-${column}`}
className="cursor-pointer font-normal text-foreground"
>
{column}
</FormLabel>
</FormItem>
))
)}
</div>
<FormMessage />
</div>
)}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { Button } from '@/components/ui/v3/button';
import { useGetMetadataResourceVersion } from '@/features/orgs/projects/common/hooks/useGetMetadataResourceVersion';
import {
BaseEventTriggerForm,
type BaseEventTriggerFormTriggerProps,
} from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm';
import type { BaseEventTriggerFormValues } from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useCreateEventTriggerMutation } from '@/features/orgs/projects/events/event-triggers/hooks/useCreateEventTriggerMutation';
import { buildEventTriggerDTO } from '@/features/orgs/projects/events/event-triggers/utils/buildEventTriggerDTO';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
export default function CreateEventTriggerForm() {
const { mutateAsync: createEventTrigger } = useCreateEventTriggerMutation();
const { data: resourceVersion } = useGetMetadataResourceVersion();
const router = useRouter();
const { orgSlug, appSubdomain } = router.query;
const renderCreateEventTriggerButton = useCallback(
({ open }: BaseEventTriggerFormTriggerProps) => (
<Button
variant="ghost"
size="icon"
aria-label="Add event trigger"
onClick={() => open()}
>
<Plus className="h-5 w-5 text-primary dark:text-foreground" />
</Button>
),
[],
);
const handleSubmit = async (data: BaseEventTriggerFormValues) => {
const eventTriggerDTO = buildEventTriggerDTO({ formValues: data });
await execPromiseWithErrorToast(
async () => {
await createEventTrigger({
args: eventTriggerDTO,
resourceVersion: resourceVersion ?? undefined,
});
router.push(
`/orgs/${orgSlug}/projects/${appSubdomain}/events/event-trigger/${data.triggerName}`,
);
},
{
loadingMessage: 'Creating event trigger...',
successMessage: 'The event trigger has been created successfully.',
errorMessage:
'An error occurred while creating the event trigger. Please try again.',
},
);
};
return (
<BaseEventTriggerForm
trigger={renderCreateEventTriggerButton}
onSubmit={handleSubmit}
titleText="Create a New Event Trigger"
descriptionText="Enter the details to create your event trigger. Click Create when you're done."
submitButtonText="Create"
/>
);
}

View File

@@ -0,0 +1 @@
export { default as CreateEventTriggerForm } from './CreateEventTriggerForm';

View File

@@ -0,0 +1,39 @@
import { Button } from '@/components/ui/v3/button';
import { cn } from '@/lib/utils';
import { type Column } from '@tanstack/react-table';
import { ChevronDown, ChevronUp } from 'lucide-react';
export default function CreatedAtHeader<TData, TValue>({
column,
}: {
column: Column<TData, TValue>;
}) {
return (
<Button
variant="ghost"
size="sm"
onClick={() => column.toggleSorting()}
className="flex items-center justify-between gap-2"
>
<span>Created At</span>
<span className="flex flex-col">
<ChevronUp
className={cn(
'-mb-0.5 h-4 w-4',
column.getIsSorted() === 'asc'
? 'text-accent-foreground'
: 'text-muted-foreground',
)}
/>
<ChevronDown
className={cn(
'-mt-0.5 h-4 w-4',
column.getIsSorted() === 'desc'
? 'text-accent-foreground'
: 'text-muted-foreground',
)}
/>
</span>
</Button>
);
}

View File

@@ -0,0 +1 @@
export { default as CreatedAtHeader } from './CreatedAtHeader';

View File

@@ -0,0 +1,107 @@
import { Button, ButtonWithLoading } from '@/components/ui/v3/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/v3/dialog';
import { useGetMetadataResourceVersion } from '@/features/orgs/projects/common/hooks/useGetMetadataResourceVersion';
import { useDeleteEventTriggerMutation } from '@/features/orgs/projects/events/event-triggers/hooks/useDeleteEventTriggerMutation';
import { useGetEventTriggers } from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventTriggers';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { isEmptyValue } from '@/lib/utils';
import { useRouter } from 'next/router';
interface DeleteEventTriggerDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
eventTriggerToDelete: string;
}
export default function DeleteEventTriggerDialog({
open,
setOpen,
eventTriggerToDelete,
}: DeleteEventTriggerDialogProps) {
const router = useRouter();
const { orgSlug, appSubdomain, eventTriggerSlug } = router.query;
const { mutateAsync: deleteEventTrigger, isLoading: isDeletingEventTrigger } =
useDeleteEventTriggerMutation();
const { data: resourceVersion } = useGetMetadataResourceVersion();
const { data: eventTriggers } = useGetEventTriggers();
const handleDeleteDialogClick = async () => {
await execPromiseWithErrorToast(
async () => {
const originalEventTrigger = eventTriggers?.find(
(et) => et.name === eventTriggerToDelete,
);
if (
isEmptyValue(eventTriggerToDelete) ||
isEmptyValue(originalEventTrigger)
) {
throw new Error(
'Error deleting event trigger, no event trigger to delete',
);
}
await deleteEventTrigger({
originalEventTrigger: originalEventTrigger!,
resourceVersion,
});
if (eventTriggerSlug === eventTriggerToDelete) {
router.push(`/orgs/${orgSlug}/projects/${appSubdomain}/events`);
}
},
{
loadingMessage: 'Deleting event trigger...',
successMessage: 'Event trigger deleted successfully.',
errorMessage: 'An error occurred while deleting the event trigger.',
},
);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="sm:max-w-[425px]"
hideCloseButton
disableOutsideClick={isDeletingEventTrigger}
>
<DialogHeader>
<DialogTitle className="text-foreground">
Delete Event Trigger
</DialogTitle>
<DialogDescription>
Are you sure you want to delete the{' '}
<span className="rounded-md bg-muted px-1 py-0.5 font-mono">
{eventTriggerToDelete}
</span>{' '}
event trigger?
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:flex sm:flex-col sm:space-x-0">
<ButtonWithLoading
variant="destructive"
className="!text-sm+ text-white"
onClick={handleDeleteDialogClick}
loading={isDeletingEventTrigger}
>
Delete
</ButtonWithLoading>
<DialogClose asChild>
<Button variant="outline" className="!text-sm+ text-foreground">
Cancel
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { default as DeleteEventTriggerDialog } from './DeleteEventTriggerDialog';

View File

@@ -0,0 +1,77 @@
import { useGetMetadataResourceVersion } from '@/features/orgs/projects/common/hooks/useGetMetadataResourceVersion';
import {
BaseEventTriggerForm,
type BaseEventTriggerFormTriggerProps,
} from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm';
import type {
BaseEventTriggerFormInitialData,
BaseEventTriggerFormValues,
} from '@/features/orgs/projects/events/event-triggers/components/BaseEventTriggerForm/BaseEventTriggerFormTypes';
import { useCreateEventTriggerMutation } from '@/features/orgs/projects/events/event-triggers/hooks/useCreateEventTriggerMutation';
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { buildEventTriggerDTO } from '@/features/orgs/projects/events/event-triggers/utils/buildEventTriggerDTO';
import parseEventTriggerFormInitialData from '@/features/orgs/projects/events/event-triggers/utils/parseEventTriggerFormInitialData/parseEventTriggerFormInitialData';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { useRouter } from 'next/router';
import { useEffect, useState, type ReactNode } from 'react';
export interface EditEventTriggerFormProps {
eventTrigger: EventTriggerViewModel;
trigger: (props: BaseEventTriggerFormTriggerProps) => ReactNode;
}
export default function EditEventTriggerForm({
eventTrigger,
trigger,
}: EditEventTriggerFormProps) {
const router = useRouter();
const { orgSlug, appSubdomain } = router.query;
const [initialData, setInitialData] =
useState<BaseEventTriggerFormInitialData>(() =>
parseEventTriggerFormInitialData(eventTrigger),
);
useEffect(() => {
setInitialData(parseEventTriggerFormInitialData(eventTrigger));
}, [eventTrigger]);
const { mutateAsync: createEventTrigger } = useCreateEventTriggerMutation();
const { data: resourceVersion } = useGetMetadataResourceVersion();
const handleSubmit = async (data: BaseEventTriggerFormValues) => {
const eventTriggerDTO = buildEventTriggerDTO({
formValues: data,
isEdit: true,
});
await execPromiseWithErrorToast(
async () => {
await createEventTrigger({
args: eventTriggerDTO,
resourceVersion: resourceVersion ?? undefined,
});
setInitialData(data);
router.push(
`/orgs/${orgSlug}/projects/${appSubdomain}/events/event-trigger/${data.triggerName}`,
);
},
{
loadingMessage: 'Editing event trigger...',
successMessage: 'The event trigger has been edited successfully.',
errorMessage:
'An error occurred while editing the event trigger. Please try again.',
},
);
};
return (
<BaseEventTriggerForm
trigger={trigger}
onSubmit={handleSubmit}
isEditing
initialData={initialData}
titleText="Edit Event Trigger"
descriptionText="Enter the details to edit your event trigger. Click Save when you're done."
submitButtonText="Save"
/>
);
}

View File

@@ -0,0 +1 @@
export { default as EditEventTriggerForm } from './EditEventTriggerForm';

View File

@@ -0,0 +1,191 @@
import { Skeleton } from '@/components/ui/v3/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/v3/table';
import PaginationControls from '@/features/orgs/projects/events/common/components/PaginationControls/PaginationControls';
import { EventTriggerInvocationLogsDataTable } from '@/features/orgs/projects/events/event-triggers/components/EventTriggerInvocationLogsDataTable';
import { DEFAULT_RETRY_TIMEOUT_SECONDS } from '@/features/orgs/projects/events/event-triggers/constants';
import useEventTriggerPagination from '@/features/orgs/projects/events/event-triggers/hooks/useEventTriggerPagination/useEventTriggerPagination';
import useGetEventLogsQuery from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventLogsQuery/useGetEventLogsQuery';
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { cn, isNotEmptyValue } from '@/lib/utils';
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from '@tanstack/react-table';
import { Fragment, useMemo, useState } from 'react';
import columns from './eventsDataTableColumns';
interface EventTriggerEventsDataTableProps {
eventTrigger: EventTriggerViewModel;
}
export default function EventTriggerEventsDataTable({
eventTrigger,
}: EventTriggerEventsDataTableProps) {
const {
offset,
limit,
setLimitAndReset,
goPrev,
goNext,
hasNoPreviousPage,
hasNoNextPage,
data,
isLoading,
} = useEventTriggerPagination({
useQueryHook: useGetEventLogsQuery,
getQueryArgs: (limitArg, offsetArg) => ({
name: eventTrigger.name,
limit: limitArg,
offset: offsetArg,
source: eventTrigger.dataSource,
}),
resetKey: `${eventTrigger.dataSource}:${eventTrigger.name}`,
});
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: data ?? [],
columns,
state: {
sorting,
},
getRowId: (row) => row.id,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: () => true,
});
const skeletonRowKeys = useMemo(
() => Array.from({ length: limit }, (_, index) => `s${index + 1}`),
[limit],
);
return (
<div className="rounded border p-4">
<h3 className="mb-3 font-medium">Events</h3>
<Table>
<colgroup>
{table.getAllLeafColumns().map((col) => (
<col key={col.id} style={{ width: col.getSize() }} />
))}
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, index) => (
<TableHead
key={header.id}
className={index === 0 ? 'pl-1' : ''}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading &&
skeletonRowKeys.map((key) => (
<TableRow key={`skeleton-${key}`}>
{table.getAllLeafColumns().map((col) => (
<TableCell
key={`skeleton-cell-${col.id}`}
style={{ width: col.getSize() }}
>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
{!isLoading &&
isNotEmptyValue(table.getRowModel().rows) &&
table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<TableRow
onClick={row.getToggleExpandedHandler()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.getToggleExpandedHandler()();
}
}}
tabIndex={0}
role="button"
aria-expanded={row.getIsExpanded()}
className={cn('cursor-pointer')}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn({
'max-w-0 truncate': cell.column.id === 'id',
})}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow key={`${row.id}-expanded`}>
<TableCell colSpan={columns.length} className="p-0">
<EventTriggerInvocationLogsDataTable
eventId={row.id}
retryTimeoutSeconds={
eventTrigger.retry_conf?.timeout_sec ??
DEFAULT_RETRY_TIMEOUT_SECONDS
}
source={eventTrigger.dataSource}
/>
</TableCell>
</TableRow>
)}
</Fragment>
))}
{!isLoading && table.getRowModel().rows?.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<PaginationControls
offset={offset}
limit={limit}
hasNoPreviousPage={hasNoPreviousPage}
hasNoNextPage={hasNoNextPage}
onPrev={goPrev}
onNext={() => !hasNoNextPage && goNext()}
onChangeLimit={setLimitAndReset}
/>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { HoverCardTimestamp } from '@/components/presentational/HoverCardTimestamp';
import { TextWithTooltip } from '@/features/orgs/projects/common/components/TextWithTooltip';
import { CreatedAtHeader } from '@/features/orgs/projects/events/event-triggers/components/CreatedAtHeader';
import { highlightMatch } from '@/features/orgs/utils/highlightMatch';
import type { EventLogEntry } from '@/utils/hasura-api/generated/schemas';
import { type ColumnDef } from '@tanstack/react-table';
import { Check, X } from 'lucide-react';
function DeliveredCell({ delivered }: { delivered: boolean }) {
if (delivered) {
return (
<div className="flex items-center justify-center">
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
);
}
return (
<div className="flex items-center justify-center">
<X className="h-4 w-4 text-red-600 dark:text-red-400" />
</div>
);
}
const columns: ColumnDef<EventLogEntry>[] = [
{
id: 'created_at',
accessorKey: 'created_at',
minSize: 50,
size: 68,
maxSize: 68,
header: ({ column }) => <CreatedAtHeader column={column} />,
cell: ({ row }) => (
<HoverCardTimestamp
date={new Date(row.original.created_at)}
className="-m-4 block w-full truncate py-4 pl-4 font-mono text-xs"
/>
),
},
{
id: 'delivered',
accessorKey: 'delivered',
minSize: 70,
size: 70,
maxSize: 70,
header: () => <div className="text-center">Delivered</div>,
enableSorting: false,
cell: ({ row }) => <DeliveredCell delivered={row.original.delivered} />,
},
{
id: 'id',
accessorKey: 'id',
header: 'ID',
minSize: 40,
size: 280,
maxSize: 600,
cell: ({ row, table }) => (
<TextWithTooltip
className="font-mono text-xs"
containerClassName="cursor-text"
text={highlightMatch(
row.original.id,
String(table.getColumn('id')?.getFilterValue() ?? ''),
)}
slotProps={{
container: {
// Prevent row expansion when clicking to select and copy the ID text
onClick: (event) => event.stopPropagation(),
},
}}
/>
),
},
{
id: 'tries',
accessorKey: 'tries',
minSize: 80,
size: 80,
maxSize: 80,
header: 'Tries',
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.tries}</span>
),
},
];
export default columns;

View File

@@ -0,0 +1 @@
export { default as EventTriggerEventsDataTable } from './EventTriggerEventsDataTable';

View File

@@ -0,0 +1,188 @@
import { Skeleton } from '@/components/ui/v3/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/v3/table';
import PaginationControls from '@/features/orgs/projects/events/common/components/PaginationControls/PaginationControls';
import useEventTriggerPagination from '@/features/orgs/projects/events/event-triggers/hooks/useEventTriggerPagination/useEventTriggerPagination';
import { useGetEventAndInvocationLogsById } from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventAndInvocationLogsById';
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas/eventInvocationLogEntry';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
import columns from './invocationDataTableColumns';
import type { EventTriggerInvocationLogsDataTableMeta } from './types';
interface EventTriggerInvocationLogsDataTableProps {
eventId: string;
source: string;
retryTimeoutSeconds: number;
}
const skeletonRowKeys = ['s1', 's2', 's3'];
export default function EventTriggerInvocationLogsDataTable({
eventId,
source,
retryTimeoutSeconds,
}: EventTriggerInvocationLogsDataTableProps) {
const [selectedLog, setSelectedLog] =
useState<EventInvocationLogEntry | null>(null);
const [isRedeliverPending, setIsRedeliverPending] = useState(false);
const {
offset,
limit,
setLimitAndReset,
goPrev,
goNext,
hasNoPreviousPage,
hasNoNextPage,
data,
isLoading,
isInitialLoading,
refetch: refetchInvocations,
} = useEventTriggerPagination({
useQueryHook: useGetEventAndInvocationLogsById,
getQueryArgs: (limitArg, offsetArg) => ({
event_id: eventId,
source,
invocation_log_limit: limitArg,
invocation_log_offset: offsetArg,
}),
getPageLength: (resp) => resp?.invocations?.length,
});
const invocations = data?.invocations;
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: invocations ?? [],
columns,
state: {
sorting,
},
getRowId: (row) => row.id,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
meta: {
selectedLog,
setSelectedLog,
isRedeliverPending,
setIsRedeliverPending,
refetchInvocations,
retryTimeoutSeconds,
} satisfies EventTriggerInvocationLogsDataTableMeta,
});
return (
<div className="border-t bg-muted p-4 pl-8 dark:bg-muted/40">
<h3 className="mb-3 font-medium">Related invocations</h3>
<Table>
<colgroup>
{table.getAllLeafColumns().map((col) => (
<col key={col.id} style={{ width: col.getSize() }} />
))}
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, index) => (
<TableHead
className={index === 0 ? 'pl-1' : ''}
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isInitialLoading &&
skeletonRowKeys.map((key) => (
<TableRow key={`skeleton-${key}`}>
{table.getAllLeafColumns().map((col) => (
<TableCell
key={`skeleton-cell-${col.id}`}
style={{ width: col.getSize() }}
>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
{isRedeliverPending && (
<TableRow data-state="skeleton">
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-16" />
</TableCell>
</TableRow>
)}
{!isLoading &&
table.getRowModel().rows?.length > 0 &&
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`${cell.column.id === 'id' ? 'max-w-0 truncate' : ''}`}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{!isLoading && table.getRowModel().rows?.length === 0 && (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<PaginationControls
offset={offset}
limit={limit}
hasNoPreviousPage={hasNoPreviousPage}
hasNoNextPage={hasNoNextPage}
onPrev={goPrev}
onNext={() => !hasNoNextPage && goNext()}
onChangeLimit={setLimitAndReset}
/>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Button, ButtonWithLoading } from '@/components/ui/v3/button';
import { Dialog, DialogTrigger } from '@/components/ui/v3/dialog';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/v3/tooltip';
import { InvocationLogDetailsDialogContent } from '@/features/orgs/projects/events/event-triggers/components/InvocationLogDetailsDialogContent';
import { DEFAULT_RETRY_TIMEOUT_SECONDS } from '@/features/orgs/projects/events/event-triggers/constants';
import useRedeliverEventMutation from '@/features/orgs/projects/events/event-triggers/hooks/useRedeliverEventMutation/useRedeliverEventMutation';
import { getToastStyleProps } from '@/utils/constants/settings';
import type { GetEventAndInvocationLogsByIdResponse } from '@/utils/hasura-api/generated/schemas';
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas/eventInvocationLogEntry';
import type { UseQueryResult } from '@tanstack/react-query';
import type { Table as TanStackTable } from '@tanstack/react-table';
import { CalendarSync, Eye } from 'lucide-react';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import type { EventTriggerInvocationLogsDataTableMeta } from './types';
export default function InvocationLogActionsCell({
row,
table,
}: {
row: EventInvocationLogEntry;
table: TanStackTable<EventInvocationLogEntry>;
}) {
const meta = table.options.meta as EventTriggerInvocationLogsDataTableMeta;
const { mutateAsync: redeliverEvent, isLoading: isRedelivering } =
useRedeliverEventMutation();
const [open, setOpen] = useState(false);
const loadingToastIdRef = useRef<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const [isThisRedeliverActionLoading, setIsThisRedeliverActionLoading] =
useState(false);
const tableRows = table.getCoreRowModel().rows;
const isRedeliverDisabled = isRedelivering || meta.isRedeliverPending;
const resetState = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (loadingToastIdRef.current) {
toast.dismiss(loadingToastIdRef.current);
loadingToastIdRef.current = null;
}
setIsThisRedeliverActionLoading(false);
meta.setIsRedeliverPending(false);
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setTimeout(() => {
meta.setSelectedLog(null);
}, 100);
}
};
const handleRedeliver = async () => {
meta.setIsRedeliverPending(true);
setIsThisRedeliverActionLoading(true);
try {
await redeliverEvent({
args: {
event_id: row.event_id,
},
});
} catch (error) {
toast.error(
'Failed to redeliver event. Please try again.',
getToastStyleProps(),
);
resetState();
return;
}
loadingToastIdRef.current = toast.loading(
'Redelivering event',
getToastStyleProps(),
);
const start = Date.now();
const timeoutMs =
(meta.retryTimeoutSeconds ?? DEFAULT_RETRY_TIMEOUT_SECONDS) * 1000;
intervalRef.current = setInterval(async () => {
const elapsed = Date.now() - start;
if (elapsed >= timeoutMs && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
return;
}
try {
const { data, error } =
(await meta.refetchInvocations()) as UseQueryResult<GetEventAndInvocationLogsByIdResponse>;
if (error) {
throw new Error('Failed to fetch invocation logs');
}
const originalIds = tableRows.map((tableRow) => tableRow.original.id);
const newIds = data?.invocations?.map((invocation) => invocation.id);
const hasNew = newIds?.some((id) => !originalIds.includes(id));
if (hasNew) {
resetState();
}
} catch (error) {
toast.error(error?.message, getToastStyleProps());
resetState();
}
}, 1000);
};
return (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<ButtonWithLoading
variant="ghost"
size="sm"
onClick={handleRedeliver}
className="-ml-1 h-8 w-8 p-0"
disabled={isRedeliverDisabled}
loading={isThisRedeliverActionLoading}
loaderClassName="mr-0 size-5"
>
{!isThisRedeliverActionLoading && (
<CalendarSync className="size-4" />
)}
</ButtonWithLoading>
</TooltipTrigger>
<TooltipContent>
<p>Redeliver Event Invocation</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => meta.setSelectedLog(row)}
className="h-8 w-8 p-0"
>
<Eye className="h-4 w-4" />
</Button>
</DialogTrigger>
<InvocationLogDetailsDialogContent log={meta.selectedLog} />
</Dialog>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as EventTriggerInvocationLogsDataTable } from './EventTriggerInvocationLogsDataTable';

View File

@@ -0,0 +1,103 @@
import { HoverCardTimestamp } from '@/components/presentational/HoverCardTimestamp';
import { Button } from '@/components/ui/v3/button';
import { TextWithTooltip } from '@/features/orgs/projects/common/components/TextWithTooltip';
import { HttpStatusText } from '@/features/orgs/projects/events/common/components/HttpStatusText';
import { highlightMatch } from '@/features/orgs/utils/highlightMatch';
import { cn } from '@/lib/utils';
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas/eventInvocationLogEntry';
import { type Column, type ColumnDef } from '@tanstack/react-table';
import { ChevronDown, ChevronUp } from 'lucide-react';
import InvocationLogActionsCell from './InvocationLogActionsCell';
function CreatedAtHeader({
column,
}: {
column: Column<EventInvocationLogEntry, unknown>;
}) {
return (
<Button
variant="ghost"
size="sm"
onClick={() => column.toggleSorting(undefined)}
className="flex items-center justify-between gap-2"
>
<span>Created At</span>
<span className="flex flex-col">
<ChevronUp
className={cn(
'-mb-0.5 h-4 w-4',
column.getIsSorted() === 'asc'
? 'text-accent-foreground'
: 'text-muted-foreground',
)}
/>
<ChevronDown
className={cn(
'-mt-0.5 h-4 w-4',
column.getIsSorted() === 'desc'
? 'text-accent-foreground'
: 'text-muted-foreground',
)}
/>
</span>
</Button>
);
}
const columns: ColumnDef<EventInvocationLogEntry>[] = [
{
id: 'created_at',
accessorKey: 'created_at',
minSize: 50,
size: 68,
maxSize: 68,
header: ({ column }) => <CreatedAtHeader column={column} />,
cell: ({ row }) => (
<HoverCardTimestamp
date={new Date(row.original.created_at)}
className="-m-4 block w-full truncate py-4 pl-4 font-mono text-xs"
/>
),
},
{
id: 'http_status',
accessorKey: 'http_status',
minSize: 70,
size: 70,
maxSize: 70,
header: 'Status',
enableSorting: false,
cell: ({ row }) => <HttpStatusText status={row.original.http_status} />,
},
{
id: 'id',
accessorKey: 'id',
header: 'ID',
minSize: 40,
size: 280,
maxSize: 600,
cell: ({ row, table }) => (
<TextWithTooltip
className="font-mono text-xs"
containerClassName="cursor-text"
text={highlightMatch(
row.original.id,
String(table.getColumn('id')?.getFilterValue() ?? ''),
)}
/>
),
},
{
id: 'actions',
minSize: 80,
size: 80,
maxSize: 80,
header: 'Actions',
enableSorting: false,
cell: ({ row, table }) => (
<InvocationLogActionsCell row={row.original} table={table} />
),
},
];
export default columns;

View File

@@ -0,0 +1,11 @@
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas/eventInvocationLogEntry';
import type { Dispatch, SetStateAction } from 'react';
export interface EventTriggerInvocationLogsDataTableMeta {
selectedLog: EventInvocationLogEntry | null;
setSelectedLog: Dispatch<SetStateAction<EventInvocationLogEntry | null>>;
isRedeliverPending: boolean;
setIsRedeliverPending: Dispatch<SetStateAction<boolean>>;
refetchInvocations: () => Promise<unknown> | void;
retryTimeoutSeconds: number;
}

View File

@@ -0,0 +1,91 @@
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/v3/tabs';
import { EventsEmptyState } from '@/features/orgs/projects/events/common/components/EventsEmptyState';
import { EventTriggerEventsDataTable } from '@/features/orgs/projects/events/event-triggers/components/EventTriggerEventsDataTable';
import EventTriggerViewSkeleton from '@/features/orgs/projects/events/event-triggers/components/EventTriggerView/EventTriggerViewSkeleton';
import EventTriggerOverview from '@/features/orgs/projects/events/event-triggers/components/EventTriggerView/sections/EventTriggerOverview';
import { useGetEventTriggers } from '@/features/orgs/projects/events/event-triggers/hooks/useGetEventTriggers';
import { isEmptyValue } from '@/lib/utils';
import { useRouter } from 'next/router';
export default function EventTriggerView() {
const router = useRouter();
const { eventTriggerSlug } = router.query;
const { data: eventTriggers, isLoading, error } = useGetEventTriggers();
const eventTrigger = eventTriggers?.find(
(trigger) => trigger.name === eventTriggerSlug,
);
if (isLoading && eventTriggerSlug) {
return <EventTriggerViewSkeleton />;
}
if (error instanceof Error) {
return (
<EventsEmptyState
title="Event trigger not found"
description={
<span>
Event trigger{' '}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">
{eventTriggerSlug}
</code>{' '}
could not be loaded.
</span>
}
/>
);
}
if (isEmptyValue(eventTrigger)) {
return (
<EventsEmptyState
title="Event trigger not found"
description={
<span>
Event trigger{' '}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">
{eventTriggerSlug}
</code>{' '}
does not exist.
</span>
}
/>
);
}
return (
<div className="w-full px-10 py-8">
<div className="mx-auto w-full max-w-5xl rounded-lg bg-background p-4">
<div className="mb-6">
<h1 className="mb-1 text-xl font-semibold text-gray-900 dark:text-gray-100">
{eventTrigger!.name}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Event Trigger Configuration
</p>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="pending-processed-events">Events</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<EventTriggerOverview eventTrigger={eventTrigger!} />
</TabsContent>
<TabsContent value="pending-processed-events">
<EventTriggerEventsDataTable eventTrigger={eventTrigger!} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Skeleton } from '@/components/ui/v3/skeleton';
export default function EventTriggerViewSkeleton() {
return (
<div className="w-full px-10 py-8">
<div className="mx-auto w-full max-w-5xl rounded-lg bg-background p-4">
<div className="mb-6">
<Skeleton className="mb-1 h-7 w-52" />
<Skeleton className="h-4 w-40" />
</div>
<div className="mb-4 flex gap-2">
<Skeleton className="h-9 w-28 rounded-md" />
<Skeleton className="h-9 w-32 rounded-md" />
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<Skeleton className="mb-3 h-5 w-40" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<Skeleton className="mb-3 h-5 w-44" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<Skeleton className="mb-3 h-5 w-48" />
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-4 w-4" />
</div>
<div className="mt-4 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as EventTriggerView } from './EventTriggerView';

View File

@@ -0,0 +1,59 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/v3/table';
import type { Headers } from '@/utils/hasura-api/generated/schemas';
import { isHeaderWithEnvValue } from '@/utils/hasura-api/guards';
export interface EventTriggerHeadersTableProps {
headers: Headers;
}
export default function EventTriggerHeadersTable({
headers,
}: EventTriggerHeadersTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Name</TableHead>
<TableHead className="w-[150px]">Type</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{headers?.map((header) => {
if (isHeaderWithEnvValue(header)) {
return (
<TableRow key={header.name}>
<TableCell className="font-medium">{header.name}</TableCell>
<TableCell>From env var</TableCell>
<TableCell>
<span className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono">
{header.value_from_env}
</span>
</TableCell>
</TableRow>
);
}
return (
<TableRow key={header.name}>
<TableCell className="font-medium">{header.name}</TableCell>
<TableCell>Value</TableCell>
<TableCell>
<span className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono">
{header.value}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,245 @@
import CopyToClipboardButton from '@/components/presentational/CopyToClipboardButton/CopyToClipboardButton';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/v3/collapsible';
import EventTriggerHeadersTable from '@/features/orgs/projects/events/event-triggers/components/EventTriggerView/sections/EventTriggerHeadersTable';
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import TriggerOperationsSection from './TriggerOperationsSection';
export default function EventTriggerOverview({
eventTrigger,
}: {
eventTrigger: EventTriggerViewModel;
}) {
const [isTransformOpen, setIsTransformOpen] = useState(false);
const [isHeadersOpen, setIsHeadersOpen] = useState(false);
const queryParams = eventTrigger.request_transform?.query_params;
let queryParamsDisplay = '';
if (typeof queryParams === 'string') {
queryParamsDisplay = queryParams;
} else if (queryParams) {
queryParamsDisplay = Object.entries(queryParams)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join('&');
}
const bodyTransform = eventTrigger.request_transform?.body;
let bodyTransformDisplay = '';
if (typeof bodyTransform === 'string') {
bodyTransformDisplay = bodyTransform;
} else if (bodyTransform) {
bodyTransformDisplay = Object.entries(bodyTransform.form_template ?? {})
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<h3 className="mb-3 font-medium text-gray-900 dark:text-gray-100">
Database & Table
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Data Source:
</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{eventTrigger.dataSource}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Schema:</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{eventTrigger.table.schema}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Table:</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{eventTrigger.table.name}
</span>
</div>
</div>
</div>
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<h3 className="mb-3 font-medium text-gray-900 dark:text-gray-100">
{'webhook' in eventTrigger
? 'Webhook Handler'
: 'Webhook Handler (from environment)'}
</h3>
<div className="text-sm">
<div className="flex items-center justify-between gap-2 break-all rounded bg-muted p-2 font-mono">
<span>
{'webhook' in eventTrigger
? eventTrigger.webhook
: eventTrigger.webhook_from_env}
</span>
<CopyToClipboardButton
className="bg-[#e3f4fc]/70 dark:bg-[#1e2942]/70 dark:hover:bg-[#253252]"
textToCopy={
'webhook' in eventTrigger
? eventTrigger.webhook
: eventTrigger.webhook_from_env
}
title="Copy webhook URL"
/>
</div>
</div>
</div>
</div>
<TriggerOperationsSection eventTrigger={eventTrigger} />
{eventTrigger.retry_conf && (
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<h3 className="mb-3 font-medium text-gray-900 dark:text-gray-100">
Retry Configuration
</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{eventTrigger.retry_conf.num_retries}
</div>
<div className="text-gray-600 dark:text-gray-400">
Max Retries
</div>
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{eventTrigger.retry_conf.interval_sec}s
</div>
<div className="text-gray-600 dark:text-gray-400">
Retry Interval
</div>
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{eventTrigger.retry_conf.timeout_sec}s
</div>
<div className="text-gray-600 dark:text-gray-400">Timeout</div>
</div>
</div>
</div>
)}
{eventTrigger.headers && (
<Collapsible open={isHeadersOpen} onOpenChange={setIsHeadersOpen}>
<div className="rounded border border-gray-200 dark:border-gray-700">
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-gray-100 dark:hover:bg-gray-700">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
Request Headers
</h3>
{isHeadersOpen ? (
<ChevronDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-600 dark:text-gray-400" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
<div className="overflow-x-auto">
<EventTriggerHeadersTable headers={eventTrigger.headers} />
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
)}
{eventTrigger.request_transform && (
<Collapsible open={isTransformOpen} onOpenChange={setIsTransformOpen}>
<div className="rounded border border-gray-200 dark:border-gray-700">
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-gray-100 dark:hover:bg-gray-700">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
Request Transform Configuration
</h3>
{isTransformOpen ? (
<ChevronDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-600 dark:text-gray-400" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-4 border-t border-gray-200 p-4 pt-4 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
{eventTrigger.request_transform?.method && (
<div>
<span className="text-gray-600 dark:text-gray-400">
Method:{' '}
</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{eventTrigger.request_transform.method}
</span>
</div>
)}
<div>
<span className="text-gray-600 dark:text-gray-400">
Template Engine:{' '}
</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{eventTrigger.request_transform?.template_engine}
</span>
</div>
</div>
{eventTrigger.request_transform?.url && (
<div className="text-sm">
<div className="mb-1 font-medium text-gray-900 dark:text-gray-100">
URL Template:
</div>
<div className="rounded p-2 font-mono text-xs text-gray-900 dark:text-gray-100">
{eventTrigger.request_transform?.url}
</div>
</div>
)}
{queryParamsDisplay && (
<div className="text-sm">
<div className="mb-1 font-medium text-gray-900 dark:text-gray-100">
Query Parameters:
</div>
<div className="rounded p-2 font-mono text-xs text-gray-900 dark:text-gray-100">
{queryParamsDisplay}
</div>
</div>
)}
{bodyTransformDisplay && (
<div className="text-sm">
<div className="mb-1 font-medium text-gray-900 dark:text-gray-100">
Body Template:
</div>
<div className="whitespace-pre-wrap rounded bg-gray-100 p-2 font-mono text-xs text-gray-900 dark:bg-gray-700 dark:text-gray-100">
{bodyTransformDisplay}
</div>
</div>
)}
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
<span>
Action:{' '}
{typeof eventTrigger.request_transform.body === 'object'
? eventTrigger.request_transform.body?.action
: 'N/A'}
</span>
<span>Version: {eventTrigger.request_transform.version}</span>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
export default function TriggerOperationsSection({
eventTrigger,
}: {
eventTrigger: EventTriggerViewModel;
}) {
const operations: string[] = [];
if (eventTrigger.definition.insert) {
operations.push('Insert');
}
if (eventTrigger.definition.update) {
operations.push('Update');
}
if (eventTrigger.definition.delete) {
operations.push('Delete');
}
if (eventTrigger.definition.enable_manual) {
operations.push('Manual (Dashboard)');
}
const updateColumns = Array.isArray(eventTrigger.definition.update?.columns)
? eventTrigger.definition.update.columns.join(', ')
: eventTrigger.definition.update?.columns;
return (
<div className="rounded border border-gray-200 p-4 dark:border-gray-700">
<h3 className="mb-2 font-medium text-gray-900 dark:text-gray-100">
Trigger Operations
</h3>
<div className="mb-3 text-sm">
On <span className="font-mono">{eventTrigger.table.name}</span> table:
</div>
<div className="mb-3 flex flex-wrap gap-2">
{operations.map((operation) => (
<span
key={operation}
className="rounded bg-gray-200 px-2 py-1 text-xs text-gray-800 dark:bg-gray-600 dark:text-gray-200"
>
{operation}
</span>
))}
</div>
{updateColumns && (
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">
On Update Columns:{' '}
</span>
<span className="font-mono text-gray-900 dark:text-gray-100">
{updateColumns}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { CodeBlock } from '@/components/presentational/CodeBlock';
import {
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/v3/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/v3/table';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/v3/tabs';
import { HttpStatusText } from '@/features/orgs/projects/events/common/components/HttpStatusText';
import type { EventInvocationLogEntry } from '@/utils/hasura-api/generated/schemas';
import InvocationLogDetailsDialogSkeleton from './InvocationLogDetailsDialogSkeleton';
interface InvocationLogDetailsDialogContentProps {
log: EventInvocationLogEntry | null;
isLoading?: boolean;
}
export default function InvocationLogDetailsDialogContent({
log,
isLoading,
}: InvocationLogDetailsDialogContentProps) {
return (
<DialogContent className="flex h-[80vh] max-w-4xl flex-col overflow-y-auto text-foreground">
{isLoading ? (
<InvocationLogDetailsDialogSkeleton />
) : (
<>
<DialogHeader>
<DialogTitle className="text-foreground">
Invocation Log Details
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 rounded border p-4">
<div className="space-y-2">
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
ID:
</span>
<span className="font-mono text-sm text-foreground">
{log?.id}
</span>
</div>
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
Event ID:
</span>
<span className="font-mono text-sm text-foreground">
{log?.event_id}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
HTTP Status:
</span>
<HttpStatusText className="text-sm" status={log?.http_status} />
</div>
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
Created:
</span>
<span className="font-mono text-sm text-foreground">
{log?.created_at}
</span>
</div>
</div>
</div>
{log && (
<Tabs defaultValue="request" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="request">Request</TabsTrigger>
<TabsTrigger value="response">Response</TabsTrigger>
</TabsList>
<TabsContent value="request" className="space-y-4">
<div>
<h4 className="mb-2 font-medium text-foreground">Headers</h4>
<div className="rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{log.request.headers.map((header) => (
<TableRow key={header.name}>
<TableCell className="font-mono text-foreground">
{header.name}
</TableCell>
<TableCell className="font-mono text-muted-foreground">
{header.value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div>
<h4 className="mb-2 font-medium text-foreground">Payload</h4>
<CodeBlock
className="rounded py-2"
copyToClipboardToastTitle={`${log.trigger_name} payload`}
>
{JSON.stringify(log.request.payload, null, 2)}
</CodeBlock>
</div>
</TabsContent>
<TabsContent value="response" className="space-y-4">
<div className="flex items-center gap-4 text-sm">
<div>
<span className="font-medium">Status: </span>
<HttpStatusText
className="text-sm"
status={log.response?.data?.status}
/>
</div>
<div>
<span className="font-medium">Type: </span>
<span className="font-mono">{log.response?.type}</span>
</div>
</div>
{log.response?.data?.headers && (
<div>
<h4 className="mb-2 font-medium">Headers</h4>
<div className="rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{log.response?.data?.headers?.map((header) => (
<TableRow key={header.name}>
<TableCell className="font-mono">
{header.name}
</TableCell>
<TableCell className="font-mono text-muted-foreground">
{header.value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
<div>
<h4 className="mb-2 font-medium">Response Body</h4>
<CodeBlock
className="w-full max-w-full whitespace-pre-wrap break-all rounded py-2"
copyToClipboardToastTitle={`${log.trigger_name} response body`}
>
{log.response?.data?.body ?? log.response?.data?.message}
</CodeBlock>
</div>
</TabsContent>
</Tabs>
)}
</>
)}
</DialogContent>
);
}

View File

@@ -0,0 +1,127 @@
import { DialogHeader, DialogTitle } from '@/components/ui/v3/dialog';
import { Skeleton } from '@/components/ui/v3/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/v3/table';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/v3/tabs';
export default function InvocationLogDetailsDialogSkeleton() {
const skeletonRowKeys = ['row-1', 'row-2', 'row-3'];
const renderSkeletonTableRows = () =>
skeletonRowKeys.map((key) => (
<TableRow key={key}>
<TableCell className="font-mono text-foreground">
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell className="font-mono text-muted-foreground">
<Skeleton className="h-4 w-40" />
</TableCell>
</TableRow>
));
return (
<>
<DialogHeader>
<DialogTitle className="text-foreground">
Invocation Log Details
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 rounded border p-4">
<div className="space-y-2">
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
ID:
</span>
<Skeleton className="h-4 w-48" />
</div>
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
Event ID:
</span>
<Skeleton className="h-4 w-48" />
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
HTTP Status:
</span>
<Skeleton className="h-4 w-16" />
</div>
<div className="flex justify-between gap-1">
<span className="text-sm font-medium text-muted-foreground">
Created:
</span>
<Skeleton className="h-4 w-40" />
</div>
</div>
</div>
<Tabs defaultValue="request" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="request">Request</TabsTrigger>
<TabsTrigger value="response">Response</TabsTrigger>
</TabsList>
<TabsContent value="request" className="space-y-4">
<div>
<h4 className="mb-2 font-medium text-foreground">Headers</h4>
<div className="rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderSkeletonTableRows()}</TableBody>
</Table>
</div>
</div>
<div>
<h4 className="mb-2 font-medium text-foreground">Payload</h4>
<Skeleton className="h-32 w-full" />
</div>
</TabsContent>
<TabsContent value="response" className="space-y-4">
<div className="flex items-center gap-4 text-sm">
<div>
<span className="font-medium">Status: </span>
<Skeleton className="h-4 w-16" />
</div>
<div>
<span className="font-medium">Type: </span>
<Skeleton className="h-4 w-24" />
</div>
</div>
<div>
<h4 className="mb-2 font-medium">Headers</h4>
<div className="rounded border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderSkeletonTableRows()}</TableBody>
</Table>
</div>
</div>
<div>
<h4 className="mb-2 font-medium">Response Body</h4>
<Skeleton className="h-32 w-full" />
</div>
</TabsContent>
</Tabs>
</>
);
}

View File

@@ -0,0 +1,2 @@
export { default as InvocationLogDetailsDialogContent } from './InvocationLogDetailsDialogContent';
export { default as InvocationLogDetailsDialogContentSkeleton } from './InvocationLogDetailsDialogSkeleton';

View File

@@ -0,0 +1,5 @@
export const DEFAULT_NUM_RETRIES = 0;
export const DEFAULT_RETRY_INTERVAL_SECONDS = 10;
export const DEFAULT_RETRY_TIMEOUT_SECONDS = 60;

View File

@@ -0,0 +1 @@
export * from './constants';

View File

@@ -0,0 +1,49 @@
import { metadataOperation } from '@/utils/hasura-api/generated/default/default';
import type {
CreateEventTriggerArgs,
CreateEventTriggerBulkOperation,
} from '@/utils/hasura-api/generated/schemas';
import type { MetadataOperationOptions } from '@/utils/hasura-api/types';
export interface CreateEventTriggerVariables {
args: CreateEventTriggerArgs;
resourceVersion?: number;
}
export default async function createEventTrigger({
appUrl,
adminSecret,
args,
resourceVersion,
}: MetadataOperationOptions & CreateEventTriggerVariables) {
try {
const response = await metadataOperation(
{
type: 'bulk',
source: args.source ?? 'default',
resource_version: resourceVersion,
args: [
{
type: 'pg_create_event_trigger',
args: {
...args,
},
},
],
} satisfies CreateEventTriggerBulkOperation,
{
baseUrl: appUrl,
adminSecret,
},
);
if (response.status === 200) {
return response.data;
}
throw new Error(response.data.error);
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1,57 @@
import { executeMigration } from '@/utils/hasura-api/generated/default/default';
import type {
CreateEventTriggerArgs,
DeleteEventTriggerStepArgs,
} from '@/utils/hasura-api/generated/schemas';
import type { MigrationOperationOptions } from '@/utils/hasura-api/types';
export interface CreateEventTriggerMigrationVariables {
args: CreateEventTriggerArgs;
}
export default async function createEventTriggerMigration({
appUrl,
adminSecret,
args,
}: MigrationOperationOptions & CreateEventTriggerMigrationVariables) {
try {
const response = await executeMigration(
{
name: `create_event_trigger_${args.name}`,
up: [
{
type: 'pg_create_event_trigger',
args: {
...args,
replace: false,
} satisfies CreateEventTriggerArgs,
},
],
down: [
{
type: 'pg_delete_event_trigger',
args: {
name: args.name,
source: args.source,
} satisfies DeleteEventTriggerStepArgs,
},
],
datasource: args.source ?? 'default',
skip_execution: false,
},
{
baseUrl: appUrl,
adminSecret,
},
);
if (response.status === 200) {
return response.data;
}
throw new Error(response.data?.message ?? 'Unknown error');
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1 @@
export { default as useCreateEventTriggerMutation } from './useCreateEventTriggerMutation';

View File

@@ -0,0 +1,90 @@
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type {
CreateEventTriggerArgs,
SuccessResponse,
} from '@/utils/hasura-api/generated/schemas';
import type { MetadataOperation200 } from '@/utils/hasura-api/generated/schemas/metadataOperation200';
import type { MutationOptions } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import createEventTrigger from './createEventTrigger';
import createEventTriggerMigration from './createEventTriggerMigration';
export interface CreateEventTriggerMutationVariables {
/**
* Arguments to create an event trigger.
*/
args: CreateEventTriggerArgs;
/**
* The resource version for (platform mode only).
*/
resourceVersion?: number;
}
export interface UseCreateEventTriggerMutationOptions {
/**
* Props passed to the underlying mutation hook.
*/
mutationOptions?: MutationOptions<
SuccessResponse | MetadataOperation200,
unknown,
CreateEventTriggerMutationVariables
>;
}
/**
* This hook is a wrapper around a fetch call that creates an event trigger.
*
* @param options - Options to use for the mutation.
* @returns The result of the mutation.
*/
export default function useCreateEventTriggerMutation({
mutationOptions,
}: UseCreateEventTriggerMutationOptions = {}) {
const { project } = useProject();
const isPlatform = useIsPlatform();
const queryClient = useQueryClient();
const mutation = useMutation<
SuccessResponse | MetadataOperation200,
unknown,
CreateEventTriggerMutationVariables
>(
(variables) => {
const appUrl = generateAppServiceUrl(
project!.subdomain,
project!.region,
'hasura',
);
const base = {
appUrl,
adminSecret: project?.config?.hasura.adminSecret!,
} as const;
if (isPlatform) {
return createEventTrigger({
args: variables.args,
resourceVersion: variables.resourceVersion,
...base,
});
}
return createEventTriggerMigration({
args: variables.args,
...base,
});
},
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['export-metadata', project?.subdomain],
});
},
...mutationOptions,
},
);
return mutation;
}

View File

@@ -0,0 +1,49 @@
import { metadataOperation } from '@/utils/hasura-api/generated/default/default';
import type {
DeleteEventTriggerBulkOperation,
DeleteEventTriggerStepArgs,
} from '@/utils/hasura-api/generated/schemas';
import type { MetadataOperationOptions } from '@/utils/hasura-api/types';
export interface DeleteEventTriggerVariables {
args: DeleteEventTriggerStepArgs;
resourceVersion?: number;
}
export async function deleteEventTrigger({
appUrl,
adminSecret,
args,
resourceVersion,
}: MetadataOperationOptions & DeleteEventTriggerVariables) {
try {
const response = await metadataOperation(
{
type: 'bulk',
source: args.source ?? 'default',
resource_version: resourceVersion,
args: [
{
type: 'pg_delete_event_trigger',
args: {
...args,
},
},
],
} satisfies DeleteEventTriggerBulkOperation,
{
baseUrl: appUrl,
adminSecret,
},
);
if (response.status === 200) {
return response.data;
}
throw new Error(response.data.error);
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1,61 @@
import type { EventTriggerViewModel } from '@/features/orgs/projects/events/event-triggers/types';
import { executeMigration } from '@/utils/hasura-api/generated/default/default';
import type { MigrationOperationOptions } from '@/utils/hasura-api/types';
export interface DeleteEventTriggerMigrationVariables {
originalEventTrigger: EventTriggerViewModel;
}
export async function deleteEventTriggerMigration({
appUrl,
adminSecret,
originalEventTrigger,
}: MigrationOperationOptions & DeleteEventTriggerMigrationVariables) {
try {
const response = await executeMigration(
{
name: `delete_et_${originalEventTrigger.name}`,
up: [
{
type: 'pg_delete_event_trigger',
args: {
source: originalEventTrigger.dataSource,
name: originalEventTrigger.name,
},
},
],
down: [
{
type: 'pg_create_event_trigger',
args: {
...originalEventTrigger,
webhook: originalEventTrigger.webhook ?? null,
webhook_from_env: originalEventTrigger.webhook_from_env ?? null,
insert: originalEventTrigger.definition.insert ?? null,
update: originalEventTrigger.definition.update ?? null,
delete: originalEventTrigger.definition.delete ?? null,
headers: originalEventTrigger.headers ?? [],
replace: false,
enable_manual: originalEventTrigger.definition.enable_manual,
},
},
],
datasource: originalEventTrigger.dataSource,
skip_execution: false,
},
{
baseUrl: appUrl,
adminSecret,
},
);
if (response.status === 200) {
return response.data;
}
throw new Error(response.data?.message ?? 'Unknown error');
} catch (error) {
console.error(error);
throw error;
}
}

View File

@@ -0,0 +1 @@
export { default as useDeleteEventTriggerMutation } from './useDeleteEventTriggerMutation';

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