Compare commits
78 Commits
storage@0.
...
feat/cron-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60684f6b04 | ||
|
|
95b08ccc29 | ||
|
|
f888da271f | ||
|
|
f6a6ea12a3 | ||
|
|
d7dd8b9936 | ||
|
|
35c85ee0dc | ||
|
|
2545f90a40 | ||
|
|
582b9b27c0 | ||
|
|
4cb4a14f5b | ||
|
|
5861996fc6 | ||
|
|
009a5d38a7 | ||
|
|
621c8faaae | ||
|
|
4c4fc82074 | ||
|
|
f0e3e76818 | ||
|
|
2b9801d8d3 | ||
|
|
eed0c45c93 | ||
|
|
6e11788c0d | ||
|
|
d6889c762d | ||
|
|
1102f586c3 | ||
|
|
4e9de6a764 | ||
|
|
c8e12ad96c | ||
|
|
45888bbf23 | ||
|
|
22879ab328 | ||
|
|
481726ed1e | ||
|
|
0ab2dd08ab | ||
|
|
9e8ad786ce | ||
|
|
24bba56e02 | ||
|
|
1477d24319 | ||
|
|
edd8544447 | ||
|
|
25bf8dda2f | ||
|
|
04e5365bb2 | ||
|
|
985157c60e | ||
|
|
0c457b683a | ||
|
|
0a0adb048d | ||
|
|
abab84474b | ||
|
|
e218fb1f4c | ||
|
|
eadf14203b | ||
|
|
c3fcb91244 | ||
|
|
b1cf1dc827 | ||
|
|
a4d0e37955 | ||
|
|
bac90afe37 | ||
|
|
f110d262a6 | ||
|
|
f20ef4117c | ||
|
|
30dede9ea0 | ||
|
|
f1c8059e8e | ||
|
|
1248fdafde | ||
|
|
3f09199f1f | ||
|
|
d84795ed3f | ||
|
|
3dab7b9146 | ||
|
|
78cfb90098 | ||
|
|
509d4f716a | ||
|
|
4f90ef18c5 | ||
|
|
29c9abd6c3 | ||
|
|
64bcf8b23a | ||
|
|
86e5bc2bf6 | ||
|
|
ab787cfc8c | ||
|
|
9e429a7310 | ||
|
|
156602392e | ||
|
|
ce2c8768c1 | ||
|
|
9d44850fb5 | ||
|
|
934de7bd10 | ||
|
|
7d45845500 | ||
|
|
a2f4a83ac3 | ||
|
|
0ccc471787 | ||
|
|
0109ddee6c | ||
|
|
12ff139212 | ||
|
|
804e0373b0 | ||
|
|
2f8cb6fcc8 | ||
|
|
4871d71eba | ||
|
|
244604fd83 | ||
|
|
eac4633434 | ||
|
|
e27b83a0f4 | ||
|
|
f9a66720cd | ||
|
|
51f32536ff | ||
|
|
dc4a6a3caa | ||
|
|
46a9de4ace | ||
|
|
6a650a0b67 | ||
|
|
4f3c1d5f9f |
@@ -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
307
dashboard/pnpm-lock.yaml
generated
@@ -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': {}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DiscardChangesDialog } from './DiscardChangesDialog';
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HoverCardTimestamp } from './HoverCardTimestamp';
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
7
dashboard/src/components/ui/v3/collapsible.tsx
Normal file
7
dashboard/src/components/ui/v3/collapsible.tsx
Normal 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 };
|
||||
177
dashboard/src/components/ui/v3/input-group.tsx
Normal file
177
dashboard/src/components/ui/v3/input-group.tsx
Normal 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,
|
||||
};
|
||||
13
dashboard/src/components/ui/v3/skeleton.tsx
Normal file
13
dashboard/src/components/ui/v3/skeleton.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as InfoTooltip } from './InfoTooltip';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TextWithTooltip } from './TextWithTooltip';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetDataSources } from './useGetDataSources';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetMetadata } from './useGetMetadata';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as InvokeEventTriggerButton } from './InvokeEventTriggerButton';
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EventsBrowserSidebar } from './EventsBrowserSidebar';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EventsEmptyState } from './EventsEmptyState';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HttpStatusText } from './HttpStatusText';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BaseCronTriggerForm } from './BaseCronTriggerForm';
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function CreateCronTriggerForm() {
|
||||
return <div />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CreateCronTriggerForm } from './CreateCronTriggerForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CronTriggerView } from './CronTriggerView';
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteCronTriggerDialog } from './DeleteCronTriggerDialog';
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EditCronTriggerForm } from './EditCronTriggerForm';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useGetCronTriggers } from './useGetCronTriggers';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BaseEventTriggerForm';
|
||||
export { default as BaseEventTriggerForm } from './BaseEventTriggerForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RequestOptionsSection } from './RequestOptionsSection';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CreateEventTriggerForm } from './CreateEventTriggerForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CreatedAtHeader } from './CreatedAtHeader';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteEventTriggerDialog } from './DeleteEventTriggerDialog';
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EditEventTriggerForm } from './EditEventTriggerForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EventTriggerEventsDataTable } from './EventTriggerEventsDataTable';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EventTriggerInvocationLogsDataTable } from './EventTriggerInvocationLogsDataTable';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EventTriggerView } from './EventTriggerView';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as InvocationLogDetailsDialogContent } from './InvocationLogDetailsDialogContent';
|
||||
export { default as InvocationLogDetailsDialogContentSkeleton } from './InvocationLogDetailsDialogSkeleton';
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DEFAULT_NUM_RETRIES = 0;
|
||||
|
||||
export const DEFAULT_RETRY_INTERVAL_SECONDS = 10;
|
||||
|
||||
export const DEFAULT_RETRY_TIMEOUT_SECONDS = 60;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './constants';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useCreateEventTriggerMutation } from './useCreateEventTriggerMutation';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user