Browse Source

Nc feat/webhook UI (#9141)

* feat: webhook wip

* feat: webhook wip custom theme

* fix: handle scroll

* chore: clean up

* fix: ux fixes

* fix: font corrections

* fix: webhook docs links fix: pr review comments

* fix: box-shadow

* fix(nc-gui): webhook css fixes

* fix(nc-gui): reduce btn width from webhook modal

* fix(nc-gui): update webhook page json editor

* fix(nc-gui): add webhook docs link

* fix(nc-gui): webhook parameter, headers input gap

* fix(nc-gui): update webhook list table

* fix(nc-gui): remove beautify json btn

* fix(nc-gui): webhook header, parameters styles

* fix(nc-gui): warning issue

* fix(nc-gui): upate test webhook btn icons and enable save changes btn by default

* fix(nc-gui): update hook type text in table

* fix(nc-gui): focus webhook title on modal open

* fix(nc-gui): minor changes

* fix(nc-gui): update filter and params btn type

* fix(nc-gui): update webhook oss ui

* fix(nc-gui): add sortby webhook operation type option

* fix(nc-gui): update webhook notification type icons

* fix(nc-gui): invalid props issue

* fix(nc-gui): update webhook condition text

* fix(nc-gui): update monaco editor font color

* test: webhook class name fix

* fix(nc-gui): add missing webhook header key dropdown options

* fix(nc-gui): update webhook header key placeholder text color

* fix(nc-gui): update webhook modal min width

* test(nc-gui): update some of the webhook related test

* test(nc-gui): update create webhook test case

* text(nc-gui): fixed some of the webhook test cases

* test(nc-gui): update webhook conditional test cases

* docs: update

* fix(nc-gui): small changes

---------

Co-authored-by: DarkPhoenix2704 <anbarasun123@gmail.com>
Co-authored-by: Ramesh Mane <101566080+rameshmane7218@users.noreply.github.com>
pull/9166/head
Raju Udava 4 months ago committed by GitHub
parent
commit
7ba227736e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/nc-gui/assets/nc-icons/mail.svg
  2. 10
      packages/nc-gui/assets/nc-icons/mattermost.svg
  3. 28
      packages/nc-gui/assets/nc-icons/microsoft-teams.svg
  4. 13
      packages/nc-gui/assets/nc-icons/slack.svg
  5. 10
      packages/nc-gui/assets/nc-icons/twilio.svg
  6. 3
      packages/nc-gui/assets/nc-icons/whatsapp.svg
  7. 7
      packages/nc-gui/assets/style.scss
  8. 179
      packages/nc-gui/components/api-client/Headers.vue
  9. 120
      packages/nc-gui/components/api-client/Params.vue
  10. 43
      packages/nc-gui/components/monaco/Editor.vue
  11. 2
      packages/nc-gui/components/nc/Modal.vue
  12. 8
      packages/nc-gui/components/project/AccessSettings.vue
  13. 532
      packages/nc-gui/components/smartsheet/details/Webhooks.vue
  14. 2
      packages/nc-gui/components/smartsheet/sidebar/toolbar/Webhook.vue
  15. 41
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  16. 168
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  17. 216
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  18. 58
      packages/nc-gui/components/webhook/Drawer.vue
  19. 885
      packages/nc-gui/components/webhook/Editor.vue
  20. 169
      packages/nc-gui/components/webhook/List.vue
  21. 24
      packages/nc-gui/components/webhook/Modal.vue
  22. 62
      packages/nc-gui/components/webhook/Test.vue
  23. 1149
      packages/nc-gui/components/webhook/index.vue
  24. 8
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  25. 18
      packages/nc-gui/composables/useUserSorts.ts
  26. 11
      packages/nc-gui/lang/en.json
  27. 2
      packages/nc-gui/lib/types.ts
  28. 14
      packages/nc-gui/utils/iconUtils.ts
  29. 34
      packages/noco-docs/docs/130.automation/020.webhook/020.create-webhook.md
  30. BIN
      packages/noco-docs/static/img/v2/automations/webhooks/create-webhook-2.png
  31. BIN
      packages/noco-docs/static/img/v2/webhook/webhook-list-2.png
  32. BIN
      packages/noco-docs/static/img/v2/webhook/webhook-list-3.png
  33. 20
      tests/playwright/pages/Dashboard/Details/WebhookPage.ts
  34. 29
      tests/playwright/pages/Dashboard/WebhookForm/index.ts
  35. 28
      tests/playwright/tests/db/features/webhook.spec.ts

4
packages/nc-gui/assets/nc-icons/mail.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.66683 2.66699H13.3335C14.0668 2.66699 14.6668 3.26699 14.6668 4.00033V12.0003C14.6668 12.7337 14.0668 13.3337 13.3335 13.3337H2.66683C1.9335 13.3337 1.3335 12.7337 1.3335 12.0003V4.00033C1.3335 3.26699 1.9335 2.66699 2.66683 2.66699Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.6668 4L8.00016 8.66667L1.3335 4" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

10
packages/nc-gui/assets/nc-icons/mattermost.svg

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1608_52255)">
<path d="M15.6408 10.5643C14.2184 14.7482 9.65945 16.9918 5.45825 15.5756C1.25684 14.1592 -0.995991 9.61935 0.426249 5.43543C1.58382 2.03006 4.81897 -0.0895385 8.24826 0.00290606L7.14898 1.29613C5.1148 1.66249 3.35815 3.04594 2.66783 5.07691C1.64069 8.09864 3.36237 11.4094 6.51332 12.4717C9.66427 13.5338 13.0514 11.9455 14.0787 8.92381C14.7668 6.89947 14.2212 4.74571 12.8387 3.2248L12.7543 1.52683C15.5255 3.52987 16.7964 7.16475 15.6408 10.5643ZM10.0495 8.37075C9.28927 9.11231 8.35538 9.04459 7.74806 8.8398C7.14054 8.63482 6.35737 8.12396 6.20584 7.07491C6.05431 6.02587 6.74001 5.17357 6.74001 5.17357L8.2342 3.32327L9.10438 2.26639L9.85137 1.34536C9.85137 1.34536 10.1942 0.888163 10.2919 0.79391C10.3112 0.775019 10.3311 0.76276 10.3506 0.753315L10.3648 0.746281L10.3673 0.745276C10.4085 0.727591 10.4557 0.723773 10.5013 0.739247C10.5461 0.754319 10.5807 0.784866 10.6026 0.822447L10.6072 0.829883L10.6112 0.838323C10.6219 0.858219 10.6307 0.881129 10.6349 0.909466C10.6548 1.04351 10.6484 1.61425 10.6484 1.61425L10.68 2.79755L10.7266 4.16351L10.7849 6.53733C10.7849 6.53733 10.8098 7.62898 10.0495 8.37075Z" fill="#1875F0"/>
</g>
<defs>
<clipPath id="clip0_1608_52255">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

28
packages/nc-gui/assets/nc-icons/microsoft-teams.svg

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1608_52223)">
<path d="M11.16 6.24805H15.2929C15.6833 6.24805 15.9998 6.56457 15.9998 6.95502V10.7195C15.9998 12.1545 14.8365 13.3178 13.4015 13.3178H13.3892C11.9542 13.318 10.7907 12.1549 10.7905 10.7199C10.7905 10.7199 10.7905 10.7196 10.7905 10.7195V6.61754C10.7905 6.41347 10.956 6.24805 11.16 6.24805Z" fill="#5059C9"/>
<path d="M13.9537 5.50411C14.8785 5.50411 15.6281 4.75445 15.6281 3.82969C15.6281 2.90494 14.8785 2.15527 13.9537 2.15527C13.029 2.15527 12.2793 2.90494 12.2793 3.82969C12.2793 4.75445 13.029 5.50411 13.9537 5.50411Z" fill="#5059C9"/>
<path d="M8.74429 5.50421C10.0801 5.50421 11.1629 4.42136 11.1629 3.0856C11.1629 1.74984 10.0801 0.666992 8.74429 0.666992C7.40853 0.666992 6.32568 1.74984 6.32568 3.0856C6.32568 4.42136 7.40853 5.50421 8.74429 5.50421Z" fill="#7B83EB"/>
<path d="M11.969 6.24805H5.14707C4.76127 6.25759 4.45607 6.57777 4.46503 6.96359V11.2572C4.41115 13.5724 6.24287 15.4937 8.55805 15.5504C10.8732 15.4937 12.7049 13.5724 12.6511 11.2572V6.96359C12.66 6.57777 12.3548 6.25759 11.969 6.24805Z" fill="#7B83EB"/>
<path opacity="0.1" d="M8.93016 6.24805V12.2648C8.92831 12.5407 8.76111 12.7886 8.50597 12.8936C8.42474 12.928 8.33743 12.9457 8.24923 12.9457H4.79248C4.74411 12.8229 4.69946 12.7001 4.66225 12.5736C4.532 12.1467 4.46554 11.7028 4.46504 11.2564V6.96247C4.45609 6.57727 4.76078 6.25759 5.14597 6.24805H8.93016Z" fill="black"/>
<path opacity="0.2" d="M8.55807 6.24805V12.6369C8.55807 12.7251 8.54034 12.8124 8.50597 12.8936C8.40092 13.1488 8.15305 13.316 7.87714 13.3178H4.96737C4.90411 13.195 4.84458 13.0722 4.79248 12.9457C4.74039 12.8192 4.69946 12.7001 4.66225 12.5736C4.532 12.1467 4.46554 11.7028 4.46504 11.2564V6.96247C4.45609 6.57727 4.76078 6.25759 5.14597 6.24805H8.55807Z" fill="black"/>
<path opacity="0.2" d="M8.55806 6.24805V11.8927C8.55523 12.2676 8.25202 12.5708 7.87713 12.5736H4.66225C4.532 12.1467 4.46554 11.7028 4.46504 11.2564V6.96247C4.45609 6.57727 4.76078 6.25759 5.14597 6.24805H8.55806Z" fill="black"/>
<path opacity="0.2" d="M8.18596 6.24805V11.8927C8.18313 12.2676 7.87992 12.5708 7.50503 12.5736H4.66225C4.532 12.1467 4.46554 11.7028 4.46504 11.2564V6.96247C4.45609 6.57727 4.76078 6.25759 5.14597 6.24805H8.18596Z" fill="black"/>
<path opacity="0.1" d="M8.93026 4.32448V5.49657C8.867 5.50029 8.80747 5.50402 8.74421 5.50402C8.68095 5.50402 8.62142 5.5003 8.55816 5.49657C8.43256 5.48824 8.308 5.46831 8.18607 5.43704C7.43258 5.2586 6.81007 4.73016 6.51165 4.01565C6.4603 3.89565 6.42044 3.77106 6.39258 3.64355H8.24932C8.6248 3.64498 8.92883 3.949 8.93026 4.32448Z" fill="black"/>
<path opacity="0.2" d="M8.55823 4.69655V5.49655C8.43263 5.48822 8.30807 5.46829 8.18614 5.43702C7.43265 5.25858 6.81014 4.73014 6.51172 4.01562H7.8773C8.25277 4.01705 8.5568 4.32108 8.55823 4.69655Z" fill="black"/>
<path opacity="0.2" d="M8.55823 4.69655V5.49655C8.43263 5.48822 8.30807 5.46829 8.18614 5.43702C7.43265 5.25858 6.81014 4.73014 6.51172 4.01562H7.8773C8.25277 4.01705 8.5568 4.32108 8.55823 4.69655Z" fill="black"/>
<path opacity="0.2" d="M8.18614 4.69656V5.43702C7.43265 5.25858 6.81014 4.73014 6.51172 4.01562H7.50521C7.88069 4.01705 8.18471 4.32108 8.18614 4.69656Z" fill="black"/>
<path d="M0.682043 4.01562H7.50399C7.88068 4.01562 8.18604 4.32099 8.18604 4.69767V11.5196C8.18604 11.8963 7.88067 12.2017 7.50399 12.2017H0.682043C0.305358 12.2017 0 11.8963 0 11.5196V4.69767C0 4.32099 0.305365 4.01562 0.682043 4.01562Z" fill="url(#paint0_linear_1608_52223)"/>
<path d="M5.88829 6.61197H4.52457V10.3255H3.65574V6.61197H2.29834V5.8916H5.88829V6.61197Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_1608_52223" x1="142.208" y1="-49.2781" x2="676.397" y2="875.914" gradientUnits="userSpaceOnUse">
<stop stop-color="#5A62C3"/>
<stop offset="0.5" stop-color="#4D55BD"/>
<stop offset="1" stop-color="#3940AB"/>
</linearGradient>
<clipPath id="clip0_1608_52223">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

13
packages/nc-gui/assets/nc-icons/slack.svg

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1608_52215)">
<path d="M3.36508 10.0824C3.36508 11.0094 2.61587 11.7586 1.68889 11.7586C0.761902 11.7586 0.0126953 11.0094 0.0126953 10.0824C0.0126953 9.15546 0.761902 8.40625 1.68889 8.40625H3.36508V10.0824ZM4.20317 10.0824C4.20317 9.15546 4.95238 8.40625 5.87936 8.40625C6.80635 8.40625 7.55555 9.15546 7.55555 10.0824V14.2729C7.55555 15.1999 6.80635 15.9491 5.87936 15.9491C4.95238 15.9491 4.20317 15.1999 4.20317 14.2729V10.0824Z" fill="#E01E5A"/>
<path d="M5.87936 3.35238C4.95238 3.35238 4.20317 2.60317 4.20317 1.67619C4.20317 0.749206 4.95238 0 5.87936 0C6.80635 0 7.55556 0.749206 7.55556 1.67619V3.35238H5.87936ZM5.87936 4.20317C6.80635 4.20317 7.55556 4.95238 7.55556 5.87936C7.55556 6.80635 6.80635 7.55556 5.87936 7.55556H1.67619C0.749206 7.55556 0 6.80635 0 5.87936C0 4.95238 0.749206 4.20317 1.67619 4.20317H5.87936Z" fill="#36C5F0"/>
<path d="M12.5967 5.87936C12.5967 4.95238 13.3459 4.20317 14.2729 4.20317C15.1999 4.20317 15.9491 4.95238 15.9491 5.87936C15.9491 6.80635 15.1999 7.55556 14.2729 7.55556H12.5967V5.87936ZM11.7586 5.87936C11.7586 6.80635 11.0094 7.55556 10.0824 7.55556C9.15546 7.55556 8.40625 6.80635 8.40625 5.87936V1.67619C8.40625 0.749206 9.15546 0 10.0824 0C11.0094 0 11.7586 0.749206 11.7586 1.67619V5.87936Z" fill="#2EB67D"/>
<path d="M10.0824 12.5967C11.0094 12.5967 11.7586 13.3459 11.7586 14.2729C11.7586 15.1999 11.0094 15.9491 10.0824 15.9491C9.15546 15.9491 8.40625 15.1999 8.40625 14.2729V12.5967H10.0824ZM10.0824 11.7586C9.15546 11.7586 8.40625 11.0094 8.40625 10.0824C8.40625 9.15546 9.15546 8.40625 10.0824 8.40625H14.2856C15.2126 8.40625 15.9618 9.15546 15.9618 10.0824C15.9618 11.0094 15.2126 11.7586 14.2856 11.7586H10.0824Z" fill="#ECB22E"/>
</g>
<defs>
<clipPath id="clip0_1608_52215">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

10
packages/nc-gui/assets/nc-icons/twilio.svg

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1608_52257)">
<path d="M8 0C12.416 0 16 3.584 16 8C16 12.416 12.416 16 8 16C3.584 16 0 12.416 0 8C0 3.584 3.584 0 8 0ZM8 2.112C4.736 2.112 2.112 4.736 2.112 8C2.112 11.264 4.736 13.888 8 13.888C11.264 13.888 13.888 11.264 13.888 8C13.888 4.736 11.264 2.112 8 2.112ZM9.984 8.32C10.903 8.32 11.648 9.065 11.648 9.984C11.648 10.903 10.903 11.648 9.984 11.648C9.065 11.648 8.32 10.903 8.32 9.984C8.32 9.065 9.065 8.32 9.984 8.32ZM6.016 8.32C6.935 8.32 7.68 9.065 7.68 9.984C7.68 10.903 6.935 11.648 6.016 11.648C5.097 11.648 4.352 10.903 4.352 9.984C4.352 9.065 5.097 8.32 6.016 8.32ZM9.984 4.352C10.903 4.352 11.648 5.097 11.648 6.016C11.648 6.935 10.903 7.68 9.984 7.68C9.065 7.68 8.32 6.935 8.32 6.016C8.32 5.097 9.065 4.352 9.984 4.352ZM6.016 4.352C6.935 4.352 7.68 5.097 7.68 6.016C7.68 6.935 6.935 7.68 6.016 7.68C5.097 7.68 4.352 6.935 4.352 6.016C4.352 5.097 5.097 4.352 6.016 4.352Z" fill="#F12E45"/>
</g>
<defs>
<clipPath id="clip0_1608_52257">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
packages/nc-gui/assets/nc-icons/whatsapp.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M1 15L1.98436 11.4035C1.1485 9.95098 0.866189 8.24494 1.18958 6.60043C1.51298 4.95592 2.42022 3.48406 3.74374 2.45671C5.06727 1.42936 6.71765 0.915921 8.39005 1.01124C10.0625 1.10656 11.6439 1.80419 12.8422 2.97528C14.0406 4.14636 14.7749 5.71179 14.9096 7.3824C15.0442 9.05301 14.5701 10.7159 13.5748 12.064C12.5795 13.4121 11.1303 14.3543 9.49475 14.7166C7.85923 15.0788 6.14795 14.8366 4.67698 14.0348M2.66613 13.353L4.84934 12.7785C6.05083 13.5591 7.49981 13.8645 8.91416 13.6354C10.3285 13.4064 11.607 12.6591 12.5006 11.5392C13.3943 10.4192 13.8391 9.00679 13.7486 7.57688C13.6581 6.14696 13.0386 4.80192 12.0108 3.80365C10.983 2.80538 9.62053 2.22535 8.18858 2.17648C6.75663 2.12762 5.35775 2.61342 4.26432 3.53931C3.17088 4.46519 2.46117 5.76487 2.27336 7.18529C2.08555 8.60571 2.4331 10.0452 3.24832 11.2234M6.8104 6.35909C6.887 6.5506 6.8104 6.74211 6.38908 7.20939C6.15927 7.4392 6.23587 7.51581 6.64187 8.09034C7.04787 8.66487 7.71432 9.20109 8.32715 9.48836C8.93998 9.77562 8.90168 9.75647 9.15831 9.45005C9.73283 8.79892 9.57963 8.64571 10.1542 8.90617L11.1883 9.40409C11.4947 9.5573 11.51 9.5573 11.5139 9.74881C11.5177 9.94032 11.4488 10.4382 11.2458 10.6527C11.0428 10.8672 10.2882 11.6026 8.94765 11.1124C7.60708 10.6221 6.68783 10.2314 5.15576 8.16311C3.62368 6.09481 5.09447 4.86915 5.29364 4.79254C5.49281 4.71594 5.56176 4.73509 5.94478 4.74275C6.04692 4.74275 6.13629 4.80659 6.21289 4.93426" fill="#25D366"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
packages/nc-gui/assets/style.scss

@ -93,6 +93,13 @@ main {
}
}
// add border on input if it has value and it is not focused
.ant-input-affix-wrapper.nc-input-border-on-value {
&:not(:has(.ant-input-clear-icon-hidden)):has(.ant-input-clear-icon) {
@apply border-[var(--ant-primary-5)];
}
}
.ant-form-item-explain {
@apply !min-h-5;
.ant-form-item-explain-error {

179
packages/nc-gui/components/api-client/Headers.vue

@ -3,13 +3,14 @@ const props = defineProps<{
modelValue: any[]
}>()
const emits = defineEmits(['update:modelValue'])
interface Option {
value: string
}
const emits = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emits)
const headerList = ref<Option[]>([
{ value: 'A-IM' },
{ value: 'Accept' },
@ -53,89 +54,109 @@ const headerList = ref<Option[]>([
])
const addHeaderRow = () => vModel.value.push({})
const deleteHeaderRow = (i: number) => vModel.value.splice(i, 1)
const filterOption = (input: string, option: Option) => option.value.toUpperCase().includes(input.toUpperCase())
</script>
<template>
<div class="flex flex-row justify-between w-full">
<table class="w-full nc-webhooks-params">
<thead class="h-8">
<tr>
<th class="w-8"></th>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('labels.headerName') }}</div>
</th>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('placeholder.value') }}</div>
</th>
<th class="w-8"></th>
</tr>
</thead>
<tbody>
<tr v-for="(headerRow, idx) in vModel" :key="idx" class="!h-2 overflow-hidden">
<td class="px-2 nc-hook-header-tab-checkbox">
<a-form-item class="form-item">
<a-checkbox v-model:checked="headerRow.enabled" />
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-auto-complete
v-model:value="headerRow.name"
class="nc-input-hook-header-key"
:options="headerList"
:placeholder="$t('placeholder.key')"
:filter-option="filterOption"
dropdown-class-name="border-1 border-gray-200"
/>
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-input
v-model:value="headerRow.value"
:placeholder="$t('placeholder.value')"
class="!rounded-md nc-input-hook-header-value"
/>
</a-form-item>
</td>
<td class="relative">
<div
v-if="idx !== 0"
class="absolute left-0 top-0.25 py-1 px-1.5 rounded-md border-1 border-gray-100"
:class="{
'text-gray-400 cursor-not-allowed bg-gray-50': vModel.length === 1,
'text-gray-600 cursor-pointer hover:bg-gray-50 hover:text-black': vModel.length !== 1,
}"
@click="deleteHeaderRow(idx)"
>
<component :is="iconMap.delete" />
</div>
</td>
</tr>
<tr>
<td :colspan="12" class="">
<NcButton size="small" type="secondary" @click="addHeaderRow">
<div class="flex flex-row items-center gap-x-1">
<div data-rec="true">{{ $t('labels.addHeader') }}</div>
<component :is="iconMap.plus" class="flex mx-auto" />
</div>
</NcButton>
</td>
</tr>
</tbody>
</table>
<div class="flex flex-col py-3 gap-1.5 w-full">
<div v-for="(headerRow, idx) in vModel" :key="idx" class="flex relative items-center w-full">
<a-form-item class="form-item w-8">
<NcCheckbox v-model:checked="headerRow.enabled" size="large" class="nc-hook-header-checkbox" />
</a-form-item>
<a-form-item class="form-item w-3/6">
<a-auto-complete
v-model:value="headerRow.name"
class="!rounded-l-lg !rounded-r-0 nc-input-hook-header-key hover:!border-x-0 !border-gray-200"
:options="headerList"
:placeholder="$t('placeholder.key')"
:filter-option="filterOption"
dropdown-class-name="border-1 border-gray-200"
/>
</a-form-item>
<a-form-item class="form-item w-3/6">
<a-input
v-model:value="headerRow.value"
:placeholder="$t('placeholder.value')"
class="nc-webhook-header-value-input !border-x-0 hover:!border-x-0 !border-gray-200 !rounded-none"
/>
</a-form-item>
<NcButton
class="!rounded-l-none delete-btn !border-gray-200 !shadow-none"
type="secondary"
size="small"
:disabled="vModel.length === 1"
@click="deleteHeaderRow(idx)"
>
<component :is="iconMap.deleteListItem" />
</NcButton>
</div>
<div class="mt-1.5">
<NcButton size="small" type="secondary" class="nc-btn-focus" @click="addHeaderRow">
<div class="flex flex-row items-center gap-x-2">
<component :is="iconMap.plus" class="flex-none" />
<div data-rec="true">{{ $t('general.add') }}</div>
</div>
</NcButton>
</div>
</div>
</template>
<style lang="scss" scoped>
.form-item {
@apply !mb-3;
<style scoped lang="scss">
.ant-input {
box-shadow: none !important;
&:hover {
@apply !hover:bg-gray-50;
}
}
.delete-btn:not([disabled]) {
@apply !text-gray-500;
}
:deep(.ant-input) {
@apply !placeholder-gray-500;
}
:deep(.ant-input.nc-webhook-header-value-input) {
@apply !border-x-0;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.nc-btn-focus:focus {
@apply !text-brand-500 !shadow-none;
}
:deep(.nc-input-hook-header-key.ant-select.ant-select-auto-complete) {
@apply !text-sm;
&.ant-select-focused {
.ant-select-selector {
@apply !shadow-none !border-gray-200;
}
}
:deep(.ant-select-selector) {
@apply !rounded-l-lg !rounded-r-none !border-gray-200;
.ant-select-selection-search .ant-select-selection-search-input::placeholder {
@apply !text-gray-500 !text-sm;
}
}
.ant-select-selector {
@apply !rounded-l-lg !rounded-r-none !border-gray-200;
.ant-select-selection-search-input {
@apply !text-sm ;
}
.ant-select-selection-placeholder{
@apply !text-gray-500;
}
}
}
</style>
</style>

120
packages/nc-gui/components/api-client/Params.vue

@ -17,72 +17,70 @@ const deleteParamRow = (i: number) => {
</script>
<template>
<div class="flex flex-row justify-between w-full">
<table class="w-full nc-webhooks-params">
<thead class="h-8">
<tr>
<th class="w-8"></th>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('title.parameterName') }}</div>
</th>
<div class="flex flex-col py-3 gap-1.5 w-full">
<div v-for="(paramRow, idx) in vModel" :key="idx" class="flex relative items-center w-full">
<a-form-item class="form-item w-8">
<NcCheckbox v-model:checked="paramRow.enabled" size="large" />
</a-form-item>
<a-form-item class="form-item w-3/6">
<a-input v-model:value="paramRow.name" :placeholder="$t('placeholder.key')" class="!rounded-l-lg !border-gray-200" />
</a-form-item>
<a-form-item class="form-item w-3/6">
<a-input
v-model:value="paramRow.value"
:placeholder="$t('placeholder.value')"
class="nc-webhook-parameters-value-input !border-x-0 !border-gray-200 !rounded-none"
/>
</a-form-item>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('placeholder.value') }}</div>
</th>
<th class="w-8"></th>
</tr>
</thead>
<NcButton
class="!rounded-l-none delete-btn !border-gray-200 !shadow-none"
type="secondary"
size="small"
:disabled="vModel.length === 1"
@click="deleteParamRow(idx)"
>
<component :is="iconMap.deleteListItem" />
</NcButton>
</div>
<tbody>
<tr v-for="(paramRow, idx) in vModel" :key="idx" class="!h-2 overflow-hidden">
<td class="px-2">
<a-form-item class="form-item">
<a-checkbox v-model:checked="paramRow.enabled" />
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-input v-model:value="paramRow.name" :placeholder="$t('placeholder.key')" class="!rounded-lg" />
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-input v-model:value="paramRow.value" :placeholder="$t('placeholder.value')" class="!rounded-lg" />
</a-form-item>
</td>
<td class="relative">
<div
class="absolute left-0 top-0.25 py-1 px-1.5 rounded-md border-1 border-gray-100"
:class="{
'text-gray-400 cursor-not-allowed bg-gray-50': vModel.length === 1,
'text-gray-600 cursor-pointer hover:bg-gray-50 hover:text-black': vModel.length !== 1,
}"
@click="deleteParamRow(idx)"
>
<component :is="iconMap.delete" />
</div>
</td>
</tr>
<tr>
<td :colspan="12" class="">
<NcButton size="small" type="secondary" @click="addParamRow">
<div class="flex flex-row items-center gap-x-1">
<div data-rec="true">{{ $t('activity.addParameter') }}</div>
<component :is="iconMap.plus" class="flex mx-auto" />
</div>
</NcButton>
</td>
</tr>
</tbody>
</table>
<div class="mt-1.5">
<NcButton size="small" type="secondary" class="nc-btn-focus" @click="addParamRow">
<div class="flex flex-row items-center gap-x-2">
<component :is="iconMap.plus" class="flex-none" />
<div data-rec="true">{{ $t('general.add') }}</div>
</div>
</NcButton>
</div>
</div>
</template>
<style lang="scss" scoped>
.form-item {
@apply !mb-3;
.ant-input {
box-shadow: none !important;
&:hover {
@apply !hover:bg-gray-50;
}
}
.delete-btn:not([disabled]) {
@apply !text-gray-500;
}
:deep(.ant-input) {
@apply !placeholder-gray-500;
}
:deep(.ant-input.nc-webhook-parameters-value-input) {
@apply !border-x-0;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.nc-btn-focus:focus {
@apply !text-brand-500 !shadow-none;
}
</style>

43
packages/nc-gui/components/monaco/Editor.vue

@ -12,6 +12,8 @@ interface Props {
disableDeepCompare?: boolean
readOnly?: boolean
autoFocus?: boolean
monacoConfig?: Partial<MonacoEditor.IStandaloneEditorConstructionOptions>
monacoCustomTheme?: Partial<MonacoEditor.IStandaloneThemeData>
}
const props = withDefaults(defineProps<Props>(), {
@ -20,13 +22,15 @@ const props = withDefaults(defineProps<Props>(), {
validate: true,
disableDeepCompare: false,
autoFocus: true,
monacoConfig: () => ({} as Partial<MonacoEditor.IStandaloneEditorConstructionOptions>),
monacoCustomTheme: () => ({} as Partial<MonacoEditor.IStandaloneThemeData>),
})
const emits = defineEmits(['update:modelValue'])
const { modelValue } = toRefs(props)
const { hideMinimap, lang, validate, disableDeepCompare, readOnly, autoFocus } = props
const { hideMinimap, lang, validate, disableDeepCompare, readOnly, autoFocus, monacoConfig, monacoCustomTheme } = props
const vModel = computed<string>({
get: () => {
@ -55,8 +59,13 @@ const root = ref<HTMLDivElement>()
let editor: MonacoEditor.IStandaloneCodeEditor
const format = () => {
editor.setValue(JSON.stringify(JSON.parse(editor?.getValue() as string), null, 2))
const format = (space = monacoConfig.tabSize || 2) => {
try {
const parsedValue = JSON.parse(editor?.getValue() as string)
editor.setValue(JSON.stringify(parsedValue, null, space))
} catch (error: unknown) {
console.error('Failed to parse and format JSON:', error)
}
}
defineExpose({
@ -77,10 +86,17 @@ onMounted(async () => {
})
}
let isCustomTheme = false
if (Object.keys(monacoCustomTheme).length) {
monacoEditor.defineTheme('custom', monacoCustomTheme)
isCustomTheme = true
}
editor = monacoEditor.create(root.value, {
model,
contextmenu: false,
theme: 'vs',
theme: isCustomTheme ? 'custom' : 'vs',
foldingStrategy: 'indentation',
selectOnLineNumbers: true,
language: props.lang,
@ -89,7 +105,7 @@ onMounted(async () => {
horizontalScrollbarSize: 1,
},
lineNumbers: 'off',
tabSize: 2,
tabSize: monacoConfig.tabSize || 2,
automaticLayout: true,
readOnly,
bracketPairColorization: {
@ -99,6 +115,8 @@ onMounted(async () => {
minimap: {
enabled: !hideMinimap,
},
...(lang === 'json' ? { detectIndentation: false, insertSpaces: true } : {}),
...monacoConfig,
})
editor.onDidChangeModelContent(async () => {
@ -122,6 +140,10 @@ onMounted(async () => {
// auto focus on json cells only
editor.focus()
}
if (lang === 'json') {
format()
}
}
})
@ -152,4 +174,13 @@ watch(
<div ref="root"></div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
:deep(.monaco-editor) {
background-color: transparent !important;
border-radius: 8px !important;
}
:deep(.overflow-guard) {
border-radius: 8px !important;
}
</style>

2
packages/nc-gui/components/nc/Modal.vue

@ -107,7 +107,7 @@ const slots = useSlots()
:class="{
'border-b-1 border-gray-200': showSeparator,
}"
class="flex pb-2 mb-2 text-lg font-medium"
class="flex pb-2 mb-2 nc-modal-header text-lg font-medium"
>
<slot name="header" />
</div>

8
packages/nc-gui/components/project/AccessSettings.vue

@ -286,7 +286,7 @@ const customRow = (record: Record<string, any>) => ({
:placeholder="$t('title.searchMembers')"
:disabled="isLoading"
allow-clear
class="nc-project-collaborator-list-search-input !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
class="nc-input-border-on-value !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
@ -388,12 +388,6 @@ const customRow = (record: Record<string, any>) => ({
@apply text-gray-500;
}
:deep(.ant-input-affix-wrapper.nc-project-collaborator-list-search-input) {
&:not(:has(.ant-input-clear-icon-hidden)):has(.ant-input-clear-icon) {
@apply border-[var(--ant-primary-5)];
}
}
.color-band {
@apply w-6 h-6 left-0 top-2.5 rounded-full flex justify-center uppercase text-white font-weight-bold text-xs items-center;
}

532
packages/nc-gui/components/smartsheet/details/Webhooks.vue

@ -1,20 +1,25 @@
<script lang="ts" setup>
import type { HookType } from 'nocodb-sdk'
import { LoadingOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
// const showWebhookDrawer = ref(false)
const { activeTable } = storeToRefs(useTablesStore())
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateSort } = useUserSorts('Webhook')
const router = useRouter()
const selectedHook = ref<undefined | HookType>()
const route = router.currentRoute
const { hooks, isHooksLoading } = storeToRefs(useWebhooksStore())
const { loadHooksList, deleteHook: _deleteHook, copyHook, saveHooks } = useWebhooksStore()
const { hooks, webhookMainUrl, isHooksLoading } = storeToRefs(useWebhooksStore())
const { loadHooksList, deleteHook: _deleteHook, copyHook, saveHooks, navigateToWebhook } = useWebhooksStore()
const { activeView } = storeToRefs(useViewsStore())
const modalDeleteButtonRef = ref(null)
const { t } = useI18n()
const { activeTable } = storeToRefs(useTablesStore())
const isWebhookModalOpen = ref(false)
const modalDeleteButtonRef = ref(null)
const indicator = h(LoadingOutlined, {
style: {
@ -23,34 +28,32 @@ const indicator = h(LoadingOutlined, {
spin: true,
})
/*
const eventList = ref<Record<string, any>[]>([
{ text: ['After', 'Insert'], value: ['after', 'insert'] },
{ text: ['After', 'Update'], value: ['after', 'update'] },
{ text: ['After', 'Delete'], value: ['after', 'delete'] },
{ text: ['After', 'Bulk Insert'], value: ['after', 'bulkInsert'] },
{ text: ['After', 'Bulk Update'], value: ['after', 'bulkUpdate'] },
{ text: ['After', 'Bulk Delete'], value: ['after', 'bulkDelete'] },
])
*/
const deleteHookId = ref('')
const showDeleteModal = ref(false)
const isDeleting = ref(false)
const toBeDeleteHook = computed(() => {
return hooks.value.find((hook) => hook.id === deleteHookId.value)
})
const selectedHookId = ref<string | undefined>(undefined)
const selectedHook = computed(() => {
if (!selectedHookId.value) return undefined
const deleteHook = async () => {
isDeleting.value = true
if (!deleteHookId.value) return
return hooks.value.find((hook) => hook.id === selectedHookId.value)
})
try {
await _deleteHook(deleteHookId.value)
} finally {
isDeleting.value = false
showDeleteModal.value = false
deleteHookId.value = ''
}
}
const showDeleteModal = ref(false)
const isDeleting = ref(false)
const isCopying = ref(false)
const selectedHookId = ref<string | undefined>(undefined)
const isDraftMode = ref(false)
const isCopying = ref(false)
const copyWebhook = async (hook: HookType) => {
if (isCopying.value) return
@ -68,22 +71,15 @@ const openDeleteModal = (hookId: string) => {
showDeleteModal.value = true
}
const deleteHook = async () => {
isDeleting.value = true
if (!deleteHookId.value) return
const webHookSearch = ref('')
try {
await _deleteHook(deleteHookId.value)
} finally {
isDeleting.value = false
showDeleteModal.value = false
deleteHookId.value = ''
}
}
const filteredHooks = computed(() =>
hooks.value.filter((hook) => hook.title?.toLowerCase().includes(webHookSearch.value.toLowerCase())),
)
const openEditor = (hookId?: string | undefined) => {
navigateToWebhook({ hookId })
}
const sortedHooks = computed(() => {
return handleGetSortedData(filteredHooks.value, sorts.value)
})
watch(showDeleteModal, () => {
if (!showDeleteModal.value) return
@ -93,6 +89,12 @@ watch(showDeleteModal, () => {
})
})
watch(isWebhookModalOpen, (val) => {
if (!val) {
selectedHook.value = undefined
}
})
watch(
() => activeTable.value?.id,
async () => {
@ -112,252 +114,264 @@ const toggleHook = async (hook: HookType) => {
}
const createWebhook = async () => {
navigateToWebhook({ openCreatePage: true })
isWebhookModalOpen.value = true
}
const onEditorClose = () => {
navigateToWebhook({ openMainPage: true })
const editHook = (hook: HookType) => {
selectedHook.value = hook
isWebhookModalOpen.value = true
}
watch(
() => route.value.params.slugs,
async () => {
if (!route.value.params.slugs) {
isDraftMode.value = false
selectedHookId.value = undefined
const onModalClose = () => {
isWebhookModalOpen.value = false
selectedHook.value = undefined
}
onMounted(async () => {
loadSorts()
})
const orderBy = computed<Record<string, SordDirectionType>>({
get: () => {
return sortDirection.value
},
set: (value: Record<string, SordDirectionType>) => {
// Check if value is an empty object
if (Object.keys(value).length === 0) {
saveOrUpdateSort({})
return
}
if (route.value.params.slugs[1] === 'create') {
isDraftMode.value = true
} else {
isDraftMode.value = false
}
const [field, direction] = Object.entries(value)[0]
selectedHookId.value = (route.value.params.slugs && route.value.params.slugs[1]) || undefined
saveOrUpdateSort({
field,
direction,
})
},
})
const eventList = ref<Record<string, any>[]>([
{ text: [t('general.on'), t('labels.recordInsert')], value: ['after', 'insert'] },
{ text: [t('general.on'), t('labels.recordUpdate')], value: ['after', 'update'] },
{ text: [t('general.on'), t('labels.recordDelete')], value: ['after', 'delete'] },
{ text: [t('general.onMultiple'), t('labels.recordInsert')], value: ['after', 'bulkInsert'] },
{ text: [t('general.onMultiple'), t('labels.recordUpdate')], value: ['after', 'bulkUpdate'] },
{ text: [t('general.onMultiple'), t('labels.recordDelete')], value: ['after', 'bulkDelete'] },
])
const columns: NcTableColumnProps[] = [
{
immediate: true,
key: 'active',
title: t('general.active'),
width: 90,
minWidth: 90,
},
)
{
key: 'name',
title: t('general.name'),
minWidth: 252,
showOrderBy: true,
dataIndex: 'title',
},
{
key: 'type',
title: t('general.type'),
basis: '25%',
minWidth: 200,
showOrderBy: true,
dataIndex: 'webhook-operation-type',
},
{
key: 'created_at',
title: t('labels.addedOn'),
width: 180,
minWidth: 180,
showOrderBy: true,
dataIndex: 'created_at',
},
{
key: 'action',
title: '',
width: 80,
minWidth: 80,
},
]
const customRow = (hook: HookType) => {
return {
onClick: () => editHook(hook),
}
}
const getHookTypeText = (hook: HookType) => {
return (
eventList.value.find((e) => e.value.includes(hook.event) && e.value.includes(hook.operation))?.text?.join(' ') ||
`Before ${hook.operation}`
)
}
</script>
<template>
<div
v-if="activeView && !isHooksLoading"
:key="selectedHookId"
class="flex flex-col pt-3 pb-12 border-gray-50 pl-3 pr-0 nc-view-sidebar-webhook nc-scrollbar-md"
style="height: calc(100vh - (var(--topbar-height) * 2))"
>
<div
class="flex flex-row justify-between w-full min-h-8 mb-8"
:class="{
'!items-start mt-1': !selectedHookId && !isDraftMode,
}"
>
<div class="flex flex-row items-center gap-x-1">
<NcButton
v-if="isDraftMode || selectedHookId"
type="text"
size="xsmall"
@click="navigateToWebhook({ openMainPage: true })"
>
<GeneralIcon icon="arrowLeft" />
</NcButton>
<div class="flex flex-row ml-2">
<NuxtLink class="link" :to="webhookMainUrl">{{ $t('objects.webhooks') }}</NuxtLink>
</div>
<template v-if="selectedHook || isDraftMode">
<div class="flex text-gray-400">/</div>
<div class="flex link">{{ selectedHook ? selectedHook.title : $t('general.create') }}</div>
</template>
<div
v-if="selectedHook"
class="flex text-xs px-1.5 py-1 rounded-md ml-1"
:class="{
'bg-green-200 text-green-800': selectedHook.active,
'bg-gray-100 text-gray-500': !selectedHook.active,
}"
>
{{ selectedHook.active ? $t('general.active') : $t('general.inactive') }}
</div>
</div>
<NcButton
v-if="!selectedHookId && !isDraftMode"
v-e="['c:actions:webhook']"
class="mr-4 max-w-40"
type="secondary"
size="small"
@click="createWebhook()"
>
<div class="flex flex-row items-center justify-between w-full text-brand-500">
<span class="ml-1">{{ $t('activity.newWebhook') }}</span>
<GeneralIcon icon="plus" />
</div>
</NcButton>
</div>
<div v-if="!selectedHookId && !isDraftMode" class="flex flex-col h-full w-full items-center">
<div v-if="hooks.length === 0" class="flex flex-col px-1.5 py-2.5 ml-1 h-full items-center gap-y-6 text-center">
<img src="~assets/img/placeholder/webhooks.png" class="!w-[24rem] flex-none" />
<div class="text-gray-700 font-bold text-2xl">{{ $t('msg.createWebhookMsg1') }}</div>
<div class="text-gray-700 max-w-[24rem]">{{ $t('msg.createWebhookMsg2') }}</div>
<NcButton v-e="['c:actions:webhook']" class="flex max-w-40" type="primary" @click="createWebhook()">
<div class="flex flex-row items-center justify-between w-full">
<span class="ml-1">{{ $t('activity.newWebhook') }}</span>
<GeneralIcon icon="plus" />
<div class="nc-webhook-wrapper w-full p-4">
<div class="max-w-250 h-full w-full mx-auto">
<div v-if="activeView && !isHooksLoading">
<div class="w-full mb-4 flex justify-between gap-3">
<div class="flex-1 flex gap-2">
<a-input
v-model:value="webHookSearch"
class="w-full nc-input-sm nc-input-border-on-value !max-w-84"
size="small"
:placeholder="$t('title.searchWebhook')"
allow-clear
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500" />
</template>
</a-input>
<NcButton
class="px-2"
type="text"
size="small"
@click="navigateTo('https://docs.nocodb.com/category/webhook/', { open: navigateToBlankTargetOpenOption })"
>
<div class="flex items-center gap-2">
{{ $t('title.docs') }}
<GeneralIcon icon="externalLink" />
</div>
</NcButton>
</div>
</NcButton>
</div>
<div v-else class="flex flex-col pb-2 mt-3 mb-2.5 w-full max-w-200">
<div class="flex flex-row nc-view-sidebar-webhook-header pl-3 pr-2 !py-2.5">
<div class="nc-view-sidebar-webhook-item-toggle header">{{ $t('general.activate') }}</div>
<div class="nc-view-sidebar-webhook-item-title header">{{ $t('general.title') }}</div>
<div class="nc-view-sidebar-webhook-item-event header">{{ $t('general.event') }}</div>
<div class="nc-view-sidebar-webhook-item-action header">{{ $t('general.action') }}</div>
<NcButton
v-e="['c:actions:webhook']"
type="secondary"
size="small"
class="!text-brand-500 !hover:text-brand-600"
@click="createWebhook"
data-testid="nc-new-webhook"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="plus" />
{{ $t('activity.newWebhook') }}
</div>
</NcButton>
</div>
<div v-for="hook in hooks" :key="hook.id" class="nc-view-sidebar-webhook-item">
<div style="height: calc(100vh - (var(--topbar-height) * 3.5))" class="">
<div
class="flex flex-row w-full items-center pl-3 pr-2 py-2 hover:bg-gray-50 rounded-lg cursor-pointer group text-gray-600"
:class="{
'bg-brand-50 !text-brand-500 hover:bg-brand-50': hook.id === selectedHookId,
}"
v-if="!hooks.length"
class="flex-col flex items-center gap-6 justify-center w-full h-full py-12 px-4 border-1 rounded-xl border-gray-200"
>
<div class="nc-view-sidebar-webhook-item-toggle">
<a-switch
v-e="['c:actions:webhook']"
size="small"
:checked="!!hook.active"
class="min-w-4"
@change="toggleHook(hook)"
/>
</div>
<div class="nc-view-sidebar-webhook-item-title font-medium flex flex-row items-center" @click="openEditor(hook.id!)">
<div class="text-inherit group-hover:text-black capitalize">
{{ hook?.title }}
<div class="text-gray-700 font-bold text-center text-2xl">{{ $t('msg.createWebhookMsg1') }}</div>
<div class="text-gray-700 text-center max-w-[24rem]">{{ $t('msg.createWebhookMsg2') }}</div>
<NcButton v-e="['c:actions:webhook']" class="flex max-w-40" type="primary" size="small" @click="createWebhook">
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="flex-none" />
<span>{{ $t('activity.newWebhook') }}</span>
</div>
</div>
<div class="nc-view-sidebar-webhook-item-event capitalize">{{ hook?.event }} {{ hook?.operation }}</div>
<div class="nc-view-sidebar-webhook-item-action !">
<NcDropdown :trigger="['click']" overlay-class-name="!rounded-md">
<NcButton
size="xsmall"
type="text"
class="nc-btn-webhook-more !text-gray-500 !hover:text-gray-800"
:class="{
'!hover:bg-brand-100': hook.id === selectedHookId,
'!hover:bg-gray-200': hook.id !== selectedHookId,
}"
>
<GeneralIcon icon="threeDotVertical" class="text-inherit" />
</NcButton>
<template #overlay>
<div class="flex flex-col p-1.5 items-start">
<NcButton
type="text"
class="w-full !rounded-md !px-2"
:loading="isCopying"
:centered="false"
@click="copyWebhook(hook)"
>
<template #loading> {{ $t('general.duplicating') }} </template>
<div class="flex items-center gap-x-1">
<GeneralIcon icon="duplicate"></GeneralIcon> {{ $t('general.duplicate') }}
</div>
</NcButton>
<NcButton type="text" class="w-full !rounded-md !px-2" :centered="false" @click="openDeleteModal(hook.id!)">
<div class="flex items-center justify-start gap-x-1 !text-red-500">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }}
</div>
</NcButton>
</div>
</template>
</NcDropdown>
</div>
</NcButton>
</div>
<NcTable
v-else
v-model:order-by="orderBy"
:columns="columns"
:data="sortedHooks"
:custom-row="customRow"
class="h-full"
body-row-class-name="nc-view-sidebar-webhook-item"
>
<template #bodyCell="{ column, record: hook }">
<div v-if="column.key === 'active'" v-e="['c:actions:webhook']" @click.stop>
<NcSwitch size="small" :checked="!!hook.active" @change="toggleHook(hook)" />
</div>
<template v-if="column.key === 'name'">
<NcTooltip class="truncate max-w-full text-gray-800 font-semibold text-sm" show-on-truncate-only>
{{ hook.title }}
<template #title>
{{ hook.title }}
</template>
</NcTooltip>
</template>
<template v-if="column.key === 'type'">
{{ getHookTypeText(hook) }}
</template>
<template v-if="column.key === 'created_at'">
{{ dayjs(hook.created_at).format('DD MMM YYYY') }}
</template>
<template v-if="column.key === 'action'">
<NcDropdown overlay-class-name="nc-webhook-item-action-dropdown">
<NcButton type="secondary" size="small" class="!w-8 !h-8" @click.stop data-testid="nc-webhook-item-action">
<component :is="iconMap.threeDotVertical" class="text-gray-700" />
</NcButton>
<template #overlay>
<NcMenu class="w-48">
<NcMenuItem key="edit" data-testid="nc-webhook-item-action-edit" @click="editHook(hook)">
<GeneralIcon icon="edit" class="text-gray-800" />
<span>{{ $t('general.edit') }}</span>
</NcMenuItem>
<NcMenuItem key="duplicate" data-testid="nc-webhook-item-action-duplicate" @click="copyWebhook(hook)">
<GeneralIcon icon="duplicate" class="text-gray-800" />
<span>{{ $t('general.duplicate') }}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />
<NcMenuItem
key="delete"
class="!hover:bg-red-50"
data-testid="nc-webhook-item-action-delete"
@click="openDeleteModal(hook.id)"
>
<div class="text-red-500">
<GeneralIcon icon="delete" class="group-hover:text-accent -ml-0.25 -mt-0.75 mr-0.5" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</template>
</template>
</NcTable>
</div>
<GeneralDeleteModal v-model:visible="showDeleteModal" :entity-name="$t('objects.webhook')" :on-delete="deleteHook">
<template #entity-preview>
<div v-if="toBeDeleteHook" class="flex flex-row items-center py-2 px-3 bg-gray-50 rounded-lg text-gray-700 mb-4">
<component :is="iconMap.hook" class="text-gray-600" />
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-2.5"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ toBeDeleteHook.title }}
</div>
</div>
</template>
</GeneralDeleteModal>
<Webhook
v-if="isWebhookModalOpen"
v-model:value="isWebhookModalOpen"
:hook="selectedHook"
:event-list="eventList"
@close="onModalClose"
/>
</div>
</div>
<div v-else class="flex w-full pr-4 justify-center">
<div class="flex flex-col mt-4 p-8 mb-4 border-1 rounded-2xl w-full max-w-200" style="height: fit-content">
<WebhookEditor :key="selectedHookId" :hook="selectedHook" @close="onEditorClose" @delete="showDeleteModal = true" />
<div
v-else
class="h-full w-full flex flex-col justify-center items-center"
style="height: calc(100vh - (var(--topbar-height) * 2))"
>
<a-spin size="large" :indicator="indicator" />
</div>
</div>
</div>
<div
v-else
class="h-full w-full flex flex-col justify-center items-center"
style="height: calc(100vh - (var(--topbar-height) * 2))"
>
<a-spin size="large" :indicator="indicator" />
</div>
<GeneralDeleteModal v-model:visible="showDeleteModal" :entity-name="$t('objects.webhook')" :on-delete="deleteHook">
<template #entity-preview>
<div v-if="toBeDeleteHook" class="flex flex-row items-center py-2 px-3 bg-gray-50 rounded-lg text-gray-700 mb-4">
<component :is="iconMap.hook" class="text-gray-600" />
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-2.5"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ toBeDeleteHook.title }}
</div>
</div>
</template>
</GeneralDeleteModal>
<!-- <LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" /> -->
</template>
<style lang="scss" scoped>
.button {
@apply px-2 cursor-pointer hover:bg-gray-50 text-gray-700 rounded hover:text-black;
}
.circle {
width: 0.6rem;
height: 0.6rem;
background-color: #ffffff;
border-radius: 50%;
position: relative;
}
.button {
@apply px-2 cursor-pointer hover:bg-gray-50 text-gray-700 rounded hover:text-black;
}
.dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-52.5%, -52.5%);
}
.link {
@apply !hover:text-gray-800 !text-gray-600 !underline-transparent !hover:underline transition-all duration-150 cursor-pointer;
}
.nc-view-sidebar-webhook-item {
@apply flex flex-row mr-3 items-center border-b-1 py-1 border-gray-100;
}
.nc-view-sidebar-webhook-item:last-child {
@apply border-b-0;
}
.nc-view-sidebar-webhook-item-toggle {
@apply flex flex-row min-w-1/10 max-w-1/10 ml-2;
}
.nc-view-sidebar-webhook-item-title {
@apply flex flex-row min-w-6/10 max-w-6/10;
}
.nc-view-sidebar-webhook-item-event {
@apply flex flex-row min-w-2/10 max-w-2/10;
}
.nc-view-sidebar-webhook-item-action {
@apply flex flex-row w-1/10 justify-end;
}
.nc-view-sidebar-webhook-item > .header {
:deep(.ant-input::placeholder) {
@apply text-gray-500;
}
</style>

2
packages/nc-gui/components/smartsheet/sidebar/toolbar/Webhook.vue

@ -193,7 +193,9 @@ watch(
<GeneralModal v-model:visible="showEditModal" width="48rem" destroy-on-close>
<div class="py-6">
<div class="webhook-scroll px-5 nc-drawer-webhook-body">
<!--
<WebhookEditor :key="selectedHookId" :hook="selectedHook" @close="showEditModal = false" />
-->
</div>
</div>
</GeneralModal>

41
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -15,6 +15,7 @@ interface Props {
isOpen?: boolean
rootMeta?: any
linkColId?: string
actionBtnType?: 'text' | 'secondary'
}
const props = withDefaults(defineProps<Props>(), {
@ -26,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
webHook: false,
link: false,
linkColId: undefined,
actionBtnType: 'text',
})
const emit = defineEmits(['update:filtersLength', 'update:draftFilter', 'update:modelValue'])
@ -519,6 +521,7 @@ const changeToDynamic = async (filter, i) => {
class="menu-filter-dropdown w-min"
:class="{
'max-h-[max(80vh,500px)] min-w-112 py-2 pl-4': !nested,
'!min-w-full !w-full !pl-0': !nested && webHook,
'min-w-full': nested,
}"
>
@ -579,7 +582,7 @@ const changeToDynamic = async (filter, i) => {
v-if="visibleFilters && visibleFilters.length"
ref="wrapperDomRef"
class="flex flex-col gap-y-1.5 nc-filter-grid min-w-full w-min"
:class="{ 'max-h-420px nc-scrollbar-thin nc-filter-top-wrapper pr-4 my-2 py-1': !nested }"
:class="{ 'max-h-420px nc-scrollbar-thin nc-filter-top-wrapper pr-4 my-2 py-1': !nested, '!pr-0': webHook && !nested }"
@click.stop
>
<template v-for="(filter, i) in filters" :key="i">
@ -610,11 +613,15 @@ const changeToDynamic = async (filter, i) => {
v-model:value="filter.logical_op"
v-e="['c:filter:logical-op:select']"
:dropdown-match-select-width="false"
class="min-w-18 max-w-18 capitalize"
class="min-w-18 capitalize"
placeholder="Group op"
dropdown-class-name="nc-dropdown-filter-logical-op-group"
:disabled="i > 1 && !isLogicalOpChangeAllowed"
:class="{ 'nc-disabled-logical-op': filter.readOnly || (i > 1 && !isLogicalOpChangeAllowed) }"
:class="{
'nc-disabled-logical-op': filter.readOnly || (i > 1 && !isLogicalOpChangeAllowed),
'!max-w-18': !webHook,
'!w-full': webHook,
}"
@click.stop
@change="onLogicalOpUpdate(filter, i)"
>
@ -660,7 +667,7 @@ const changeToDynamic = async (filter, i) => {
v-model:value="filter.logical_op"
v-e="['c:filter:logical-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="h-full !min-w-18 !max-w-18 capitalize"
class="h-full !max-w-18 !min-w-18 capitalize"
hide-details
:disabled="filter.readOnly || (visibleFilters.indexOf(filter) > 1 && !isLogicalOpChangeAllowed)"
dropdown-class-name="nc-dropdown-filter-logical-op"
@ -686,7 +693,11 @@ const changeToDynamic = async (filter, i) => {
<SmartsheetToolbarFieldListAutoCompleteDropdown
:key="`${i}_6`"
v-model="filter.fk_column_id"
class="nc-filter-field-select min-w-32 max-w-32 max-h-8"
:class="{
'max-w-32': !webHook,
'!w-full': webHook,
}"
class="nc-filter-field-select min-w-32 max-h-8"
:columns="fieldsToFilter"
:disabled="filter.readOnly"
:meta="meta"
@ -698,8 +709,12 @@ const changeToDynamic = async (filter, i) => {
v-model:value="filter.comparison_op"
v-e="['c:filter:comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-operation-select !min-w-26.75 !max-w-26.75 max-h-8"
class="caption nc-filter-operation-select !min-w-26.75 max-h-8"
:placeholder="$t('labels.operation')"
:class="{
'!max-w-26.75': !webHook,
'!w-full': webHook,
}"
density="compact"
variant="solo"
:disabled="filter.readOnly"
@ -735,7 +750,7 @@ const changeToDynamic = async (filter, i) => {
class="caption nc-filter-sub_operation-select min-w-28"
:class="{
'flex-grow w-full': !showFilterInput(filter),
'max-w-28': showFilterInput(filter),
'max-w-28': showFilterInput(filter) && !webHook,
}"
:placeholder="$t('labels.operationSub')"
density="compact"
@ -789,6 +804,10 @@ const changeToDynamic = async (filter, i) => {
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:class="{
'!w-full': webHook,
'!w-18': !webHook,
}"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@ -879,7 +898,7 @@ const changeToDynamic = async (filter, i) => {
'mt-1 mb-2': filters.length,
}"
>
<NcButton size="small" type="text" class="nc-btn-focus" @click.stop="addFilter()">
<NcButton size="small" :type="actionBtnType" class="nc-btn-focus" @click.stop="addFilter()">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
@ -887,7 +906,7 @@ const changeToDynamic = async (filter, i) => {
</div>
</NcButton>
<NcButton v-if="nestedLevel < 5" class="nc-btn-focus" type="text" size="small" @click.stop="addFilterGroup()">
<NcButton v-if="nestedLevel < 5" class="nc-btn-focus" :type="actionBtnType" size="small" @click.stop="addFilterGroup()">
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plus" />
@ -905,7 +924,7 @@ const changeToDynamic = async (filter, i) => {
'mt-1 mb-2': filters.length,
}"
>
<NcButton class="nc-btn-focus" size="small" type="text" @click.stop="addFilter()">
<NcButton class="nc-btn-focus" size="small" :type="actionBtnType" @click.stop="addFilter()">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
@ -916,7 +935,7 @@ const changeToDynamic = async (filter, i) => {
<NcButton
v-if="!link && !webHook && nestedLevel < 5"
class="nc-btn-focus"
type="text"
:type="actionBtnType"
size="small"
@click.stop="addFilterGroup()"
>

168
packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue

@ -1,168 +0,0 @@
<script lang="ts" setup>
import type { RequestParams } from 'nocodb-sdk'
import { ExportTypes } from 'nocodb-sdk'
import { saveAs } from 'file-saver'
import * as XLSX from 'xlsx'
const { t } = useI18n()
const sharedViewListDlg = ref(false)
const isPublicView = inject(IsPublicInj, ref(false))
const isView = false
const { base } = storeToRefs(useBase())
const { $api } = useNuxtApp()
const meta = inject(MetaInj, ref())
const fields = inject(FieldsInj, ref([]))
const selectedView = inject(ActiveViewInj, ref())
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { exportFile: sharedViewExportFile } = useSharedView()
const isLocked = inject(IsLockedInj)
const showWebhookDrawer = ref(false)
const quickImportDialog = ref(false)
const { isUIAllowed } = useRoles()
const exportFile = async (exportType: ExportTypes) => {
let offset = 0
let c = 1
const responseType = exportType === ExportTypes.EXCEL ? 'base64' : 'blob'
try {
while (!isNaN(offset) && offset > -1) {
let res
if (isPublicView.value) {
res = await sharedViewExportFile(fields.value, offset, exportType, responseType)
} else {
res = await $api.dbViewRow.export(
'noco',
base?.value.id as string,
meta.value?.id as string,
selectedView.value?.id as string,
exportType,
{
responseType,
query: {
fields: fields.value.map((field) => field.title),
offset,
sortArrJson: JSON.stringify(sorts.value),
filterArrJson: JSON.stringify(nestedFilters.value),
},
} as RequestParams,
)
}
const { data, headers } = res
if (exportType === ExportTypes.EXCEL) {
const workbook = XLSX.read(data, { type: 'base64' })
XLSX.writeFile(workbook, `${meta.value?.title}_exported_${c++}.xlsx`)
} else if (exportType === ExportTypes.CSV) {
const blob = new Blob([data], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${meta.value?.title}_exported_${c++}.csv`)
}
offset = +headers['nc-export-offset']
if (offset > -1) {
// Downloading more files
message.info(t('msg.info.downloadingMoreFiles'))
} else {
// Successfully exported all table data
message.success(t('msg.success.tableDataExported'))
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div>
<a-dropdown>
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-1 items-center">
<MdiFlashOutline />
<!-- More -->
<span class="!text-sm font-weight-medium">{{ $t('general.more') }}</span>
<MdiMenuDown class="text-grey" />
</div>
</a-button>
<template #overlay>
<div class="bg-gray-50 py-2 shadow-lg !border">
<div>
<div v-e="['a:actions:download-csv']" class="nc-menu-item" @click="exportFile(ExportTypes.CSV)">
<MdiDownloadOutline class="text-gray-500" />
<!-- Download as CSV -->
{{ $t('activity.downloadCSV') }}
</div>
<div v-e="['a:actions:download-excel']" class="nc-menu-item" @click="exportFile(ExportTypes.EXCEL)">
<MdiDownloadOutline class="text-gray-500" />
<!-- Download as XLSX -->
{{ $t('activity.downloadExcel') }}
</div>
<div
v-if="isUIAllowed('csvImport') && !isView && !isPublicView"
v-e="['a:actions:upload-csv']"
class="nc-menu-item"
:class="{ disabled: isLocked }"
@click="!isLocked ? (quickImportDialog = true) : {}"
>
<MdiUploadOutline class="text-gray-500" />
<!-- Upload CSV -->
{{ $t('activity.uploadCSV') }}
</div>
<div
v-if="isUIAllowed('viewShare') && !isView && !isPublicView"
v-e="['a:actions:shared-view-list']"
class="nc-menu-item"
@click="sharedViewListDlg = true"
>
<MdiViewListOutline class="text-gray-500" />
<!-- Shared View List -->
{{ $t('activity.listSharedView') }}
</div>
<div
v-if="isUIAllowed('webhook') && !isView && !isPublicView"
v-e="['c:actions:webhook']"
class="nc-menu-item"
@click="showWebhookDrawer = true"
>
<MdiHook class="text-gray-500" />
{{ $t('objects.webhooks') }}
</div>
</div>
</div>
</template>
</a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" />
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />
<a-modal
v-model:visible="sharedViewListDlg"
:class="{ active: sharedViewListDlg }"
:title="$t('activity.listSharedView')"
width="max(900px,60vw)"
:footer="null"
wrap-class-name="nc-modal-shared-view-list"
>
<LazySmartsheetToolbarSharedViewList v-if="sharedViewListDlg" />
</a-modal>
</div>
</template>

216
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -1,216 +0,0 @@
<script lang="ts" setup>
import type { Ref } from '@vue/reactivity'
import { LockType } from '#imports'
const { t } = useI18n()
const sharedViewListDlg = ref(false)
const isPublicView = inject(IsPublicInj, ref(false))
const isView = false
const { $api, $e } = useNuxtApp()
const { isSqlView } = useSmartsheetStoreOrThrow()
const selectedView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const showWebhookDrawer = ref(false)
const showApiSnippetDrawer = ref(false)
const showErd = ref(false)
type QuickImportDialogType = 'csv' | 'excel' | 'json'
// TODO: add 'json' when it's ready
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<(typeof quickImportDialogTypes)[number], Ref<boolean>> = quickImportDialogTypes.reduce(
(acc: any, curr) => {
acc[curr] = ref(false)
return acc
},
{},
) as Record<QuickImportDialogType, Ref<boolean>>
const { isUIAllowed } = useRoles()
useBase()
const meta = inject(MetaInj, ref())
const currentBaseId = computed(() => meta.value?.base_id)
const currentSourceId = computed(() => meta.value?.source_id)
/*
const Icon = computed(() => {
switch (selectedView.value?.lock_type) {
case LockType.Personal:
return iconMap.account
case LockType.Locked:
return iconMap.lock
case LockType.Collaborative:
default:
return iconMap.users
}
})
*/
const lockType = computed(() => (selectedView.value?.lock_type as LockType) || LockType.Collaborative)
async function changeLockType(type: LockType) {
$e('a:grid:lockmenu', { lockType: type })
if (!selectedView.value) return
if (type === 'personal') {
// Coming soon
return message.info(t('msg.toast.futureRelease'))
}
try {
selectedView.value.lock_type = type
await $api.dbView.update(selectedView.value.id as string, {
lock_type: type,
})
message.success(`Successfully Switched to ${type} view`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const open = ref(false)
useMenuCloseOnEsc(open)
</script>
<template>
<div>
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-actions-menu" placement="bottomRight">
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn !border-1 !border-gray-200 !rounded-md !py-1 !px-2">
<MdiDotsHorizontal class="!w-4 !h-4" />
</a-button>
<template #overlay>
<a-menu class="!py-0 !rounded !text-gray-800 text-sm" data-testid="toolbar-actions" @click="open = false">
<a-menu-item-group>
<template v-if="isUIAllowed('csvTableImport') && !isView && !isPublicView && !isSqlView">
<a-sub-menu key="upload">
<template #title>
<div v-e="['c:navdraw:preview-as']" class="nc-base-menu-item group">
<GeneralIcon type="upload" />
{{ $t('general.upload') }}
<div class="flex-1" />
<component :is="iconMap.arrowRight" />
</div>
</template>
<template #expandIcon></template>
<template v-for="(dialog, type) in quickImportDialogs">
<a-menu-item v-if="isUIAllowed(`${type}TableImport`) && !isView && !isPublicView" :key="type">
<div
v-e="[`a:upload:${type}`]"
class="nc-base-menu-item"
:class="{ disabled: isLocked }"
@click="!isLocked ? (dialog.value = true) : {}"
>
<component :is="iconMap.upload" />
{{ `${$t('general.upload')} ${type.toUpperCase()}` }}
</div>
</a-menu-item>
</template>
</a-sub-menu>
</template>
<a-sub-menu key="download">
<template #title>
<div v-e="['c:download']" class="nc-base-menu-item group">
<GeneralIcon icon="download" />
{{ $t('general.download') }}
<div class="flex-1" />
<component :is="iconMap.arrowRight" />
</div>
</template>
<template #expandIcon></template>
<LazySmartsheetToolbarExportSubActions />
</a-sub-menu>
<a-sub-menu
v-if="isUIAllowed('viewCreateOrEdit')"
key="lock-type"
class="scrollbar-thin-dull max-h-90vh overflow-auto !py-0"
>
<template #title>
<div v-e="['c:navdraw:preview-as']" class="nc-base-menu-item group px-0 !py-0">
<LazySmartsheetToolbarLockType hide-tick :type="lockType" />
<component :is="iconMap.arrowRight" />
</div>
</template>
<template #expandIcon></template>
<a-menu-item @click="changeLockType(LockType.Collaborative)">
<LazySmartsheetToolbarLockType :type="LockType.Collaborative" />
</a-menu-item>
<a-menu-item @click="changeLockType(LockType.Locked)">
<LazySmartsheetToolbarLockType :type="LockType.Locked" />
</a-menu-item>
<!-- <a-menu-item @click="changeLockType(LockType.Personal)">
<LazySmartsheetToolbarLockType :type="LockType.Personal" />
</a-menu-item> -->
</a-sub-menu>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>
<template v-if="currentSourceId && currentBaseId">
<LazyDlgQuickImport
v-for="tp in quickImportDialogTypes"
:key="tp"
v-model="quickImportDialogs[tp].value"
:import-type="tp"
:base-id="currentBaseId"
:source-id="currentSourceId"
:import-data-only="true"
/>
</template>
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />
<LazySmartsheetToolbarErd v-model="showErd" />
<a-modal
v-model:visible="sharedViewListDlg"
:class="{ active: sharedViewListDlg }"
:title="$t('activity.listSharedView')"
width="max(900px,60vw)"
:footer="null"
wrap-class-name="nc-modal-shared-view-list"
>
<LazySmartsheetToolbarSharedViewList v-if="sharedViewListDlg" />
</a-modal>
<LazySmartsheetApiSnippet v-model="showApiSnippetDrawer" />
</div>
</template>
<style scoped>
:deep(.ant-dropdown-menu-submenu-title) {
@apply py-0;
}
:deep(.ant-dropdown-menu-item-group-title) {
@apply hidden;
}
</style>

58
packages/nc-gui/components/webhook/Drawer.vue

@ -1,58 +0,0 @@
<script setup lang="ts">
interface Props {
modelValue: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const editOrAdd = ref(false)
const vModel = useVModel(props, 'modelValue', emits)
const currentHook = ref<Record<string, any>>()
async function editHook(hook: Record<string, any>) {
editOrAdd.value = true
currentHook.value = hook
}
async function addHook() {
editOrAdd.value = true
currentHook.value = undefined
}
</script>
<template>
<a-drawer
v-model:visible="vModel"
:closable="false"
placement="right"
width="700px"
:body-style="{ background: 'rgba(67, 81, 232, 0.05)', padding: '0px 0px', overflow: 'hidden' }"
class="nc-drawer-webhook"
@keydown.esc="vModel = false"
>
<a-layout class="nc-drawer-webhook-body">
<a-layout-content class="px-10 py-5 scrollbar-thin-primary">
<LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" />
<LazyWebhookList v-else @edit="editHook" @add="addHook" />
</a-layout-content>
<a-layout-footer class="!bg-white border-t flex">
<a-button
v-e="['e:hiring']"
class="mx-auto mb-4 !rounded-md"
href="https://angel.co/company/nocodb"
target="_blank"
size="large"
rel="noopener noreferrer"
>
🚀 {{ $t('labels.weAreHiring') }}! 🚀
</a-button>
</a-layout-footer>
</a-layout>
</a-drawer>
</template>

885
packages/nc-gui/components/webhook/Editor.vue

@ -1,885 +0,0 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import type { HookReqType, HookType } from 'nocodb-sdk'
import {
Form,
MetaInj,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
iconMap,
inject,
isEeUI,
message,
onMounted,
parseProp,
reactive,
ref,
useApi,
// useGlobal,
useI18n,
useNuxtApp,
watch,
} from '#imports'
import { extractNextDefaultName } from '~/helpers/parsers/parserHelpers'
interface Props {
hook?: HookType
}
const props = defineProps<Props>()
const emit = defineEmits(['close', 'delete'])
const { t } = useI18n()
const { $e } = useNuxtApp()
const { api, isLoading: loading } = useApi()
// const { appInfo } = useGlobal()
const { hooks } = storeToRefs(useWebhooksStore())
const { base } = storeToRefs(useBase())
const meta = inject(MetaInj, ref())
const titleDomRef = ref<HTMLInputElement | undefined>()
// const hookTabKey = ref('hook-edit')
const useForm = Form.useForm
const defaultHookName = t('labels.webhook')
let hookRef = reactive<
Omit<HookType, 'notification'> & { notification: Record<string, any>; eventOperation?: string; condition: boolean }
>({
id: '',
title: defaultHookName,
event: undefined,
operation: undefined,
eventOperation: undefined,
notification: {
type: 'URL',
payload: {
method: 'POST',
body: '{{ json event }}',
headers: [{}],
parameters: [{}],
path: '',
},
},
condition: false,
active: true,
version: 'v2',
})
const isBodyShown = ref(hookRef.version === 'v1' || isEeUI)
const urlTabKey = ref<'params' | 'headers' | 'body'>('params')
const apps: Record<string, any> = ref()
const webhookTestRef = ref()
const slackChannels = ref<Record<string, any>[]>([])
const teamsChannels = ref<Record<string, any>[]>([])
const discordChannels = ref<Record<string, any>[]>([])
const mattermostChannels = ref<Record<string, any>[]>([])
const filterRef = ref()
const formInput = ref({
'Email': [
{
key: 'to',
label: t('labels.toAddress'),
placeholder: t('labels.toAddress'),
type: 'SingleLineText',
required: true,
},
{
key: 'subject',
label: t('labels.subject'),
placeholder: t('labels.subject'),
type: 'SingleLineText',
required: true,
},
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
],
'Slack': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
],
'Microsoft Teams': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
],
'Discord': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
],
'Mattermost': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
],
'Twilio': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
{
key: 'to',
label: t('labels.commaSeparatedMobileNumber'),
placeholder: t('labels.commaSeparatedMobileNumber'),
type: 'LongText',
required: true,
},
],
'Whatsapp Twilio': [
{
key: 'body',
label: t('labels.body'),
placeholder: t('labels.body'),
type: 'LongText',
required: true,
},
{
key: 'to',
label: t('labels.commaSeparatedMobileNumber'),
placeholder: t('labels.commaSeparatedMobileNumber'),
type: 'LongText',
required: true,
},
],
})
const isRenaming = ref(false)
// TODO: Add back when show logs is working
const showLogs = computed(
() => false,
// !(
// appInfo.automationLogLevel === AutomationLogLevel.OFF ||
// (appInfo.automationLogLevel === AutomationLogLevel.ALL && !appInfo.ee)
// ),
)
const eventList = ref<Record<string, any>[]>([
{ text: [t('general.after'), t('general.insert')], value: ['after', 'insert'] },
{ text: [t('general.after'), t('general.update')], value: ['after', 'update'] },
{ text: [t('general.after'), t('general.delete')], value: ['after', 'delete'] },
{ text: [t('general.after'), t('general.bulkInsert')], value: ['after', 'bulkInsert'] },
{ text: [t('general.after'), t('general.bulkUpdate')], value: ['after', 'bulkUpdate'] },
{ text: [t('general.after'), t('general.bulkDelete')], value: ['after', 'bulkDelete'] },
])
const notificationList = computed(() => {
return isEeUI
? [{ type: 'URL', text: t('datatype.URL') }]
: [
{ type: 'URL', text: t('datatype.URL') },
{ type: 'Email', text: t('datatype.Email') },
{ type: 'Slack', text: t('general.slack') },
{ type: 'Microsoft Teams', text: t('general.microsoftTeams') },
{ type: 'Discord', text: t('general.discord') },
{ type: 'Mattermost', text: t('general.matterMost') },
{ type: 'Twilio', text: t('general.twilio') },
{ type: 'Whatsapp Twilio', text: t('general.whatsappTwilio') },
]
})
const methodList = [
{ title: 'GET' },
{ title: 'POST' },
{ title: 'DELETE' },
{ title: 'PUT' },
{ title: 'HEAD' },
{ title: 'PATCH' },
]
const validators = computed(() => {
return {
'title': [fieldRequiredValidator()],
'eventOperation': [fieldRequiredValidator()],
'notification.type': [fieldRequiredValidator()],
...(hookRef.notification.type === 'URL' && {
'notification.payload.method': [fieldRequiredValidator()],
'notification.payload.path': [fieldRequiredValidator()],
}),
...(hookRef.notification.type === 'Email' && {
'notification.payload.to': [fieldRequiredValidator()],
'notification.payload.subject': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()],
}),
...(['Slack', 'Microsoft Teams', 'Discord', 'Mattermost'].includes(hookRef.notification.type) && {
'notification.payload.channels': [fieldRequiredValidator()],
'notification.payload.body': [fieldRequiredValidator()],
}),
...((hookRef.notification.type === 'Twilio' || hookRef.notification.type === 'Whatsapp Twilio') && {
'notification.payload.body': [fieldRequiredValidator()],
'notification.payload.to': [fieldRequiredValidator()],
}),
}
})
const { validate, validateInfos } = useForm(hookRef, validators)
const isValid = computed(() => {
// Recursively check if all the fields are valid
const check = (obj: Record<string, any>) => {
for (const key in obj) {
if (typeof obj[key] === 'object') {
if (!check(obj[key])) {
return false
}
} else if (obj && key === 'validateStatus' && obj[key] === 'error') {
return false
}
}
return true
}
return hookRef && check(validateInfos)
})
function onNotificationTypeChange(reset = false) {
if (reset) {
hookRef.notification.payload = {} as Record<string, any>
if (['Slack', 'Microsoft Teams', 'Discord', 'Mattermost'].includes(hookRef.notification.type)) {
hookRef.notification.payload.channels = []
hookRef.notification.payload.body = ''
}
}
if (hookRef.notification.type === 'Slack') {
slackChannels.value = (apps.value && apps.value.Slack && apps.value.Slack.parsedInput) || []
}
if (hookRef.notification.type === 'Microsoft Teams') {
teamsChannels.value = (apps.value && apps.value['Microsoft Teams'] && apps.value['Microsoft Teams'].parsedInput) || []
}
if (hookRef.notification.type === 'Discord') {
discordChannels.value = (apps.value && apps.value.Discord && apps.value.Discord.parsedInput) || []
}
if (hookRef.notification.type === 'Mattermost') {
mattermostChannels.value = (apps.value && apps.value.Mattermost && apps.value.Mattermost.parsedInput) || []
}
if (hookRef.notification.type === 'URL') {
const body = hookRef.notification.payload.body
hookRef.notification.payload.body = body ? (body === '{{ json data }}' ? '{{ json event }}' : body) : '{{ json event }}'
hookRef.notification.payload.parameters = hookRef.notification.payload.parameters || [{}]
hookRef.notification.payload.headers = hookRef.notification.payload.headers || [{}]
hookRef.notification.payload.method = hookRef.notification.payload.method || 'POST'
hookRef.notification.payload.auth = hookRef.notification.payload.auth || ''
}
}
function setHook(newHook: HookType) {
const notification = newHook.notification as Record<string, any>
Object.assign(hookRef, {
...newHook,
notification: {
...notification,
payload: notification.payload,
},
})
}
function onEventChange() {
const { notification: { payload = {}, type = {} } = {} } = hookRef
Object.assign(hookRef, {
...hookRef,
notification: {
type,
payload,
},
})
hookRef.notification.payload = payload
const channels: Ref<Record<string, any>[] | null> = ref(null)
switch (hookRef.notification.type) {
case 'Slack':
channels.value = slackChannels.value
break
case 'Microsoft Teams':
channels.value = teamsChannels.value
break
case 'Discord':
channels.value = discordChannels.value
break
case 'Mattermost':
channels.value = mattermostChannels.value
break
}
if (channels) {
hookRef.notification.payload.webhook_url =
hookRef.notification.payload.webhook_url &&
hookRef.notification.payload.webhook_url.map((v: { webhook_url: string }) =>
channels.value?.find((s) => v.webhook_url === s.webhook_url),
)
}
if (hookRef.notification.type === 'URL') {
hookRef.notification.payload = hookRef.notification.payload || {}
hookRef.notification.payload.parameters = hookRef.notification.payload.parameters || [{}]
hookRef.notification.payload.headers = hookRef.notification.payload.headers || [{}]
hookRef.notification.payload.method = hookRef.notification.payload.method || 'POST'
}
}
async function loadPluginList() {
if (isEeUI) return
try {
const plugins = (
await api.plugin.webhookList({
query: {
base_id: base.value.id,
},
})
).list!
apps.value = plugins.reduce((o, p) => {
const plugin: { title: string; tags: string[]; parsedInput: Record<string, any> } = {
title: '',
tags: [],
parsedInput: {},
...(p as any),
}
plugin.tags = p.tags ? p.tags.split(',') : []
plugin.parsedInput = parseProp(p.input)
o[plugin.title] = plugin
return o
}, {} as Record<string, any>)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const isConditionSupport = computed(() => {
return hookRef.eventOperation && !hookRef.eventOperation.includes('bulk')
})
async function saveHooks() {
loading.value = true
try {
await validate()
} catch (_: any) {
message.error(t('msg.error.invalidForm'))
loading.value = false
return
}
try {
let res
if (hookRef.id) {
res = await api.dbTableWebhook.update(hookRef.id, {
...hookRef,
notification: {
...hookRef.notification,
payload: hookRef.notification.payload,
},
})
} else {
res = await api.dbTableWebhook.create(meta.value!.id!, {
...hookRef,
notification: {
...hookRef.notification,
payload: hookRef.notification.payload,
},
} as HookReqType)
hooks.value.push(res)
}
if (res && typeof res.notification === 'string') {
res.notification = JSON.parse(res.notification)
}
if (!hookRef.id && res) {
hookRef = { ...hookRef, ...res } as any
}
if (filterRef.value) {
await filterRef.value.applyChanges(hookRef.id, false, isConditionSupport.value)
}
// Webhook details updated successfully
hooks.value = hooks.value.map((h) => {
if (h.id === hookRef.id) {
return hookRef
}
return h
})
emit('close')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
loading.value = false
}
$e('a:webhook:add', {
operation: hookRef.operation,
condition: hookRef.condition,
notification: hookRef.notification.type,
})
}
async function testWebhook() {
await webhookTestRef.value.testWebhook()
}
const getDefaultHookName = (hooks: HookType[]) => {
return extractNextDefaultName([...hooks.map((el) => el?.title || '')], defaultHookName)
}
watch(
() => hookRef.eventOperation,
() => {
if (!hookRef.eventOperation) return
const [event, operation] = hookRef.eventOperation.split(' ')
hookRef.event = event as HookType['event']
hookRef.operation = operation as HookType['operation']
},
)
watch(
() => props.hook,
() => {
if (props.hook) {
setHook(props.hook)
onEventChange()
} else {
// Set the default hook title only when creating a new hook.
hookRef.title = getDefaultHookName(hooks.value)
}
},
{ immediate: true },
)
onMounted(async () => {
await loadPluginList()
if (hookRef.event && hookRef.operation) {
hookRef.eventOperation = `${hookRef.event} ${hookRef.operation}`
} else {
hookRef.eventOperation = eventList.value[0].value.join(' ')
}
onNotificationTypeChange()
setTimeout(() => {
if (hookRef.id === '') {
titleDomRef.value?.click()
titleDomRef.value?.select()
}
}, 50)
})
</script>
<template>
<div class="flex nc-webhook-header pb-3 gap-x-2 items-start">
<div class="flex flex-1">
<a-form-item v-bind="validateInfos.title" class="flex flex-grow">
<div
class="flex flex-grow px-1.5 py-0.125 items-center rounded-md border-gray-200 bg-gray-50 outline-gray-200"
style="outline-style: solid; outline-width: thin"
>
<input
ref="titleDomRef"
v-model="hookRef.title"
class="flex flex-grow text-lg font-medium capitalize outline-none bg-inherit nc-text-field-hook-title"
:placeholder="$t('placeholder.webhookTitle')"
:contenteditable="true"
@blur="isRenaming = false"
@focus="isRenaming = true"
@keydown.enter.prevent="titleDomRef?.blur()"
/>
</div>
</a-form-item>
</div>
<div class="flex flex-row gap-2">
<NcButton class="nc-btn-webhook-test" type="secondary" size="small" @click="testWebhook">
<div class="flex items-center px-1">{{ $t('activity.testWebhook') }}</div>
</NcButton>
<NcButton
class="nc-btn-webhook-save"
type="primary"
:loading="loading"
size="small"
:disabled="!isValid"
@click.prevent="saveHooks"
>
<template #loading> {{ $t('general.saving') }} </template>
<div class="flex items-center px-1">{{ $t('general.save') }}</div>
</NcButton>
</div>
</div>
<div class="flex flex-row">
<div
class="nc-webhook-form flex flex-col"
:class="{
'w-1/2': showLogs,
'w-full': !showLogs,
}"
>
<a-form :model="hookRef" name="create-or-edit-webhook">
<a-form-item>
<div class="form-field-header">{{ $t('general.event') }}</div>
<a-row type="flex" :gutter="[16, 16]">
<a-col :span="12">
<a-form-item v-bind="validateInfos.eventOperation">
<NcSelect
v-model:value="hookRef.eventOperation"
size="large"
:placeholder="$t('general.event')"
class="nc-text-field-hook-event capitalize"
dropdown-class-name="nc-dropdown-webhook-event"
>
<a-select-option
v-for="(event, i) in eventList"
:key="i"
class="capitalize"
:value="event.value.join(' ')"
:disabled="hookRef.version === 'v1' && ['bulkInsert', 'bulkUpdate', 'bulkDelete'].includes(event.value[1])"
>
<div class="flex items-center gap-2 justify-between">
<div>{{ event.text.join(' ') }}</div>
<component
:is="iconMap.check"
v-if="hookRef.eventOperation === event.value.join(' ')"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item v-bind="validateInfos['notification.type']">
<NcSelect
v-model:value="hookRef.notification.type"
size="large"
class="nc-select-hook-notification-type"
:placeholder="$t('general.notification')"
dropdown-class-name="nc-dropdown-webhook-notification"
@change="onNotificationTypeChange(true)"
>
<a-select-option v-for="(notificationOption, i) in notificationList" :key="i" :value="notificationOption.type">
<div class="flex items-center gap-2">
<component :is="iconMap.link" v-if="notificationOption.type === 'URL'" class="mr-2" />
<component :is="iconMap.email" v-if="notificationOption.type === 'Email'" class="mr-2" />
<MdiSlack v-if="notificationOption.type === 'Slack'" class="mr-2" />
<MdiMicrosoftTeams v-if="notificationOption.type === 'Microsoft Teams'" class="mr-2" />
<MdiDiscord v-if="notificationOption.type === 'Discord'" class="mr-2" />
<MdiChat v-if="notificationOption.type === 'Mattermost'" class="mr-2" />
<MdiWhatsapp v-if="notificationOption.type === 'Whatsapp Twilio'" class="mr-2" />
<MdiCellphoneMessage v-if="notificationOption.type === 'Twilio'" class="mr-2" />
<div class="flex-1">{{ notificationOption.text }}</div>
<component
:is="iconMap.check"
v-if="hookRef.notification.type === notificationOption.type"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hookRef.notification.type === 'URL'" class="mb-5" type="flex" :gutter="[16, 0]">
<a-col :span="6">
<div>Action</div>
<NcSelect
v-model:value="hookRef.notification.payload.method"
size="large"
class="nc-select-hook-url-method"
dropdown-class-name="nc-dropdown-hook-notification-url-method"
>
<a-select-option v-for="(method, i) in methodList" :key="i" :value="method.title">
<div class="flex items-center gap-2 justify-between">
<div>{{ method.title }}</div>
<component
:is="iconMap.check"
v-if="hookRef.notification.payload.method === method.title"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-col>
<a-col :span="18">
<div>Link</div>
<a-form-item v-bind="validateInfos['notification.payload.path']">
<a-input
v-model:value="hookRef.notification.payload.path"
size="large"
placeholder="http://example.com"
class="nc-text-field-hook-url-path !rounded-md"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<NcTabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="border-1 !pb-2 !rounded-lg">
<a-tab-pane key="params" :tab="$t('title.parameter')" force-render>
<LazyApiClientParams v-model="hookRef.notification.payload.parameters" class="p-4" />
</a-tab-pane>
<a-tab-pane key="headers" :tab="$t('title.headers')" class="nc-tab-headers">
<LazyApiClientHeaders v-model="hookRef.notification.payload.headers" class="!p-4" />
</a-tab-pane>
<a-tab-pane v-if="isBodyShown" key="body" tab="Body">
<LazyMonacoEditor
v-model="hookRef.notification.payload.body"
disable-deep-compare
:validate="false"
class="min-h-60 max-h-80"
/>
</a-tab-pane>
<!-- No in use at this moment -->
<!-- <a-tab-pane key="auth" tab="Auth"> -->
<!-- <LazyMonacoEditor v-model="hook.notification.payload.auth" class="min-h-60 max-h-80" /> -->
<!-- <span class="text-gray-500 prose-sm p-2"> -->
<!-- For more about auth option refer -->
<!-- <a class="prose-sm" href ="https://github.com/axios/axios#request-config" target="_blank">axios docs</a>. -->
<!-- </span> -->
<!-- </a-tab-pane> -->
</NcTabs>
</a-col>
</a-row>
<a-row v-if="hookRef.notification.type === 'Slack'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hookRef.notification.payload.channels"
:selected-channel-list="hookRef.notification.payload.channels"
:available-channel-list="slackChannels"
:placeholder="$t('placeholder.selectSlackChannels')"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hookRef.notification.type === 'Microsoft Teams'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hookRef.notification.payload.channels"
:selected-channel-list="hookRef.notification.payload.channels"
:available-channel-list="teamsChannels"
:placeholder="$t('placeholder.selectTeamsChannels')"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hookRef.notification.type === 'Discord'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hookRef.notification.payload.channels"
:selected-channel-list="hookRef.notification.payload.channels"
:available-channel-list="discordChannels"
:placeholder="$t('placeholder.selectDiscordChannels')"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="hookRef.notification.type === 'Mattermost'" type="flex">
<a-col :span="24">
<a-form-item v-bind="validateInfos['notification.payload.channels']">
<LazyWebhookChannelMultiSelect
v-model="hookRef.notification.payload.channels"
:selected-channel-list="hookRef.notification.payload.channels"
:available-channel-list="mattermostChannels"
:placeholder="$t('placeholder.selectMattermostChannels')"
/>
</a-form-item>
</a-col>
</a-row>
<a-row v-if="formInput[hookRef.notification.type] && hookRef.notification.payload" type="flex">
<a-col v-for="(input, i) in formInput[hookRef.notification.type]" :key="i" :span="24">
<a-form-item v-if="input.type === 'LongText'" v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-textarea v-model:value="hookRef.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
<a-form-item v-else v-bind="validateInfos[`notification.payload.${input.key}`]">
<a-input v-model:value="hookRef.notification.payload[input.key]" :placeholder="input.label" size="large" />
</a-form-item>
</a-col>
</a-row>
<a-row v-show="isConditionSupport" class="mb-5" type="flex">
<a-col :span="24">
<div class="rounded-lg border-1 p-6">
<a-checkbox
:checked="Boolean(hookRef.condition)"
class="nc-check-box-hook-condition"
@update:checked="hookRef.condition = $event"
>
{{ $t('activity.onCondition') }}
</a-checkbox>
<LazySmartsheetToolbarColumnFilter
v-if="hookRef.condition"
ref="filterRef"
class="!p-0 mt-4"
:auto-save="false"
:show-loading="false"
:hook-id="hookRef.id"
:web-hook="true"
@update:filters-length="hookRef.condition = $event > 0"
/>
</div>
</a-col>
</a-row>
<a-row>
<a-col :span="24">
<div v-if="isBodyShown" class="text-gray-600">
<div class="flex items-center">
<em
>{{ $t('msg.webhookBodyMsg1') }} <strong>{{ $t('msg.webhookBodyMsg2') }}</strong>
{{ $t('msg.webhookBodyMsg3') }}</em
>
<a-tooltip bottom>
<template #title>
<span>
<strong>{{ $t('general.data') }}</strong> : {{ $t('title.rowData') }} <br />
</span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
</div>
<div class="my-3">
<a
href="https://docs.nocodb.com/automation/webhook/create-webhook/#webhook-with-custom-payload-"
target="_blank"
rel="noopener"
>
<!-- Document Reference -->
{{ $t('labels.docReference') }}
</a>
</div>
</div>
<LazyWebhookTest
ref="webhookTestRef"
:hook="{
...hookRef,
notification: {
...hookRef.notification,
payload: hookRef.notification.payload,
},
}"
/>
</a-col>
</a-row>
</a-form-item>
</a-form>
</div>
<div v-if="showLogs" class="nc-webhook-calllog flex w-1/2">
<LazyWebhookCallLog :hook="hookRef" />
</div>
</div>
</template>
<style lang="scss" scoped>
.circle {
width: 0.6rem;
height: 0.6rem;
background-color: #ffffff;
border-radius: 50%;
position: relative;
}
.dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-52.5%, -52.5%);
}
:deep(.ant-tabs-tab) {
@apply border-r-0 border-l-0 border-t-0 !px-4 !bg-inherit !border-b-2 border-transparent text-gray-600;
}
:deep(.ant-tabs-tab-active) {
@apply !px-4 !border-primary;
}
.form-field-header {
@apply mb-1;
}
</style>

169
packages/nc-gui/components/webhook/List.vue

@ -1,169 +0,0 @@
<script setup lang="ts">
import type { FilterReqType, HookReqType, HookType } from 'nocodb-sdk'
const emit = defineEmits(['edit', 'add'])
const { t } = useI18n()
const { $api, $e } = useNuxtApp()
const hooks = ref<HookType[]>([])
const meta = inject(MetaInj, ref())
async function loadHooksList() {
try {
const hookList = (await $api.dbTableWebhook.list(meta.value?.id as string)).list
hooks.value = hookList.map((hook) => {
hook.notification = parseProp(hook.notification)
return hook
})
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function deleteHook(item: HookType, index: number) {
Modal.confirm({
title: `Do you want to delete '${item.title}'?`,
wrapClassName: 'nc-modal-hook-delete',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
if (item.id) {
await $api.dbTableWebhook.delete(item.id)
hooks.value.splice(index, 1)
} else {
hooks.value.splice(index, 1)
}
// Hook deleted successfully
message.success(t('msg.success.webhookDeleted'))
if (!hooks.value.length) {
hooks.value = []
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:webhook:delete')
},
})
}
async function copyHook(hook: HookType) {
try {
const newHook = await $api.dbTableWebhook.create(hook.fk_model_id!, {
...hook,
title: `${hook.title} - Copy`,
active: false,
} as HookReqType)
if (newHook) {
$e('a:webhook:copy')
// create the corresponding filters
const hookFilters = (await $api.dbTableWebhookFilter.read(hook.id!, {})).list
for (const hookFilter of hookFilters) {
await $api.dbTableWebhookFilter.create(newHook.id!, {
comparison_op: hookFilter.comparison_op,
comparison_sub_op: hookFilter.comparison_sub_op,
fk_column_id: hookFilter.fk_column_id,
fk_parent_id: hookFilter.fk_parent_id,
is_group: hookFilter.is_group,
logical_op: hookFilter.logical_op,
value: hookFilter.value,
} as FilterReqType)
}
newHook.notification = parseProp(newHook.notification)
hooks.value = [newHook, ...hooks.value]
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
onMounted(() => {
loadHooksList()
})
</script>
<template>
<div class="">
<div class="mb-2">
<div class="float-left font-bold text-xl mt-2 mb-4">{{ meta?.title }} : Webhooks</div>
<a-button
v-e="['c:webhook:add']"
class="float-right !rounded-md nc-btn-create-webhook"
type="primary"
size="middle"
@click="emit('add')"
>
{{ $t('activity.addWebhook') }}
</a-button>
</div>
<a-divider />
<div v-if="hooks.length" class="">
<a-list item-layout="horizontal" :data-source="hooks" class="cursor-pointer scrollbar-thin-primary">
<template #renderItem="{ item, index }">
<a-list-item class="p-2 nc-hook" @click="emit('edit', item)">
<a-list-item-meta>
<template #description>
<span class="uppercase"> {{ item.event }} {{ item.operation.replace(/[A-Z]/g, ' $&') }}</span>
</template>
<template #title>
<div class="text-xl normal-case">
<span class="text-gray-400 text-sm"> ({{ item.version }}) </span>
{{ item.title }}
</div>
</template>
<template #avatar>
<div class="px-2">
<component :is="iconMap.hook" class="text-xl" />
</div>
<div class="px-2 text-white rounded" :class="{ 'bg-green-500': item.active, 'bg-gray-500': !item.active }">
{{ item.active ? 'ON' : 'OFF' }}
</div>
</template>
</a-list-item-meta>
<template #extra>
<div>
<!-- Notify Via -->
<div class="mr-2">{{ $t('labels.notifyVia') }} : {{ item?.notification?.type }}</div>
<div class="float-right pt-2 pr-1">
<a-tooltip v-if="item.version === 'v2'" placement="left">
<template #title>
{{ $t('activity.copyWebhook') }}
</template>
<component :is="iconMap.copy" class="text-xl nc-hook-copy-icon" @click.stop="copyHook(item)" />
</a-tooltip>
<a-tooltip placement="left">
<template #title>
{{ $t('activity.deleteWebhook') }}
</template>
<component :is="iconMap.delete" class="text-xl nc-hook-delete-icon" @click.stop="deleteHook(item, index)" />
</a-tooltip>
</div>
</div>
</template>
</a-list-item>
</template>
</a-list>
</div>
<div v-else class="min-h-[75vh]">
<div class="p-4 bg-gray-100 text-gray-600">
Webhooks list is empty, create new webhook by clicking 'Create webhook' button.
</div>
</div>
</div>
</template>

24
packages/nc-gui/components/webhook/Modal.vue

@ -1,24 +0,0 @@
<script lang="ts" setup>
const isOpen = ref(false)
const editOrAdd = ref(false)
const currentHook = ref<Record<string, any>>()
async function editHook(hook: Record<string, any>) {
editOrAdd.value = true
currentHook.value = hook
}
async function addHook() {
editOrAdd.value = true
currentHook.value = undefined
}
</script>
<template>
<GeneralModal v-model:visible="isOpen">
<LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" />
<LazyWebhookList v-else @edit="editHook" @add="addHook" />
</GeneralModal>
</template>

62
packages/nc-gui/components/webhook/Test.vue

@ -1,62 +0,0 @@
<script setup lang="ts">
import type { HookTestReqType, HookType } from 'nocodb-sdk'
interface Props {
hook: HookType
}
const { hook } = defineProps<Props>()
const { t } = useI18n()
const { $api } = useNuxtApp()
const meta = inject(MetaInj, ref())
const sampleData = ref()
watch(
() => hook?.operation,
async () => {
await loadSampleData()
},
)
async function loadSampleData() {
sampleData.value = await $api.dbTableWebhook.samplePayloadGet(
meta?.value?.id as string,
hook?.operation || 'insert',
hook.version!,
)
}
async function testWebhook() {
try {
await $api.dbTableWebhook.test(
meta.value?.id as string,
{
hook,
payload: sampleData.value,
} as HookTestReqType,
)
// Webhook tested successfully
message.success(t('msg.success.webhookTested'))
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
defineExpose({
testWebhook,
})
onMounted(async () => {
await loadSampleData()
})
</script>
<template>
<div class="mb-4 font-weight-medium">Sample Payload</div>
<LazyMonacoEditor v-model="sampleData" class="min-h-60 max-h-80" />
</template>

1149
packages/nc-gui/components/webhook/index.vue

File diff suppressed because it is too large Load Diff

8
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -172,7 +172,7 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
v-model:value="userSearchText"
allow-clear
:disabled="isCollaboratorsLoading"
class="nc-collaborator-list-search-input !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
class="nc-input-border-on-value !max-w-90 !h-8 !px-3 !py-1 !rounded-lg"
placeholder="Search members"
>
<template #prefix>
@ -315,12 +315,6 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
@apply text-gray-500;
}
:deep(.ant-input-affix-wrapper.nc-collaborator-list-search-input) {
&:not(:has(.ant-input-clear-icon-hidden)):has(.ant-input-clear-icon) {
@apply border-[var(--ant-primary-5)];
}
}
.badge-text {
@apply text-[14px] pt-1 text-center;
}

18
packages/nc-gui/composables/useUserSorts.ts

@ -1,5 +1,6 @@
import rfdc from 'rfdc'
import { OrderedOrgRoles, OrderedProjectRoles, OrderedWorkspaceRoles } from 'nocodb-sdk'
import dayjs from 'dayjs'
import type { UsersSortType } from '~/lib/types'
/**
@ -8,7 +9,7 @@ import type { UsersSortType } from '~/lib/types'
* @param {string} roleType - The type of role for which user sorts are managed ('Workspace', 'Org', or 'Project').
* @returns {object} An object containing reactive values and functions related to user sorts.
*/
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organization') {
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organization' | 'Webhook') {
const clone = rfdc()
const { user } = useGlobal()
@ -147,6 +148,21 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organi
return b[sortsConfig.field] - a[sortsConfig.field]
}
}
case 'webhook-operation-type':{
if (sortsConfig.direction === 'asc') {
return `${a?.event} ${a?.operation}`?.localeCompare(`${b?.event} ${b?.operation}`)
} else {
return `${b?.event} ${b?.operation}`?.localeCompare(`${a?.event} ${a?.operation}`)
}
}
case 'created_at':
case 'updated_at': {
if (sortsConfig.direction === 'asc') {
return dayjs(a[sortsConfig.field]).isAfter(dayjs(b[sortsConfig.field])) ? 1 : -1
} else {
return dayjs(a[sortsConfig.field]).isBefore(dayjs(b[sortsConfig.field])) ? 1 : -1
}
}
}
return 0

11
packages/nc-gui/lang/en.json

@ -93,6 +93,8 @@
"none": "None"
},
"general": {
"on": "On",
"onMultiple": "On Multiple",
"addLookupField": "Add {count} lookup fields",
"role": "Role",
"general": "General",
@ -272,6 +274,7 @@
"use": "Use",
"stack": "Stack",
"ipAddress": "IP Address",
"trigger": "Trigger",
"integration": "Integration",
"integrations": "Integrations",
"connection": "Connection",
@ -422,6 +425,7 @@
"isNotNull": "is not null"
},
"title": {
"searchWebhook": "Search webhook",
"webcam": "Webcam",
"uploadViaUrl": "Upload via URL",
"localFiles": "Local files",
@ -557,6 +561,11 @@
"directlyInRealTime": "Directly in real time"
},
"labels": {
"recordInsert": "Record Insert",
"recordUpdate": "Record Update",
"recordDelete": "Record Delete",
"supportDocs": "Support Docs",
"addedOn": "Added on",
"changeDisplayValueField": "Change display value field",
"selectYourNewTitleFor": "Select your new display value field for ",
"searchDisplayValue": "Select display value field",
@ -913,6 +922,7 @@
"syncDataModalSubtitle" : "Register the services you are interested in to get notified when they become available"
},
"activity": {
"webhookDetails": "Webhook Details",
"hideWeekends": "Hide weekends",
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
@ -1088,6 +1098,7 @@
"addWebhook": "Add New Webhook",
"enableWebhook": "Enable Webhook",
"testWebhook": "Test Webhook",
"createWebhook": "Create Webhook",
"copyWebhook": "Copy Webhook",
"deleteWebhook": "Delete Webhook",
"newToken": "Add New Token",

2
packages/nc-gui/lib/types.ts

@ -221,7 +221,7 @@ interface SidebarTableNode extends TableType {
}
interface UsersSortType {
field?: 'email' | 'roles' | 'title' | 'id' | 'memberCount' | 'baseCount' | 'workspaceCount'
field?: 'email' | 'roles' | 'title' | 'id' | 'memberCount' | 'baseCount' | 'workspaceCount' | 'created_at'
direction?: 'asc' | 'desc'
}

14
packages/nc-gui/utils/iconUtils.ts

@ -243,6 +243,12 @@ import NcZendesk from '~icons/nc-icons/zendesk'
import NcBookOpen from '~icons/nc-icons/book-open'
import NcCircleCheckSolid from '~icons/nc-icons/check-circle-solid'
import NcAlertTriangleSolid from '~icons/nc-icons/alert-triangle-solid'
import NcMail from '~icons/nc-icons/mail'
import NcSlack from '~icons/nc-icons/slack'
import NcMicrosoftTeams from '~icons/nc-icons/microsoft-teams'
import NcMattermost from '~icons/nc-icons/mattermost'
import NcTwilio from '~icons/nc-icons/twilio'
import NcWhatsapp from '~icons/nc-icons/whatsapp'
// keep it for reference
// todo: remove it after all icons are migrated
@ -712,7 +718,13 @@ export const iconMap = {
zendesk: NcZendesk,
bookOpen: NcBookOpen,
circleCheckSolid: NcCircleCheckSolid,
alertTriangleSolid: NcAlertTriangleSolid
alertTriangleSolid: NcAlertTriangleSolid,
mail: NcMail,
slack: NcSlack,
microsoftTeams: NcMicrosoftTeams,
mattermost: NcMattermost,
twilio: NcTwilio,
whatsapp: NcWhatsapp,
}
export const getMdiIcon = (type: string): any => {

34
packages/noco-docs/docs/130.automation/020.webhook/020.create-webhook.md

@ -21,14 +21,14 @@ keywords: ['NocoDB webhook', 'create webhook']
1. Name of the webhook
2. Select the event for which webhook needs to be triggered
| Trigger | Details |
|-------------------|-------------------------------------|
| After Insert | After a single record is inserted |
| After Update | After a single record is updated |
| After Delete | After a single record is deleted |
| After Bulk Insert | After bulk records are inserted |
| After Bulk Update | After bulk records are updated |
| After Bulk Delete | After bulk records are deleted |
| Trigger | Details |
|------------------------------|-------------------------------------|
| After Record Insert | After a single record is inserted |
| After Record Update | After a single record is updated |
| After Record Delete | After a single record is deleted |
| After Multiple Record Insert | After bulk records are inserted |
| After Multiple Record Update | After bulk records are updated |
| After Multiple Record Delete | After bulk records are deleted |
3. Method & URL: Configure the endpoint to which webhook needs to be triggered. Supported methods are GET, POST, DELETE, PUT, HEAD, PATCH
4. Headers & Parameters: Configure Request headers & parameters (optional)
@ -253,22 +253,12 @@ In summary, webhook will be triggered only when `Condition(old-record) = false`
</Tabs>
[//]: # (## Call Log)
[//]: # ()
[//]: # (Call Log allows user to check the call history of the hook. By default, it has been disabled. However, it can be configured by using environment variable `NC_AUTOMATION_LOG_LEVEL`.)
[//]: # ()
[//]: # (- `NC_AUTOMATION_LOG_LEVEL=OFF`: No logs will be displayed and no history will be inserted to meta database.)
[//]: # (- `NC_AUTOMATION_LOG_LEVEL=ERROR`: only error logs will be displayed and history of error logs will be inserted to meta database.)
[//]: # (- `NC_AUTOMATION_LOG_LEVEL=ALL`: Both error and success logs will be displayed and history of both types of logs will be inserted to meta database. **This option is only available for Enterprise Edition.**)
### Webhook with custom payload ☁
[//]: # ()
[//]: # (![image]&#40;https://user-images.githubusercontent.com/35857179/228790148-1e3f21c7-9385-413a-843f-b93073ca6bea.png&#41;)
:::info
This feature is only available in the paid plans, in both cloud & self-hosted.
:::
### Webhook with custom payload ☁
In the enterprise edition, you can set up a personalized payload for your webhook. Just head to the `Body` tab to make the necessary configurations. Users can utilize [handlebar syntax](https://handlebarsjs.com/guide/#simple-expressions), which allows them to access and manipulate the data easily.
Use `{{ json event }}` to access the event data. Sample response is as follows

BIN
packages/noco-docs/static/img/v2/automations/webhooks/create-webhook-2.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 260 KiB

BIN
packages/noco-docs/static/img/v2/webhook/webhook-list-2.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 221 KiB

BIN
packages/noco-docs/static/img/v2/webhook/webhook-list-3.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 223 KiB

20
tests/playwright/pages/Dashboard/Details/WebhookPage.ts

@ -22,11 +22,11 @@ export class WebhookPage extends BasePage {
super(details.rootPage);
this.detailsPage = details;
this.addHookButton = this.get().locator('.nc-view-sidebar-webhook-plus-icon:visible');
this.webhookItems = this.get().locator('.nc-view-sidebar-webhook-item');
this.webhookItems = this.get().locator('.nc-table-row');
}
get() {
return this.detailsPage.get().locator('.nc-view-sidebar-webhook');
return this.detailsPage.get().locator('.nc-table-wrapper');
}
async itemCount() {
@ -51,10 +51,18 @@ export class WebhookPage extends BasePage {
.click();
}
async itemContextMenu({ index, operation }: { index: number; operation: 'edit' | 'duplicate' | 'delete' }) {
await (await this.getItem({ index })).getByTestId('nc-webhook-item-action').click();
const contextMenu = this.rootPage.locator('.nc-webhook-item-action-dropdown:visible');
await contextMenu.waitFor({ state: 'visible' });
await contextMenu.getByTestId(`nc-webhook-item-action-${operation}`).click();
await contextMenu.waitFor({ state: 'hidden' });
}
async deleteHook({ index }: { index: number }) {
const hookItem = await this.getItem({ index });
await hookItem.hover();
await hookItem.locator('.nc-button.nc-btn-webhook-more').click({ force: true });
await this.rootPage.locator('.ant-dropdown:visible').locator('button.ant-btn:has-text("Delete")').click();
return await this.itemContextMenu({ index, operation: 'delete' });
}
}

29
tests/playwright/pages/Dashboard/WebhookForm/index.ts

@ -19,17 +19,19 @@ export class WebhookFormPage extends BasePage {
this.toolbar = dashboard.grid.toolbar;
this.topbar = dashboard.grid.topbar;
this.addNewButton = this.dashboard.get().locator('.nc-btn-create-webhook');
this.saveButton = this.get().locator('button:has-text("Save")');
this.saveButton = this.get().getByTestId('nc-save-webhook');
this.testButton = this.get().locator('button:has-text("Test Webhook")');
}
get() {
return this.dashboard.get().locator(`.nc-view-sidebar-webhook`);
return this.rootPage.locator(`.nc-modal-webhook-create-edit`);
}
async create({ title, event, url = 'http://localhost:9090/hook' }: { title: string; event: string; url?: string }) {
await this.dashboard.grid.topbar.openDetailedTab();
await this.dashboard.details.clickWebhooksTab();
// wait for tab transition
await this.rootPage.waitForTimeout(200);
await this.dashboard.details.clickAddWebhook();
await this.get().waitFor({ state: 'visible' });
@ -108,7 +110,7 @@ export class WebhookFormPage extends BasePage {
httpMethodsToMatch: ['POST', 'PATCH'],
});
await this.verifyToast({ message: 'Webhook details updated successfully' });
// await this.verifyToast({ message: 'Webhook details updated successfully' });
}
async test() {
@ -125,7 +127,8 @@ export class WebhookFormPage extends BasePage {
async close() {
// type esc key
await this.get().press('Escape');
// await this.get().press('Escape');
// await this.get().waitFor({ state: 'hidden' });
await this.dashboard.grid.topbar.openDataTab();
}
@ -133,7 +136,14 @@ export class WebhookFormPage extends BasePage {
await this.dashboard.grid.topbar.openDetailedTab();
await this.dashboard.details.clickWebhooksTab();
await (await this.dashboard.details.webhook.getItem({ index })).click();
const rowItem = await this.dashboard.details.webhook.getItem({ index });
await rowItem.waitFor({ state: 'visible' });
await rowItem.scrollIntoViewIfNeeded();
await rowItem.click({
force: true,
});
await this.get().waitFor({ state: 'visible' });
}
@ -164,17 +174,16 @@ export class WebhookFormPage extends BasePage {
.locator(`.ant-select-item:has-text("${key}")`)
.click({ force: true });
await this.get().locator('.nc-input-hook-header-value').clear();
await this.get().locator('.nc-input-hook-header-value').type(value);
await this.get().locator('.nc-webhook-header-value-input').clear();
await this.get().locator('.nc-webhook-header-value-input').type(value);
await this.get().press('Enter');
// find out if the checkbox is already checked
const isChecked = await this.get()
.locator('.nc-hook-header-tab-checkbox')
.locator('.nc-hook-header-checkbox')
.locator('input.ant-checkbox-input')
.isChecked();
if (!isChecked)
await this.get().locator('.nc-hook-header-tab-checkbox').locator('input.ant-checkbox-input').click();
if (!isChecked) await this.get().locator('.nc-hook-header-checkbox').locator('input.ant-checkbox-input').click();
}
async verifyForm({

28
tests/playwright/tests/db/features/webhook.spec.ts

@ -158,7 +158,7 @@ test.describe.serial('Webhook', () => {
// after insert hook
await webhook.create({
title: 'hook-1',
event: 'After Insert',
event: 'On Record Insert',
});
await clearServerData({ request });
await dashboard.grid.addNewRow({
@ -191,7 +191,7 @@ test.describe.serial('Webhook', () => {
// after update hook
await webhook.create({
title: 'hook-2',
event: 'After Update',
event: 'On Record Update',
});
await clearServerData({ request });
@ -226,7 +226,7 @@ test.describe.serial('Webhook', () => {
// after delete hook
await webhook.create({
title: 'hook-3',
event: 'After Delete',
event: 'On Record Delete',
});
await clearServerData({ request });
await dashboard.grid.addNewRow({
@ -262,14 +262,14 @@ test.describe.serial('Webhook', () => {
await webhook.open({ index: 0 });
await webhook.configureWebhook({
title: 'hook-1-modified',
event: 'After Delete',
event: 'On Record Delete',
});
await webhook.save();
await webhook.close();
await webhook.open({ index: 1 });
await webhook.configureWebhook({
title: 'hook-2-modified',
event: 'After Delete',
event: 'On Record Delete',
});
await webhook.save();
await webhook.close();
@ -333,17 +333,17 @@ test.describe.serial('Webhook', () => {
// after insert hook
await webhook.create({
title: 'hook-1',
event: 'After Insert',
event: 'On Record Insert',
});
// after insert hook
await webhook.create({
title: 'hook-2',
event: 'After Update',
event: 'On Record Update',
});
// after insert hook
await webhook.create({
title: 'hook-3',
event: 'After Delete',
event: 'On Record Delete',
});
await webhook.open({ index: 0 });
@ -521,15 +521,15 @@ test.describe.serial('Webhook', () => {
// create after insert webhook
await webhook.create({
title: 'hook-1',
event: 'After Bulk Insert',
event: 'On Multiple Record Insert',
});
await webhook.create({
title: 'hook-1',
event: 'After Bulk Update',
event: 'On Multiple Record Update',
});
await webhook.create({
title: 'hook-1',
event: 'After Bulk Delete',
event: 'On Multiple Record Delete',
});
await clearServerData({ request });
@ -694,7 +694,7 @@ test.describe.serial('Webhook', () => {
// after update hook
await webhook.create({
title: 'hook-2',
event: 'After Update',
event: 'On Record Update',
});
// clear server data
@ -793,12 +793,12 @@ test.describe.serial('Webhook', () => {
// after insert hook
await webhook.create({
title: 'hook-1',
event: 'After Delete',
event: 'On Record Delete',
});
// after insert hook
await webhook.create({
title: 'hook-2',
event: 'After Bulk Delete',
event: 'On Multiple Record Delete',
});
const titles = ['Poole', 'Delaware', 'Pabalo', 'John', 'Vicky', 'Tom'];

Loading…
Cancel
Save